From 6224713939d4622bbea1fa510c33a3000f392b7d Mon Sep 17 00:00:00 2001
From: Jakob Moser <moser@cl.uni-heidelberg.de>
Date: Sun, 18 May 2025 21:29:32 +0200
Subject: [PATCH] Add Song

---
 karaokatalog/Song.py | 88 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 88 insertions(+)
 create mode 100644 karaokatalog/Song.py

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)
+    )