diff --git a/core/traxsource.py b/core/traxsource.py index 85d213b..d0372b4 100644 --- a/core/traxsource.py +++ b/core/traxsource.py @@ -6,6 +6,7 @@ Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md. from __future__ import annotations +import re from dataclasses import dataclass @@ -59,3 +60,47 @@ def list_genres() -> list: ] result.sort(key=lambda g: g["name"].casefold()) return result + + +class TraxsourceError(Exception): + """Base per errori Traxsource.""" + + +class TraxsourceUnreachableError(TraxsourceError): + """Rete / 5xx dopo retry.""" + + +class TraxsourceParseError(TraxsourceError): + """HTML ricevuto ma non conforme allo schema atteso.""" + + +_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$") +_SIZE_RE = re.compile(r"/\d+x\d+/") + + +def _split_title_mix(full_title: str) -> tuple: + """Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix'). + Se non ci sono parentesi finali, mix = ''.""" + if not full_title: + return ("", "") + m = _MIX_PAREN_RE.match(full_title.strip()) + if m: + return (m.group(1).strip(), m.group(2).strip()) + return (full_title.strip(), "") + + +def _format_artists(names: list) -> str: + """['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace.""" + clean = [n.strip() for n in names if n and n.strip()] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return ", ".join(clean[:-1]) + " & " + clean[-1] + + +def _large_cover(url: str) -> str: + """Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato.""" + if not url: + return "" + return _SIZE_RE.sub("/500x500/", url) diff --git a/tests/test_traxsource.py b/tests/test_traxsource.py index c1eb3da..b1c322f 100644 --- a/tests/test_traxsource.py +++ b/tests/test_traxsource.py @@ -45,3 +45,53 @@ class TestTraxsourceTrack: ) assert t.image_url == "" assert t.cover_url_large == "" + + +class TestSplitTitleMix: + def test_with_parens(self): + assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix") + + def test_without_parens(self): + assert traxsource._split_title_mix("Foo") == ("Foo", "") + + def test_multiple_parens_takes_last(self): + # es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix" + assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix") + + def test_empty(self): + assert traxsource._split_title_mix("") == ("", "") + + +class TestFormatArtists: + def test_single(self): + assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon" + + def test_two(self): + assert traxsource._format_artists(["A", "B"]) == "A & B" + + def test_three(self): + assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C" + + def test_empty(self): + assert traxsource._format_artists([]) == "" + + def test_strips_whitespace(self): + assert traxsource._format_artists([" A ", " B "]) == "A & B" + + +class TestLargeCover: + def test_substitutes_52x52_with_500x500(self): + url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg" + assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg" + + def test_no_change_when_no_pattern(self): + assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg" + + def test_empty(self): + assert traxsource._large_cover("") == "" + + +class TestExceptions: + def test_exceptions_are_subclasses(self): + assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError) + assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError)