soulsync/core/metadata/release_dates.py
BoulderBadgeDad e135873b4b #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.
2026-06-07 00:29:05 -07:00

79 lines
3 KiB
Python

"""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