ListenBrainz publishes "Weekly Jams for X" / "Weekly Exploration
for X" with a fresh MBID every week, and "Top Discoveries of YYYY
for X" / "Top Missed Recordings of YYYY for X" with a fresh MBID
every year. Auto-mirroring those per-period yielded one mirrored-
playlist row per week/year — useless for Auto-Sync schedules
because the underlying LB playlist never updates, only a brand new
playlist replaces it. The user accumulates 100+ dead Weekly Jams
rows per year if they discover regularly.
This commit collapses each family into a single ROLLING mirror
keyed by a synthetic ``source_playlist_id`` (e.g.
``lb_weekly_jams_Nezreka``). Each new period UPSERTs into the same
row, so the user gets one stable Auto-Sync schedule per series
that automatically picks up the latest period's tracks on every
refresh. Non-series LB playlists (user-created, collaborative,
Last.fm radios for a specific seed) continue to mirror under
their per-playlist MBID as before. Per-period LB playlists are
still visible + usable on the LB Sync tab — only the mirror layer
collapses.
- ``core/playlists/lb_series.py`` (new) — series-detect helper
with regex patterns + canonical-name + LIKE-pattern template
for each known LB family. Exposes
``detect_series(title)``, ``is_series_synthetic_id(id)``, and
``list_series_synthetic_ids()`` so both the JS auto-mirror hook
and the LB adapter can speak the same language.
- ``GET /api/listenbrainz/series-detect?title=...`` — thin HTTP
shim around ``detect_series`` so the auto-mirror JS doesn't
duplicate the regex.
- ``ListenBrainzPlaylistSource.get_playlist`` now recognizes
synthetic series ids — it queries the LB cache for the newest
cache row whose title matches the series' LIKE pattern and
resolves to that row's MBID before fetching tracks. The mirror's
meta keeps the synthetic id so refreshes always re-resolve to
the latest period.
- ``_mirrorListenBrainzAfterDiscovery`` (sync-services.js) calls
the new detect endpoint when discovery completes — if a match
comes back it swaps the per-period MBID for the synthetic id +
the canonical name. Existing Last.fm radio routing logic stays
intact (Last.fm radios aren't a series).
- ``ListenBrainzManager._cleanup_per_period_series_mirrors`` —
one-shot consolidation sweeper runs in ``_cleanup_old_playlists``
+ deletes any legacy per-period mirror rows so the consolidated
rolling mirror is the only one left. Idempotent — only matches
per-period titles ("Weekly Jams for ..., week of ...") and never
the canonical rolling-mirror titles ("ListenBrainz Weekly
Jams").
- 11 new tests pin the detector + synthetic-id helpers; 236 total
across adapter + automation + lb-series suites green.
89 lines
3.8 KiB
Python
89 lines
3.8 KiB
Python
"""Tests for the LB rotating-series detector that powers the
|
|
rolling-mirror collapse on the Sync page.
|
|
|
|
Pins the title patterns + canonical-name templates so accidental
|
|
regex tweaks don't silently break the auto-mirror grouping the
|
|
Auto-Sync manager + Mirrored tab rely on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from core.playlists.lb_series import (
|
|
detect_series,
|
|
is_series_synthetic_id,
|
|
list_series_synthetic_ids,
|
|
)
|
|
|
|
|
|
class TestDetectSeries:
|
|
def test_weekly_jams_collapses_into_rolling_series(self):
|
|
m = detect_series("Weekly Jams for Nezreka, week of 2026-05-25 Mon")
|
|
assert m is not None
|
|
assert m.series_id == "lb_weekly_jams_Nezreka"
|
|
assert m.canonical_name == "ListenBrainz Weekly Jams"
|
|
assert m.source_for_mirror == "listenbrainz"
|
|
assert m.title_pattern == "Weekly Jams for Nezreka, week of %"
|
|
|
|
def test_weekly_exploration_collapses_into_rolling_series(self):
|
|
m = detect_series("Weekly Exploration for Nezreka, week of 2026-04-13 Mon")
|
|
assert m is not None
|
|
assert m.series_id == "lb_weekly_exploration_Nezreka"
|
|
assert m.canonical_name == "ListenBrainz Weekly Exploration"
|
|
assert m.title_pattern == "Weekly Exploration for Nezreka, week of %"
|
|
|
|
def test_top_discoveries_collapses_per_user(self):
|
|
m = detect_series("Top Discoveries of 2024 for Nezreka")
|
|
assert m is not None
|
|
assert m.series_id == "lb_top_discoveries_Nezreka"
|
|
assert m.canonical_name == "ListenBrainz Top Discoveries (latest year)"
|
|
assert m.title_pattern == "Top Discoveries of % for Nezreka"
|
|
|
|
def test_top_missed_collapses_per_user(self):
|
|
m = detect_series("Top Missed Recordings of 2025 for Nezreka")
|
|
assert m is not None
|
|
assert m.series_id == "lb_top_missed_Nezreka"
|
|
assert m.canonical_name == "ListenBrainz Top Missed Recordings (latest year)"
|
|
|
|
def test_user_with_spaces_in_name(self):
|
|
# ListenBrainz allows usernames with spaces; the regex should
|
|
# still match and the series id propagates the literal user
|
|
# token. Whether SQLite LIKE works on that is the caller's
|
|
# problem — we just preserve the captured value.
|
|
m = detect_series("Weekly Jams for Some User, week of 2026-01-05 Mon")
|
|
assert m is not None
|
|
assert m.series_id == "lb_weekly_jams_Some User"
|
|
|
|
def test_lastfm_radio_is_not_a_series(self):
|
|
# Last.fm radios get their own per-seed MBID — they should NOT
|
|
# be collapsed into a rolling series.
|
|
assert detect_series("Last.fm Radio: Selfish by Madison Beer") is None
|
|
|
|
def test_user_created_playlist_is_not_a_series(self):
|
|
assert detect_series("My Custom Playlist") is None
|
|
|
|
def test_empty_title_returns_none(self):
|
|
assert detect_series("") is None
|
|
assert detect_series(None) is None # type: ignore[arg-type]
|
|
|
|
|
|
class TestSyntheticIdHelpers:
|
|
def test_known_prefixes_listed(self):
|
|
prefixes = list_series_synthetic_ids()
|
|
assert "lb_weekly_jams_" in prefixes
|
|
assert "lb_weekly_exploration_" in prefixes
|
|
assert "lb_top_discoveries_" in prefixes
|
|
assert "lb_top_missed_" in prefixes
|
|
|
|
def test_is_series_synthetic_id_matches_known(self):
|
|
assert is_series_synthetic_id("lb_weekly_jams_Nezreka") is True
|
|
assert is_series_synthetic_id("lb_weekly_exploration_OtherUser") is True
|
|
assert is_series_synthetic_id("lb_top_discoveries_X") is True
|
|
|
|
def test_is_series_synthetic_id_rejects_mbids(self):
|
|
# Real LB playlist MBIDs are UUID-shaped, never start with ``lb_``.
|
|
assert is_series_synthetic_id("4badb5c9-266e-42ef-9d06-879ee311c9e0") is False
|
|
assert is_series_synthetic_id("") is False
|
|
assert is_series_synthetic_id("lb_") is False # not a real series
|
|
assert is_series_synthetic_id("lb_random_thing") is False
|