#705: release-date gate — unreleased tracks stay out of the wishlist cycle and Fresh Tape
Watchlist scans add announced albums on purpose (so singles download the day they drop), but the future-dated tracks leaked into two hot paths: - Fresh Tape / Release Radar: future albums got NEGATIVE days_old, and the recency score (100 - days*7) has no upper clamp — prereleases weren't just slipping into the radar, they were mathematically FAVORED above every released track. That's the "50% prerelease" report. The builder now skips confidently-future albums (and clamps days_old to 0 as a belt). - Wishlist processing: every auto cycle burned a full Soulseek search + timeout per unreleased track (~60 tracks/cycle for the reporter). Both the auto and manual flows now skip future-dated tracks with a counted log line. They STAY in the wishlist and join the cycle automatically the day their release date passes — no state, the date check is per-cycle. An explicit manual track selection overrides the gate (the user asked for those). The gate (core/metadata/release_dates.py, pure + tested) is conservative by design: Spotify dates come as YYYY / YYYY-MM / YYYY-MM-DD, and a track only gates when its date is CONFIDENTLY future at its stated precision. Release day counts as released; garbage or missing dates never block anything (including out-of-range months/days, which fall back a precision level). Tests: 6 covering all precisions, the release-day boundary, garbage tolerance, dict shapes, and ordered partitioning. 403 wishlist/watchlist tests pass.
This commit is contained in:
parent
1f7834cc7b
commit
e135873b4b
4 changed files with 182 additions and 1 deletions
79
core/metadata/release_dates.py
Normal file
79
core/metadata/release_dates.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
67
tests/test_release_dates.py
Normal file
67
tests/test_release_dates.py
Normal file
|
|
@ -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']
|
||||
Loading…
Reference in a new issue