diff --git a/core/metadata/release_dates.py b/core/metadata/release_dates.py new file mode 100644 index 00000000..d780ba82 --- /dev/null +++ b/core/metadata/release_dates.py @@ -0,0 +1,79 @@ +"""Release-date gating (#705): keep unreleased tracks out of hot paths. + +Watchlist scans intentionally pick up ANNOUNCED albums — singles drop early, +the rest of the tracklist carries a future release date. Two places must not +treat those as available: + + - the wishlist auto-processor: searching Soulseek for a track that isn't + out yet burns a full search+timeout per track, every cycle + - the Fresh Tape / Release Radar builder: future albums got NEGATIVE + days_old, which INFLATED their recency score (100 - days*7) above every + released track — prereleases weren't just slipping in, they were favored + +Spotify-style dates come in three precisions: YYYY, YYYY-MM, YYYY-MM-DD. +The gate is deliberately conservative: a track is "unreleased" only when the +date is CONFIDENTLY in the future at its stated precision. Unparseable or +missing dates are treated as released (never block on bad data), and a +release dated today counts as released — release-day tracks should flow. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +def is_future_release(release_date_str: Any, today: Optional[date] = None) -> bool: + """True only when ``release_date_str`` is confidently in the future.""" + if not release_date_str or not isinstance(release_date_str, str): + return False + today = today or date.today() + parts = release_date_str.strip().split('-') + try: + year = int(parts[0]) + except (ValueError, IndexError): + return False + if len(parts) == 1 or not parts[1]: + return year > today.year + try: + month = int(parts[1]) + except ValueError: + return year > today.year + if not 1 <= month <= 12: + # Garbage month — fall back to year precision, never block on it. + return year > today.year + if len(parts) == 2 or not parts[2]: + return (year, month) > (today.year, today.month) + try: + day = int(parts[2][:2]) + except ValueError: + return (year, month) > (today.year, today.month) + try: + return date(year, month, day) > today + except ValueError: + return (year, month) > (today.year, today.month) + + +def track_release_date(track: Dict[str, Any]) -> str: + """Pull the release date off a track dict in its common shapes.""" + if not isinstance(track, dict): + return '' + album = track.get('album') + if isinstance(album, dict) and album.get('release_date'): + return str(album['release_date']) + return str(track.get('release_date') or '') + + +def split_released_unreleased( + tracks: Iterable[Dict[str, Any]], + today: Optional[date] = None, +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Partition tracks into (released, unreleased) by their release date.""" + released: List[Dict[str, Any]] = [] + unreleased: List[Dict[str, Any]] = [] + for t in tracks: + if is_future_release(track_release_date(t), today=today): + unreleased.append(t) + else: + released.append(t) + return released, unreleased diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index c330de4c..ee6128f1 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -3613,13 +3613,24 @@ class WatchlistScanner: if not album_data or 'tracks' not in album_data: continue + # #705: announced-but-unreleased albums must not reach the + # radar — and worse, their NEGATIVE days_old used to INFLATE + # the recency score (100 - days*7) above every released + # track, which is why Fresh Tape filled up with prereleases. + from core.metadata.release_dates import is_future_release + if is_future_release(album.get('release_date', '')): + logger.debug( + f"Release Radar: skipping unreleased album " + f"'{album.get('album_name', '?')}' ({album.get('release_date')})") + continue + # Calculate days since release for recency score days_old = 14 try: release_date_str = album.get('release_date', '') if release_date_str and len(release_date_str) >= 10: release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d") - days_old = (datetime.now() - release_date).days + days_old = max(0, (datetime.now() - release_date).days) except Exception as e: logger.debug("release-date parse: %s", e) diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index 2074921e..b9aabad2 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -729,6 +729,19 @@ def _prepare_and_run_manual_wishlist_batch( logger.warning(f"[Manual-Wishlist] Found and removed {duplicates_found} duplicate tracks during sanitization") logger.info(f"[Manual-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service") + # #705: don't burn a search+timeout on tracks that aren't out yet + # (watchlist scans add announced albums on purpose). They stay in the + # wishlist and start processing the cycle after their release date + # passes. An explicit track selection overrides the gate — the user + # asked for those specifically. + if not track_ids: + from core.metadata.release_dates import split_released_unreleased + wishlist_tracks, _unreleased = split_released_unreleased(wishlist_tracks) + if _unreleased: + logger.info( + f"[Manual-Wishlist] Skipping {len(_unreleased)} unreleased track(s) " + f"(future release date) — they'll be searched once released") + if track_ids: track_lookup = {} for track in wishlist_tracks: @@ -919,6 +932,17 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom logger.warning(f"[Auto-Wishlist] Found and removed {duplicates_found} duplicate tracks during sanitization") logger.info(f"[Auto-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service") + # #705: skip tracks with a future release date — searching for + # them burns a full search+timeout per track, every cycle, for + # files that can't exist yet. They stay in the wishlist and + # join the cycle automatically once their date passes. + from core.metadata.release_dates import split_released_unreleased + wishlist_tracks, _unreleased = split_released_unreleased(wishlist_tracks) + if _unreleased: + logger.info( + f"[Auto-Wishlist] Skipping {len(_unreleased)} unreleased track(s) " + f"(future release date) — they'll be searched once released") + # CYCLE FILTERING: Get current cycle and filter tracks by category current_cycle = get_wishlist_cycle(lambda: music_database) diff --git a/tests/test_release_dates.py b/tests/test_release_dates.py new file mode 100644 index 00000000..3cefc52c --- /dev/null +++ b/tests/test_release_dates.py @@ -0,0 +1,67 @@ +"""#705 release-date gate: unreleased tracks stay out of hot paths. + +Watchlist scans add announced albums on purpose; the gate keeps their +future-dated tracks out of the wishlist search cycle and the Fresh Tape +radar until release day. Conservative by design: only a CONFIDENTLY +future date gates; bad/missing dates never block anything. +""" + +from __future__ import annotations + +from datetime import date + +from core.metadata.release_dates import ( + is_future_release, + split_released_unreleased, + track_release_date, +) + +TODAY = date(2026, 6, 7) + + +def test_full_dates(): + assert is_future_release('2026-06-08', today=TODAY) is True + assert is_future_release('2026-06-07', today=TODAY) is False # release DAY = released + assert is_future_release('2026-06-06', today=TODAY) is False + assert is_future_release('2027-01-01', today=TODAY) is True + + +def test_partial_dates_are_conservative(): + # Year-only: future only when the YEAR is future. + assert is_future_release('2027', today=TODAY) is True + assert is_future_release('2026', today=TODAY) is False + # Year-month: future only when the MONTH is future. + assert is_future_release('2026-07', today=TODAY) is True + assert is_future_release('2026-06', today=TODAY) is False + assert is_future_release('2026-05', today=TODAY) is False + + +def test_garbage_never_blocks(): + for bad in ('', None, 'unknown', 'soon', '20xx-01-01', '2026-13-45', 123, {}): + assert is_future_release(bad, today=TODAY) is False + + +def test_invalid_day_falls_back_to_month_precision(): + # 2026-06-99 is unparseable as a date but month precision says "not future". + assert is_future_release('2026-06-99', today=TODAY) is False + assert is_future_release('2026-07-99', today=TODAY) is True + + +def test_track_release_date_shapes(): + assert track_release_date({'album': {'release_date': '2026-10-03'}}) == '2026-10-03' + assert track_release_date({'release_date': '2026'}) == '2026' + assert track_release_date({'album': 'a-string'}) == '' + assert track_release_date({}) == '' + assert track_release_date(None) == '' + + +def test_split_partitions_and_preserves_order(): + tracks = [ + {'name': 'out', 'album': {'release_date': '2026-01-01'}}, + {'name': 'tomorrow', 'album': {'release_date': '2026-06-08'}}, + {'name': 'no-date', 'album': {}}, + {'name': 'next-year', 'release_date': '2027'}, + ] + released, unreleased = split_released_unreleased(tracks, today=TODAY) + assert [t['name'] for t in released] == ['out', 'no-date'] + assert [t['name'] for t in unreleased] == ['tomorrow', 'next-year']