En del opdateringer
This commit is contained in:
772
Henriks Musik/linedance_tag_analyse.py
Normal file
772
Henriks Musik/linedance_tag_analyse.py
Normal file
@@ -0,0 +1,772 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
linedance_tag_analyse.py
|
||||
|
||||
Scanner en mappe med MP3-filer og analyserer om de følger mønsteret:
|
||||
TIT2 = dansens navn
|
||||
TALB = sangens rigtige titel
|
||||
TCON = niveau
|
||||
|
||||
Verificerer via MusicBrainz API og gemmer resultat i CSV til gennemgang.
|
||||
|
||||
Brug:
|
||||
python linedance_tag_analyse.py /sti/til/musik [--output rapport.csv] [--apply godkendt.csv]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
import struct
|
||||
import argparse
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
# ── Konfiguration ──────────────────────────────────────────────────────────────
|
||||
ACOUSTID_API_KEY = "6fd9DGNDqG" # Erstat med din egen
|
||||
ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
|
||||
MUSICBRAINZ_URL = "https://musicbrainz.org/ws/2/recording"
|
||||
MB_USER_AGENT = "LineDanceTagFixer/1.0 (dit@email.dk)" # Skal udfyldes
|
||||
API_DELAY = 1.1 # Sek mellem MusicBrainz-kald (max 1/sek)
|
||||
ACOUSTID_DELAY = 0.4
|
||||
MATCH_THRESHOLD = 0.80 # Mindste lighed for "sikker" match
|
||||
FPCALC_PATH = "fpcalc" # Eller fuld sti f.eks. C:\Tools\fpcalc.exe
|
||||
|
||||
# ── Hjælpefunktioner ──────────────────────────────────────────────────────────
|
||||
|
||||
def normalize(s: str) -> str:
|
||||
"""Normaliser streng til sammenligning — lowercase, fjern specialtegn."""
|
||||
import unicodedata, re
|
||||
s = unicodedata.normalize("NFKD", s or "")
|
||||
s = s.encode("ascii", "ignore").decode()
|
||||
s = re.sub(r"[^a-z0-9 ]", "", s.lower())
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
def similarity(a: str, b: str) -> float:
|
||||
"""Simpel tegnbaseret lighed 0-1."""
|
||||
a, b = normalize(a), normalize(b)
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
# Longest common subsequence approximation via set overlap
|
||||
set_a = set(a.split())
|
||||
set_b = set(b.split())
|
||||
if not set_a or not set_b:
|
||||
return 0.0
|
||||
overlap = len(set_a & set_b)
|
||||
return overlap / max(len(set_a), len(set_b))
|
||||
|
||||
# ── Niveau-normalisering ──────────────────────────────────────────────────────
|
||||
# Officielle niveauer fra Linedancer Guide to Level Definitions (2017):
|
||||
# Absolute Beginner → Beginner → Improver → Intermediate → Advanced
|
||||
#
|
||||
# Vi gemmer dem på engelsk da det er den internationale standard.
|
||||
# Mapper alle kendte varianter til officiel betegnelse.
|
||||
|
||||
NIVEAU_MAP = {
|
||||
# ── Absolute Beginner ──
|
||||
"absolute beginner": "Absolute Beginner",
|
||||
"abs. beginner": "Absolute Beginner",
|
||||
"abs beginner": "Absolute Beginner",
|
||||
"absolute beg": "Absolute Beginner",
|
||||
"ab": "Absolute Beginner",
|
||||
"absolut begynder": "Absolute Beginner",
|
||||
|
||||
# ── Beginner ──
|
||||
"beginner": "Beginner",
|
||||
"beg": "Beginner",
|
||||
"begin": "Beginner",
|
||||
"begynder": "Beginner",
|
||||
"newbie": "Beginner",
|
||||
"basic": "Beginner",
|
||||
|
||||
# ── High Beginner ──
|
||||
"high beginner": "High Beginner",
|
||||
"high beg": "High Beginner",
|
||||
|
||||
# ── Low Improver ──
|
||||
"low improver": "Low Improver",
|
||||
"low imp": "Low Improver",
|
||||
"beginner/improver": "Low Improver",
|
||||
"beg/imp": "Low Improver",
|
||||
"beg/improver": "Low Improver",
|
||||
|
||||
# ── Improver ──
|
||||
"improver": "Improver",
|
||||
"imp": "Improver",
|
||||
"easy": "Improver",
|
||||
"easy intermediate": "Improver",
|
||||
"easy/intermediate": "Improver",
|
||||
"easy inter": "Improver",
|
||||
"let øvet": "Improver",
|
||||
"let": "Improver",
|
||||
|
||||
# ── High Improver ──
|
||||
"high improver": "High Improver",
|
||||
"high imp": "High Improver",
|
||||
"improver/intermediate": "High Improver",
|
||||
"imp/int": "High Improver",
|
||||
|
||||
# ── Low Intermediate ──
|
||||
"low intermediate": "Low Intermediate",
|
||||
"low inter": "Low Intermediate",
|
||||
"low int": "Low Intermediate",
|
||||
|
||||
# ── Intermediate ──
|
||||
"intermediate": "Intermediate",
|
||||
"inter": "Intermediate",
|
||||
"int": "Intermediate",
|
||||
"øvet": "Intermediate",
|
||||
|
||||
# ── High Intermediate ──
|
||||
"high intermediate": "High Intermediate",
|
||||
"high inter": "High Intermediate",
|
||||
"high int": "High Intermediate",
|
||||
"intermediate/advanced": "High Intermediate",
|
||||
"int/adv": "High Intermediate",
|
||||
|
||||
# ── Advanced ──
|
||||
"advanced": "Advanced",
|
||||
"adv": "Advanced",
|
||||
"videregående": "Advanced",
|
||||
}
|
||||
|
||||
def normaliser_niveau(raw: str) -> tuple[str, bool]:
|
||||
"""
|
||||
Returner (normaliseret_niveau, fundet).
|
||||
fundet=True hvis vi genkender niveauet.
|
||||
"""
|
||||
if not raw:
|
||||
return "", False
|
||||
key = raw.strip().lower()
|
||||
if key in NIVEAU_MAP:
|
||||
return NIVEAU_MAP[key], True
|
||||
# Delvis match — find længste nøgle der er indeholdt
|
||||
best_match = ""
|
||||
best_len = 0
|
||||
for k, v in NIVEAU_MAP.items():
|
||||
if k in key and len(k) > best_len:
|
||||
best_match = v
|
||||
best_len = len(k)
|
||||
if best_match:
|
||||
return best_match, True
|
||||
return raw.strip(), False
|
||||
|
||||
|
||||
|
||||
def read_id3(path: str) -> dict:
|
||||
"""Læs relevante ID3v2 tags fra MP3 uden eksterne biblioteker."""
|
||||
result = {
|
||||
"tit2": "", "tpe1": "", "talb": "", "tcon": "",
|
||||
"mbid": "", "linedance_dances": []
|
||||
}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(65536)
|
||||
|
||||
if data[:3] != b"ID3":
|
||||
return result
|
||||
|
||||
major = data[3]
|
||||
tag_size = ((data[6]&0x7f)<<21)|((data[7]&0x7f)<<14)|((data[8]&0x7f)<<7)|(data[9]&0x7f)
|
||||
pos = 10
|
||||
|
||||
while pos < min(tag_size + 10, len(data) - 10):
|
||||
if major == 3:
|
||||
fid = data[pos:pos+4].decode("latin1", errors="replace")
|
||||
fsize = struct.unpack(">I", data[pos+4:pos+8])[0]
|
||||
pos += 10
|
||||
else: # v2.4
|
||||
fid = data[pos:pos+4].decode("latin1", errors="replace")
|
||||
fsize = struct.unpack(">I", data[pos+4:pos+8])[0]
|
||||
pos += 10
|
||||
|
||||
if fid == "\x00\x00\x00\x00" or fsize <= 0 or fsize > 100000:
|
||||
break
|
||||
|
||||
content = data[pos:pos+fsize]
|
||||
pos += fsize
|
||||
|
||||
try:
|
||||
if fid.startswith("T") and len(content) > 1:
|
||||
enc = content[0]
|
||||
raw = content[1:]
|
||||
if enc in (1, 2):
|
||||
txt = raw.decode("utf-16", errors="replace")
|
||||
elif enc == 3:
|
||||
txt = raw.decode("utf-8", errors="replace")
|
||||
else:
|
||||
txt = raw.decode("latin1", errors="replace")
|
||||
txt = txt.strip("\x00").strip()
|
||||
|
||||
if fid == "TIT2":
|
||||
result["tit2"] = txt
|
||||
elif fid == "TPE1":
|
||||
result["tpe1"] = txt
|
||||
elif fid == "TALB":
|
||||
result["talb"] = txt
|
||||
elif fid == "TCON":
|
||||
result["tcon"] = txt
|
||||
elif fid == "TXXX":
|
||||
# Format: desc\x00value
|
||||
parts = txt.split("\x00", 1)
|
||||
if len(parts) == 2:
|
||||
desc, val = parts
|
||||
else:
|
||||
desc, val = txt, ""
|
||||
desc_clean = desc.strip()
|
||||
val_clean = val.strip()
|
||||
|
||||
if desc_clean in ("MusicBrainz Recording Id", "MusicBrainz Track Id"):
|
||||
result["mbid"] = val_clean
|
||||
elif desc_clean.startswith("LINEDANCE_DANCE_"):
|
||||
result["linedance_dances"].append(val_clean)
|
||||
|
||||
elif fid == "UFID" and len(content) > 4:
|
||||
# UFID: owner\x00data
|
||||
null = content.find(b"\x00")
|
||||
if null >= 0:
|
||||
owner = content[:null].decode("latin1", errors="replace")
|
||||
if "musicbrainz" in owner.lower():
|
||||
result["mbid"] = content[null+1:].decode("utf-8", errors="replace").strip("\x00")
|
||||
|
||||
elif fid == "COMM" and len(content) > 4:
|
||||
enc = content[0]
|
||||
raw = content[4:]
|
||||
if enc in (1, 2):
|
||||
txt = raw.decode("utf-16", errors="replace")
|
||||
else:
|
||||
txt = raw.decode("utf-8", errors="replace")
|
||||
result["comm"] = txt.strip("\x00").strip()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── AcoustID fingerprinting ────────────────────────────────────────────────────
|
||||
|
||||
def fingerprint_file(path: str) -> tuple[str, int] | None:
|
||||
"""Kør fpcalc og returner (fingerprint, duration)."""
|
||||
fpcalc = find_fpcalc()
|
||||
if not fpcalc:
|
||||
return None
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[fpcalc, "-json", path],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
d = json.loads(r.stdout)
|
||||
fp = d.get("fingerprint")
|
||||
dur = int(d.get("duration", 0))
|
||||
if fp and dur:
|
||||
return fp, dur
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_fpcalc() -> str | None:
|
||||
"""Find fpcalc på systemet — returner sti eller None."""
|
||||
import shutil
|
||||
# Prøv konfigureret sti først
|
||||
if shutil.which(FPCALC_PATH):
|
||||
return FPCALC_PATH
|
||||
# Prøv kendte Windows-stier
|
||||
windows_paths = [
|
||||
r"C:\Program Files\fpcalc\fpcalc.exe",
|
||||
r"C:\Tools\fpcalc.exe",
|
||||
r"C:\fpcalc.exe",
|
||||
os.path.join(os.path.dirname(__file__), "fpcalc.exe"),
|
||||
os.path.join(os.path.dirname(__file__), "fpcalc"),
|
||||
]
|
||||
for p in windows_paths:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def lookup_acoustid(fingerprint: str, duration: int) -> str | None:
|
||||
"""Slå fingerprint op i AcoustID og returner MBID."""
|
||||
try:
|
||||
params = urllib.parse.urlencode({
|
||||
"client": ACOUSTID_API_KEY,
|
||||
"fingerprint": fingerprint,
|
||||
"duration": duration,
|
||||
"meta": "recordings",
|
||||
})
|
||||
req = urllib.request.Request(
|
||||
f"{ACOUSTID_API_URL}?{params}",
|
||||
headers={"User-Agent": MB_USER_AGENT}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
|
||||
if data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return None
|
||||
|
||||
best = max(results, key=lambda r: r.get("score", 0))
|
||||
if best.get("score", 0) < 0.85:
|
||||
return None
|
||||
|
||||
recordings = best.get("recordings", [])
|
||||
return recordings[0].get("id") if recordings else None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── MusicBrainz opslag ────────────────────────────────────────────────────────
|
||||
|
||||
def lookup_musicbrainz(mbid: str) -> dict | None:
|
||||
"""Slå MBID op i MusicBrainz og returner titel + kunstner."""
|
||||
try:
|
||||
url = f"{MUSICBRAINZ_URL}/{mbid}?fmt=json&inc=artists"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": MB_USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
|
||||
title = data.get("title", "")
|
||||
artists = data.get("artist-credit", [])
|
||||
artist = ""
|
||||
if artists:
|
||||
parts = []
|
||||
for a in artists:
|
||||
if isinstance(a, dict) and "artist" in a:
|
||||
parts.append(a["artist"].get("name", ""))
|
||||
elif isinstance(a, str):
|
||||
parts.append(a)
|
||||
artist = "".join(parts)
|
||||
|
||||
return {"title": title, "artist": artist} if title else None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── Hoved-analyse ─────────────────────────────────────────────────────────────
|
||||
|
||||
def analyse_file(path: str, use_acoustid: bool = True) -> dict:
|
||||
"""
|
||||
Analyser én fil og returner en dict med resultat.
|
||||
Status: SIKKER / SANDSYNLIG / USIKKER / UKENDT
|
||||
"""
|
||||
import re
|
||||
|
||||
# ── Værdier der ligner niveau men er genre/andet ──────────────────────────
|
||||
IKKE_NIVEAU = {
|
||||
"country", "rock", "pop", "jazz", "blues", "latin", "swing",
|
||||
"line dance", "linedance", "general country", "waltz", "cha cha",
|
||||
"rumba", "tango", "samba", "foxtrot", "quickstep", "two step",
|
||||
"west coast swing", "east coast swing",
|
||||
}
|
||||
|
||||
def dans_fra_filnavn(filename):
|
||||
"""Forsøg at udtrække dansenavn fra filnavn."""
|
||||
navn = os.path.splitext(filename)[0]
|
||||
# Mønster 1: starter med (dans) eller [dans]
|
||||
m = re.match(r"^[\(\[](.*?)[\)\]]", navn)
|
||||
if m:
|
||||
kandidat = m.group(1).strip()
|
||||
if len(kandidat) > 2 and not kandidat.isdigit():
|
||||
return kandidat
|
||||
# Mønster 2: slutter med (dans)
|
||||
m = re.search(r"[\(\[](.*?)[\)\]]\s*$", navn)
|
||||
if m:
|
||||
kandidat = m.group(1).strip()
|
||||
if len(kandidat) > 2 and not kandidat.isdigit():
|
||||
return kandidat
|
||||
return ""
|
||||
|
||||
result = {
|
||||
"fil": path,
|
||||
"filename": os.path.basename(path),
|
||||
"tit2": "",
|
||||
"tpe1": "",
|
||||
"talb": "",
|
||||
"tcon": "",
|
||||
"mbid": "",
|
||||
"mb_title": "",
|
||||
"mb_artist": "",
|
||||
"dans_forslag": "",
|
||||
"niveau_rå": "",
|
||||
"niveau_forslag": "",
|
||||
"niveau_genkendt": "",
|
||||
"rigtig_titel": "",
|
||||
"rigtig_artist": "",
|
||||
"match_score": 0.0,
|
||||
"status": "UKENDT",
|
||||
"noter": "",
|
||||
"handling": "",
|
||||
}
|
||||
|
||||
# 1. Læs tags
|
||||
tags = read_id3(path)
|
||||
result["tit2"] = tags.get("tit2", "")
|
||||
result["tpe1"] = tags.get("tpe1", "")
|
||||
result["talb"] = tags.get("talb", "")
|
||||
result["tcon"] = tags.get("tcon", "")
|
||||
result["mbid"] = tags.get("mbid", "")
|
||||
ld_dances = tags.get("linedance_dances", [])
|
||||
|
||||
noter = []
|
||||
|
||||
# Normaliser niveau fra TCON — filtrer genre-værdier fra
|
||||
niveau_rå = result["tcon"]
|
||||
if niveau_rå.strip().lower() in IKKE_NIVEAU:
|
||||
niveau_normaliseret, niveau_genkendt = "", False
|
||||
noter.append(f"TCON er genre, ikke niveau: '{niveau_rå}'")
|
||||
else:
|
||||
niveau_normaliseret, niveau_genkendt = normaliser_niveau(niveau_rå)
|
||||
result["niveau_rå"] = niveau_rå
|
||||
result["niveau_forslag"] = niveau_normaliseret
|
||||
result["niveau_genkendt"] = "JA" if niveau_genkendt else ("NEJ" if niveau_rå else "")
|
||||
|
||||
if niveau_rå and not niveau_genkendt:
|
||||
noter.append(f"Ukendt niveau-værdi: '{niveau_rå}'")
|
||||
|
||||
if tags.get("error"):
|
||||
result["status"] = "FEJL"
|
||||
result["noter"] = tags["error"]
|
||||
return result
|
||||
|
||||
# 2. Hent MBID hvis mangler
|
||||
mbid = result["mbid"]
|
||||
if not mbid and use_acoustid:
|
||||
fp = fingerprint_file(path)
|
||||
if fp:
|
||||
fingerprint, duration = fp
|
||||
time.sleep(ACOUSTID_DELAY)
|
||||
mbid = lookup_acoustid(fingerprint, duration)
|
||||
if mbid:
|
||||
result["mbid"] = mbid
|
||||
noter.append("MBID fundet via AcoustID")
|
||||
else:
|
||||
noter.append("AcoustID: ingen match")
|
||||
else:
|
||||
noter.append("fpcalc fejlede")
|
||||
|
||||
# 3. MusicBrainz opslag
|
||||
mb = None
|
||||
if mbid:
|
||||
time.sleep(API_DELAY)
|
||||
mb = lookup_musicbrainz(mbid)
|
||||
if mb:
|
||||
result["mb_title"] = mb["title"]
|
||||
result["mb_artist"] = mb["artist"]
|
||||
else:
|
||||
noter.append("MusicBrainz: ingen data for MBID")
|
||||
|
||||
# 4. Vurder mønster
|
||||
tit2 = result["tit2"]
|
||||
talb = result["talb"]
|
||||
tpe1 = result["tpe1"]
|
||||
|
||||
if mb:
|
||||
# Sammenlign TALB med MusicBrainz titel
|
||||
score_title = similarity(talb, mb["title"])
|
||||
score_artist = similarity(tpe1, mb["artist"])
|
||||
result["match_score"] = round((score_title + score_artist) / 2, 2)
|
||||
|
||||
if score_title >= MATCH_THRESHOLD:
|
||||
# TALB matcher rigtig titel → TIT2 er sandsynligvis dansen
|
||||
result["dans_forslag"] = tit2
|
||||
result["rigtig_titel"] = mb["title"]
|
||||
result["rigtig_artist"] = mb["artist"]
|
||||
result["niveau_forslag"] = result["tcon"]
|
||||
|
||||
if ld_dances:
|
||||
noter.append(f"LINEDANCE_DANCE_1 allerede sat: {ld_dances[0]}")
|
||||
if similarity(tit2, ld_dances[0]) > 0.7:
|
||||
result["status"] = "ALLEREDE_RETTET"
|
||||
result["handling"] = ""
|
||||
else:
|
||||
noter.append(f"TIT2 og LINEDANCE_DANCE_1 stemmer ikke overens!")
|
||||
result["status"] = "KONFLIKT"
|
||||
result["handling"] = "MANUEL"
|
||||
elif score_title >= 0.95 and score_artist >= MATCH_THRESHOLD:
|
||||
result["status"] = "SIKKER"
|
||||
result["handling"] = "RET_AUTOMATISK"
|
||||
elif score_title >= MATCH_THRESHOLD:
|
||||
result["status"] = "SANDSYNLIG"
|
||||
result["handling"] = "GODKEND_MANUEL"
|
||||
else:
|
||||
result["status"] = "USIKKER"
|
||||
result["handling"] = "MANUEL"
|
||||
else:
|
||||
noter.append(f"TALB matcher ikke MB titel (score={score_title:.2f}): '{talb}' vs '{mb['title']}'")
|
||||
result["status"] = "USIKKER"
|
||||
result["handling"] = "MANUEL"
|
||||
# Vis alligevel hvad MB siger
|
||||
result["rigtig_titel"] = mb["title"]
|
||||
result["rigtig_artist"] = mb["artist"]
|
||||
else:
|
||||
# Ingen MB data — vurder ud fra tags alene
|
||||
filename = os.path.basename(path)
|
||||
|
||||
if talb and tit2 and tit2 != talb:
|
||||
# Klassisk mønster: TIT2=dans, TALB=sang
|
||||
noter.append("Ingen MBID — mønster muligvis rigtigt men ukontrolleret")
|
||||
result["dans_forslag"] = tit2
|
||||
result["rigtig_titel"] = talb
|
||||
result["status"] = "UKONTROLLERET"
|
||||
result["handling"] = "GODKEND_MANUEL"
|
||||
|
||||
elif not tit2 and not talb:
|
||||
# Helt tomme tags — prøv filnavn
|
||||
dans_fn = dans_fra_filnavn(filename)
|
||||
if dans_fn:
|
||||
noter.append(f"Dans udtrukket fra filnavn: '{dans_fn}'")
|
||||
result["dans_forslag"] = dans_fn
|
||||
result["status"] = "FRA_FILNAVN"
|
||||
result["handling"] = "GODKEND_MANUEL"
|
||||
else:
|
||||
result["status"] = "UKENDT"
|
||||
result["handling"] = "MANUEL"
|
||||
|
||||
elif tit2 and tit2 == talb:
|
||||
# Titel = album — dans kan være i filnavn
|
||||
dans_fn = dans_fra_filnavn(filename)
|
||||
if dans_fn:
|
||||
noter.append(f"TIT2=TALB, dans fra filnavn: '{dans_fn}'")
|
||||
result["dans_forslag"] = dans_fn
|
||||
result["rigtig_titel"] = tit2
|
||||
result["status"] = "FRA_FILNAVN"
|
||||
result["handling"] = "GODKEND_MANUEL"
|
||||
else:
|
||||
result["status"] = "UKENDT"
|
||||
result["handling"] = "MANUEL"
|
||||
|
||||
else:
|
||||
result["status"] = "UKENDT"
|
||||
result["handling"] = "MANUEL"
|
||||
|
||||
result["noter"] = "; ".join(noter)
|
||||
return result
|
||||
|
||||
|
||||
# ── CSV output ─────────────────────────────────────────────────────────────────
|
||||
|
||||
FIELDNAMES = [
|
||||
"status", "handling", "filename",
|
||||
"dans_forslag", "niveau_forslag", "niveau_genkendt", "niveau_rå",
|
||||
"rigtig_titel", "rigtig_artist",
|
||||
"mb_title", "mb_artist", "match_score",
|
||||
"tit2", "tpe1", "talb", "tcon", "mbid",
|
||||
"noter", "fil"
|
||||
]
|
||||
|
||||
def write_csv(rows: list[dict], path: str):
|
||||
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
print(f"\nGemt: {path}")
|
||||
|
||||
|
||||
# ── Apply: udfør rettelser fra godkendt CSV ───────────────────────────────────
|
||||
|
||||
def apply_corrections(csv_path: str, dry_run: bool = True):
|
||||
"""Læs godkendt CSV og udfør rettelserne."""
|
||||
import shutil
|
||||
|
||||
with open(csv_path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
|
||||
to_fix = [r for r in rows if r.get("handling") in ("RET_AUTOMATISK", "GODKEND_MANUEL")]
|
||||
print(f"\n{len(to_fix)} filer skal rettes")
|
||||
if dry_run:
|
||||
print("(DRY RUN — ingen filer ændres endnu)\n")
|
||||
|
||||
ok = fejl = spring = 0
|
||||
|
||||
for row in to_fix:
|
||||
path = row["fil"]
|
||||
dans = row["dans_forslag"].strip()
|
||||
titel = row["rigtig_titel"].strip()
|
||||
artist = row["rigtig_artist"].strip() or row["tpe1"].strip()
|
||||
|
||||
if not dans or not titel:
|
||||
print(f" SPRING: {row['filename']} — mangler dans eller titel")
|
||||
spring += 1
|
||||
continue
|
||||
|
||||
print(f" {'[DRY]' if dry_run else '[RET]'} {row['filename']}")
|
||||
print(f" TIT2: '{row['tit2']}' → '{titel}'")
|
||||
print(f" TALB: '{row['talb']}' → beholdes")
|
||||
print(f" LINEDANCE_DANCE_1 → '{dans}'")
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
write_corrections(path, titel, artist, dans)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print(f" FEJL: {e}")
|
||||
fejl += 1
|
||||
else:
|
||||
ok += 1
|
||||
|
||||
print(f"\nResultat: {ok} ok, {fejl} fejl, {spring} sprunget over")
|
||||
|
||||
|
||||
def write_corrections(path: str, title: str, artist: str, dance: str):
|
||||
"""Skriv rettede tags til fil med mutagen."""
|
||||
try:
|
||||
from mutagen.id3 import ID3, TIT2, TPE1, TXXX, Encoding
|
||||
tags = ID3(path)
|
||||
|
||||
# Sæt rigtig titel
|
||||
tags["TIT2"] = TIT2(encoding=Encoding.UTF8, text=title)
|
||||
|
||||
# Sæt kunstner hvis vi har den
|
||||
if artist:
|
||||
tags["TPE1"] = TPE1(encoding=Encoding.UTF8, text=artist)
|
||||
|
||||
# Sæt LINEDANCE_DANCE_1
|
||||
for key in [k for k in tags.keys() if "LINEDANCE_DANCE_" in k]:
|
||||
del tags[key]
|
||||
tags.add(TXXX(encoding=Encoding.UTF8, desc="LINEDANCE_DANCE_1", text=dance))
|
||||
|
||||
tags.save(path)
|
||||
|
||||
except ImportError:
|
||||
# Fallback: skriv raw ID3 — kræver mutagen
|
||||
raise RuntimeError("mutagen skal installeres for at skrive tags: pip install mutagen")
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="LineDance tag-analyse og -rettelse")
|
||||
parser.add_argument("mappe", nargs="?", help="Mappe med MP3-filer")
|
||||
parser.add_argument("--output", default="linedance_rapport.csv", help="Output CSV")
|
||||
parser.add_argument("--apply", help="Anvend rettelser fra godkendt CSV")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Vis hvad der ville ske (med --apply)")
|
||||
parser.add_argument("--ingen-acoustid", action="store_true", help="Spring AcoustID over (hurtigere)")
|
||||
parser.add_argument("--max", type=int, default=0, help="Maks antal filer (0=alle)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Apply-tilstand
|
||||
if args.apply:
|
||||
apply_corrections(args.apply, dry_run=args.dry_run)
|
||||
return
|
||||
|
||||
if not args.mappe:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Find alle MP3-filer
|
||||
mp3_filer = []
|
||||
for root, dirs, files in os.walk(args.mappe):
|
||||
for f in files:
|
||||
if f.lower().endswith(".mp3"):
|
||||
mp3_filer.append(os.path.join(root, f))
|
||||
|
||||
mp3_filer.sort()
|
||||
if args.max:
|
||||
mp3_filer = mp3_filer[:args.max]
|
||||
|
||||
total = len(mp3_filer)
|
||||
print(f"\nFundet {total} MP3-filer i {args.mappe}")
|
||||
print(f"Output: {args.output}")
|
||||
|
||||
# Tjek fpcalc
|
||||
if not args.ingen_acoustid:
|
||||
fpcalc_sti = find_fpcalc()
|
||||
if fpcalc_sti:
|
||||
print(f"fpcalc: fundet ({fpcalc_sti})")
|
||||
print(f"AcoustID API: {ACOUSTID_API_KEY[:6]}...")
|
||||
else:
|
||||
print(f"fpcalc: IKKE FUNDET — AcoustID deaktiveres")
|
||||
print(f" Download fpcalc fra: https://acoustid.org/chromaprint")
|
||||
print(f" Placer fpcalc.exe i samme mappe som scriptet, eller opdater FPCALC_PATH")
|
||||
args.ingen_acoustid = True
|
||||
else:
|
||||
print("AcoustID: SLÅET FRA")
|
||||
print(f"\nStarter analyse...\n")
|
||||
|
||||
resultater = []
|
||||
tæller = {"SIKKER": 0, "SANDSYNLIG": 0, "ALLEREDE_RETTET": 0,
|
||||
"UKONTROLLERET": 0, "FRA_FILNAVN": 0,
|
||||
"USIKKER": 0, "KONFLIKT": 0, "UKENDT": 0, "FEJL": 0}
|
||||
|
||||
for i, path in enumerate(mp3_filer, 1):
|
||||
navn = os.path.basename(path)
|
||||
print(f"[{i:4}/{total}] {navn[:60]}", end="", flush=True)
|
||||
|
||||
try:
|
||||
res = analyse_file(path, use_acoustid=not args.ingen_acoustid)
|
||||
except Exception as e:
|
||||
res = {"fil": path, "filename": navn, "status": "FEJL", "noter": str(e),
|
||||
"handling": "", "dans_forslag": "", "rigtig_titel": "", "rigtig_artist": "",
|
||||
"mb_title": "", "mb_artist": "", "match_score": 0, "tit2": "", "tpe1": "",
|
||||
"talb": "", "tcon": "", "mbid": ""}
|
||||
|
||||
status = res.get("status", "UKENDT")
|
||||
tæller[status] = tæller.get(status, 0) + 1
|
||||
resultater.append(res)
|
||||
|
||||
print(f" → {status}")
|
||||
|
||||
# Gem løbende hvert 50. fil
|
||||
if i % 50 == 0:
|
||||
write_csv(resultater, args.output)
|
||||
|
||||
# Gem endelig rapport
|
||||
write_csv(resultater, args.output)
|
||||
|
||||
# Statistik
|
||||
print("\n" + "="*55)
|
||||
print("RAPPORT")
|
||||
print("="*55)
|
||||
print(f" SIKKER : {tæller.get('SIKKER',0):4} → kan rettes automatisk")
|
||||
print(f" SANDSYNLIG : {tæller.get('SANDSYNLIG',0):4} → bør godkendes")
|
||||
print(f" ALLEREDE_RETTET : {tæller.get('ALLEREDE_RETTET',0):4} → spring over")
|
||||
print(f" UKONTROLLERET : {tæller.get('UKONTROLLERET',0):4} → ingen MBID, men mønster muligt")
|
||||
print(f" FRA_FILNAVN : {tæller.get('FRA_FILNAVN',0):4} → dans udtrukket fra filnavn")
|
||||
print(f" USIKKER : {tæller.get('USIKKER',0):4} → manuel gennemgang")
|
||||
print(f" KONFLIKT : {tæller.get('KONFLIKT',0):4} → tags modstrider hinanden")
|
||||
print(f" UKENDT : {tæller.get('UKENDT',0):4} → kan ikke vurderes")
|
||||
print(f" FEJL : {tæller.get('FEJL',0):4} → teknisk fejl")
|
||||
print("="*55)
|
||||
# Niveau-statistik
|
||||
niveau_værdier = {}
|
||||
for r in resultater:
|
||||
rå = r.get("niveau_rå", "").strip()
|
||||
if rå:
|
||||
niveau_værdier[rå] = niveau_værdier.get(rå, 0) + 1
|
||||
|
||||
if niveau_værdier:
|
||||
print("\nNiveau-værdier fundet i TCON:")
|
||||
for val, antal in sorted(niveau_værdier.items(), key=lambda x: -x[1]):
|
||||
norm, genkendt = normaliser_niveau(val)
|
||||
mærke = "✓" if genkendt else "?"
|
||||
print(f" {mærke} '{val}' ({antal}x) → '{norm}'")
|
||||
|
||||
print(f"\nÅbn {args.output} i Excel og:")
|
||||
print(" 1. Gennemgå SANDSYNLIG og UKONTROLLERET")
|
||||
print(" 2. Sæt 'handling' til RET_AUTOMATISK eller SPRING for hver")
|
||||
print(" 3. Kør: python linedance_tag_analyse.py --apply rapport.csv --dry-run")
|
||||
print(" 4. Tjek output, kør så uden --dry-run")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2036
Henriks Musik/rapport.csv
Normal file
2036
Henriks Musik/rapport.csv
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user