""" tag_reader.py — Læser og skriver metadata fra lydfiler. Understøttede formater og danse-tag support: MP3 — læs + skriv danse (ID3 TXXX-felter) FLAC — læs + skriv danse (Vorbis Comments) OGG — læs + skriv danse (Vorbis Comments) OPUS — læs + skriv danse (Vorbis Comments) M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE) WAV — læs metadata, ingen danse-tag support WMA — læs metadata, ingen danse-tag support AIFF — læs metadata, ingen danse-tag support Danse gemmes ALTID i SQLite uanset format. Fil-skrivning er kun muligt for de formater der understøtter custom tags. """ import os from datetime import datetime, timezone from pathlib import Path try: from mutagen import File as MutagenFile from mutagen.id3 import ID3, TXXX from mutagen.flac import FLAC from mutagen.mp4 import MP4, MP4FreeForm MUTAGEN_AVAILABLE = True except ImportError: MUTAGEN_AVAILABLE = False print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret") # Filtyper vi høster metadata fra SUPPORTED_EXTENSIONS = { ".mp3", ".flac", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".wma", ".aiff", ".aif", } # Formater der understøtter skrivning af danse-tags til fil WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"} # Tag-nøgler brugt på tværs af formater TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1 VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1 M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste) def is_supported(path: str | Path) -> bool: return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS def can_write_dances(path: str | Path) -> bool: """Returnerer True hvis formatet understøtter skrivning af danse-tags til fil.""" return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS def get_file_modified_at(path: str | Path) -> str: ts = os.path.getmtime(str(path)) return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() # ── Læsning ─────────────────────────────────────────────────────────────────── def read_tags(path: str | Path) -> dict: """ Læser metadata og danse fra en lydfil. Returnerer dict med: title, artist, album, bpm, duration_sec, file_format, file_modified_at, dances, can_write_dances. """ path = Path(path) result = { "local_path": str(path), "title": path.stem, "artist": "", "album": "", "bpm": 0, "duration_sec": 0, "file_format": path.suffix.lower().lstrip("."), "file_modified_at": get_file_modified_at(path), "dances": [], "can_write_dances": can_write_dances(path), } if not MUTAGEN_AVAILABLE: return result try: audio = MutagenFile(str(path), easy=False) if audio is None: return result if hasattr(audio, "info") and audio.info: result["duration_sec"] = int(getattr(audio.info, "length", 0)) ext = path.suffix.lower() if ext == ".mp3": _read_mp3(audio, result) elif ext == ".flac": _read_vorbis(audio, result) elif ext in (".ogg", ".opus"): _read_vorbis(audio, result) elif ext in (".m4a", ".aac", ".mp4"): _read_m4a(audio, result) else: _read_generic(audio, result) except Exception as e: print(f"Fejl ved læsning af {path}: {e}") return result def _read_mp3(audio, result: dict): tags = audio.tags if not tags: return if "TIT2" in tags: result["title"] = str(tags["TIT2"].text[0]) if "TPE1" in tags: result["artist"] = str(tags["TPE1"].text[0]) if "TALB" in tags: result["album"] = str(tags["TALB"].text[0]) if "TBPM" in tags: try: result["bpm"] = int(float(str(tags["TBPM"].text[0]))) except (ValueError, TypeError): pass dances = {} for key, frame in tags.items(): if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key: try: num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", "")) dances[num] = str(frame.text[0]) except (ValueError, IndexError): pass result["dances"] = [dances[k] for k in sorted(dances.keys())] def _read_vorbis(audio, result: dict): """FLAC og OGG/Opus bruger begge Vorbis Comments.""" tags = audio.tags if not tags: return result["title"] = tags.get("title", [result["title"]])[0] result["artist"] = tags.get("artist", [""])[0] result["album"] = tags.get("album", [""])[0] try: result["bpm"] = int(tags.get("bpm", [0])[0]) except (ValueError, TypeError): pass # Danse gemmes som linedance_dance.1, linedance_dance.2 ... dances = {} for key, values in tags.items(): if key.lower().startswith(f"{VORBIS_DANCE_KEY}."): try: num = int(key.split(".")[-1]) dances[num] = values[0] except (ValueError, IndexError): pass # Fallback: enkelt felt linedance_dance med komma-separeret liste if not dances and VORBIS_DANCE_KEY in tags: result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()] return result["dances"] = [dances[k] for k in sorted(dances.keys())] def _read_m4a(audio, result: dict): tags = audio.tags if not tags: return if "\xa9nam" in tags: result["title"] = str(tags["\xa9nam"][0]) if "\xa9ART" in tags: result["artist"] = str(tags["\xa9ART"][0]) if "\xa9alb" in tags: result["album"] = str(tags["\xa9alb"][0]) if "tmpo" in tags: try: result["bpm"] = int(tags["tmpo"][0]) except (ValueError, TypeError): pass # Danse gemmes som ----:LINEDANCE:DANCE — én værdi per dans if M4A_DANCE_FREEFORM in tags: result["dances"] = [ v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v) for v in tags[M4A_DANCE_FREEFORM] ] def _read_generic(audio, result: dict): try: easy = MutagenFile(result["local_path"], easy=True) if easy and easy.tags: result["title"] = easy.tags.get("title", [result["title"]])[0] result["artist"] = easy.tags.get("artist", [""])[0] result["album"] = easy.tags.get("album", [""])[0] except Exception: pass # ── Skrivning ───────────────────────────────────────────────────────────────── def write_dances(path: str | Path, dances: list[str]) -> bool: """ Skriver danse til filen hvis formatet understøtter det. Returnerer True ved succes, False hvis formatet ikke understøtter det. Kaster Exception ved fejl under skrivning. """ if not MUTAGEN_AVAILABLE: return False path = Path(path) ext = path.suffix.lower() if ext not in WRITABLE_DANCE_FORMATS: return False if ext == ".mp3": return _write_mp3_dances(path, dances) elif ext in (".flac", ".ogg", ".opus"): return _write_vorbis_dances(path, dances) elif ext in (".m4a", ".aac"): return _write_m4a_dances(path, dances) return False def _write_mp3_dances(path: Path, dances: list[str]) -> bool: try: tags = ID3(str(path)) for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]: del tags[key] for i, name in enumerate(dances, start=1): tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name)) tags.save(str(path)) return True except Exception as e: print(f"MP3 skrivefejl {path}: {e}") return False def _write_vorbis_dances(path: Path, dances: list[str]) -> bool: try: audio = MutagenFile(str(path), easy=False) if audio is None or audio.tags is None: return False # Slet eksisterende danse-felter keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")] for key in keys_to_delete: del audio.tags[key] # Skriv nye — ét felt per dans for i, name in enumerate(dances, start=1): audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name audio.save() return True except Exception as e: print(f"Vorbis skrivefejl {path}: {e}") return False def _write_m4a_dances(path: Path, dances: list[str]) -> bool: try: audio = MP4(str(path)) audio.tags[M4A_DANCE_FREEFORM] = [ MP4FreeForm(name.encode("utf-8")) for name in dances ] audio.save() return True except Exception as e: print(f"M4A skrivefejl {path}: {e}") return False # ── Hurtig læsning af kun danse (uden fuld tag-scan) ───────────────────────── def read_dances_from_file(path: str | Path) -> list[str]: """Læser kun danse fra en fil — hurtigere end fuld read_tags().""" tags = read_tags(path) return tags.get("dances", [])