diff --git a/.env.example b/.env.example index dd2280f..2af306c 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra # 32 random bytes, base64. Generate with: openssl rand -base64 32 LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + +# Host directory for the downloaded music library (bind-mounted into the worker at /music) +MUSIC_DIR=./music diff --git a/docker-compose.yml b/docker-compose.yml index 8af4498..a009542 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: environment: DATABASE_URL: ${DATABASE_URL} LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} + volumes: + - ${MUSIC_DIR:-./music}:/music depends_on: db: condition: service_healthy diff --git a/worker/lyra_worker/library.py b/worker/lyra_worker/library.py new file mode 100644 index 0000000..475d002 --- /dev/null +++ b/worker/lyra_worker/library.py @@ -0,0 +1,19 @@ +import re + +from lyra_worker.types import MBTarget + +_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def safe_name(s: str) -> str: + """A filesystem-safe path component (single segment, no separators).""" + cleaned = _ILLEGAL.sub("_", s).strip().strip(".").strip() + return cleaned or "Unknown" + + +def album_dir(dest_root: str, target: MBTarget) -> str: + """`{dest_root}/{safe artist}/{safe album}[ (year)]`.""" + album = safe_name(target.album) + if target.year: + album = f"{album} ({target.year})" + return f"{dest_root}/{safe_name(target.artist)}/{album}" diff --git a/worker/lyra_worker/pipeline.py b/worker/lyra_worker/pipeline.py index bffb25d..f14608b 100644 --- a/worker/lyra_worker/pipeline.py +++ b/worker/lyra_worker/pipeline.py @@ -5,6 +5,7 @@ import psycopg from lyra_worker.adapters.base import SourceAdapter from lyra_worker.confidence import score_confidence +from lyra_worker.library import album_dir from lyra_worker.quality import quality_class from lyra_worker.ranker import rank_candidates from lyra_worker.types import Candidate, MBTarget @@ -150,7 +151,7 @@ def run_pipeline( # 4. download (fall-through) _set_state(conn, job_id, "downloading", "download") by_source = {a.name: a for a in adapters} - dest = f"{dest_root}/{target.artist}/{target.album}" + dest = album_dir(dest_root, target) winner = None result = None for candidate in ranked: diff --git a/worker/tests/test_library.py b/worker/tests/test_library.py new file mode 100644 index 0000000..854dd6b --- /dev/null +++ b/worker/tests/test_library.py @@ -0,0 +1,24 @@ +from lyra_worker.library import album_dir, safe_name +from lyra_worker.types import MBTarget + + +def test_safe_name_replaces_illegal_chars(): + assert safe_name("AC/DC") == "AC_DC" + assert safe_name('a:b*c?"') == "a_b_c__" + assert safe_name(" .Trailing. ") == "Trailing" + assert safe_name("") == "Unknown" + + +def test_album_dir_with_year(): + t = MBTarget(artist="John Mayer", album="Continuum", year=2006) + assert album_dir("/music", t) == "/music/John Mayer/Continuum (2006)" + + +def test_album_dir_without_year(): + t = MBTarget(artist="John Mayer", album="Continuum") + assert album_dir("/music", t) == "/music/John Mayer/Continuum" + + +def test_album_dir_sanitizes(): + t = MBTarget(artist="AC/DC", album="Back in Black", year=1980) + assert album_dir("/music", t) == "/music/AC_DC/Back in Black (1980)"