diff --git a/karaokatalog/Song.py b/karaokatalog/Song.py new file mode 100644 index 0000000..31caed2 --- /dev/null +++ b/karaokatalog/Song.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Self +import filecmp + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Song: + """ + A song, represented by a txt file and accompanying files (most importantly, an audio file). + + See https://usdx.eu/format/ for the specification. + """ + + title: str + artist: str + audio: Path | None + video: Path | None + cover: Path | None + song_txt: Path + + @property + def dir(self) -> Path: + return self.song_txt.parent + + def has_identic_files(self, other: Song) -> bool: + """ + Return if the directory for this song and the directory for the other song contain identic files. + """ + comparison = filecmp.dircmp(self.dir, other.dir, shallow=False) + + # Two directories are identic if they don't have any differing files, and if there were also no + # errors during comparison. + # + # See also: + # - https://docs.python.org/3/library/filecmp.html#filecmp.dircmp.diff_files + return not comparison.diff_files and not comparison.funny_files + + @classmethod + def from_song_txt(cls, song_txt: Path) -> Self | None: + with song_txt.open(encoding="utf-8", errors="ignore") as f: + tags = dict(_parse_tag_line(line) for line in f if line.startswith("#")) + + title = tags.get("TITLE") + artist = tags.get("ARTIST") + audio_name = tags.get("AUDIO", tags.get("MP3")) + video_name = tags.get("VIDEO") + cover_name = tags.get("COVER") + + if not title or not artist: + # Both are mandatory according to the specification + return None + + return cls( + title=title, + artist=artist, + audio=song_txt / audio_name if audio_name else None, + video=song_txt / video_name if video_name else None, + cover=song_txt / cover_name if cover_name else None, + song_txt=song_txt, + ) + + +def _parse_tag_line(tag_line: str) -> tuple[str, str | None]: + """ + Parse a tag line of the format: + + #KEY:Value + + or + + #KEY: + + Returns a tuple of (key, value), where the value might be None. + """ + + key_and_potentially_value = tuple( + tag_line.strip().removeprefix("#").split(":", maxsplit=1) + ) + + return ( + key_and_potentially_value + if len(key_and_potentially_value) == 2 + else (key_and_potentially_value[0], None) + )