v0.35.0: a play history for the yearly Wrapped

The play journals keep running totals per track, which is enough for "most
played ever" and nothing a Wrapped asks: top songs of this year, minutes
listened, which month, what time of day, what got skipped. Those all come
from one timestamped line per listen, so LinTunes now keeps that, while
Preferences -> General -> "Keep a play history for my yearly Wrapped" is on.

history/<machine>/<YYYY-MM>.jsonl, one writer per folder like the journals:
local start time with its offset, ms actually heard (Player adds position
steps under 1.5 s, so seeks and pauses don't count), how it ended, where it
played and from what, and a name/artist/album snapshot that outlives a
delete. Under 5 s is noise and never written.

andTunes 0.3.0 writes the same lines on the Rabbit when sync says the
desktop wants them, and sync copies each month home when the device's copy
is longer. "Share this year's play history with friends" puts a trimmed
copy (no ids, playlists or devices) in each friend's cassette folder, daily
in November and December and monthly otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 16:47:40 -07:00
co-authored by Claude Opus 5.5
parent b3d6c01774
commit ad1d152948
26 changed files with 1148 additions and 15 deletions
+380
View File
@@ -0,0 +1,380 @@
"""Round 74: a listen history for the yearly Wrapped.
The play journals keep totals; a Wrapped needs one timestamped line per
listen. Pins: what Player counts as heard (seeks and pauses don't count), how
a listen ends, the 5 s noise floor, the per-machine month files, the Rabbit's
history coming home, the merge, and what friends receive and how often.
"""
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from lintunes import player as player_module
from lintunes.andtunes import plays as plays_mod
from lintunes.andtunes.manifest import build_manifest
from lintunes.cassette import wrapped
from lintunes.models import Library, Track
from lintunes.player import PlaybackSink, Player
from lintunes.storage import conflict_resolver, listen_history
from lintunes.storage.listen_history import ListenHistory, entry_for
class _Sink(PlaybackSink):
def __init__(self, parent=None):
super().__init__(parent)
self._playing = False
self._position = 0
def load(self, track, autoplay, start_ms):
self._playing = autoplay
self._position = start_ms
def play(self):
self._playing = True
def pause(self):
self._playing = False
def stop(self):
self._playing = False
def seek(self, position_ms):
self._position = position_ms
def position_ms(self):
return self._position
def duration_ms(self):
return 0
def is_playing(self):
return self._playing
def set_volume(self, level):
pass
def can_play(self, track):
return True
def unplayable_reason(self, track):
return ""
def shutdown(self):
pass
def tick(self, *positions):
for p in positions:
self._position = p
self.position_changed.emit(p)
def _player(qapp, tmp_path, count=2):
library = Library()
for tid in range(1, count + 1):
path = tmp_path / f"{tid}.mp3"
path.write_bytes(b"x")
library.tracks[tid] = Track(track_id=tid, name=f"Song {tid}",
artist="Artist", album="Album",
genre="Jazz", year=1961,
total_time=200_000, location=str(path))
manager = MagicMock(library=library)
with patch.multiple(player_module, QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock()):
player = Player(manager)
sink = _Sink()
player.set_sink(sink)
ended = []
player.listen_ended.connect(lambda *args: ended.append(args))
return player, sink, ended, manager
def _steps(start, stop, step=500):
return range(start, stop + 1, step)
class TestPlayerAccounting:
def test_skip_reports_time_heard(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(*_steps(500, 10_000))
player.next()
(key, track, started, heard, how), = ended
assert (key, track.track_id, heard, how) == (1, 1, 10_000, "skip")
assert started.tzinfo is not None # local time, with its offset
def test_a_seek_is_not_listening(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(500, 1000, 120_000, 120_500, 3000, 3500) # forward, then back
player.next()
assert ended[0][3] == 2000
def test_a_natural_finish(self, qapp, tmp_path):
player, sink, ended, manager = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(*_steps(500, 6000))
sink.ended.emit()
assert [(e[1].track_id, e[4]) for e in ended] == [(1, "finish")]
manager.record_play.assert_called_once_with(1)
def test_stop_and_quit(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(*_steps(500, 6000))
player.stop()
player.play_queue([2], 0)
sink.tick(*_steps(500, 6000))
player.shutdown()
assert [e[4] for e in ended] == ["stop", "quit"]
def test_nothing_heard_reports_nothing(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
player.next()
assert ended == []
def test_replaying_the_last_track_opens_a_new_listen(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path, count=1)
player.play_queue([1], 0)
sink.tick(*_steps(500, 6000))
sink.ended.emit() # queue ran out; track stays current
sink._position = 0
player.toggle_play()
sink.tick(*_steps(500, 7000))
player.stop()
assert [(e[3], e[4]) for e in ended] == [(6000, "finish"), (7000, "stop")]
def _track(**fields):
base = dict(track_id=7, name="Alhambra", artist="Ahmad Jamal",
album_artist="Ahmad Jamal", album="Alhambra", genre="Jazz",
year=1961, total_time=300_000)
base.update(fields)
return Track(**base)
WHEN = datetime(2026, 10, 2, 21, 14, 3, tzinfo=timezone(timedelta(hours=-7)))
class TestEntry:
def test_shape(self):
entry = entry_for(_track(), started=WHEN, heard_ms=200_000,
how="finish", src="desktop", ctx="playlist:ab12",
shuffle=True, track_id=7)
assert entry == {"t": "2026-10-02T21:14:03-07:00", "ms": 200_000,
"len": 300_000, "end": "finish", "src": "desktop",
"id": 7, "ctx": "playlist:ab12", "shuf": True,
"n": "Alhambra", "a": "Ahmad Jamal",
"aa": "Ahmad Jamal", "al": "Alhambra", "g": "Jazz",
"y": 1961}
def test_under_five_seconds_is_noise(self):
assert entry_for(_track(), started=WHEN, heard_ms=4999, how="skip",
src="desktop") is None
def test_friend_track_has_no_id(self):
entry = entry_for(_track(), started=WHEN, heard_ms=9000, how="skip",
src="desktop", track_id="cassette:tok:7")
assert "id" not in entry
class TestHistoryFiles:
def _entry(self, when, name="x"):
return entry_for(_track(name=name), started=when, heard_ms=60_000,
how="finish", src="desktop", track_id=7)
def test_month_files_per_machine(self, tmp_path):
mine, theirs = ListenHistory("aaa"), ListenHistory("bbb")
mine.add(self._entry(WHEN))
mine.add(self._entry(WHEN + timedelta(days=40), "november"))
theirs.add(self._entry(WHEN - timedelta(hours=1), "earlier"))
mine.flush(tmp_path)
theirs.flush(tmp_path)
names = sorted(str(p.relative_to(tmp_path))
for p in (tmp_path / "history").rglob("*.jsonl"))
assert names == ["history/aaa/2026-10.jsonl", "history/aaa/2026-11.jsonl",
"history/bbb/2026-10.jsonl"]
assert [e["n"] for e in listen_history.read_year(tmp_path, 2026)] == \
["earlier", "x", "november"]
assert not mine.dirty
def test_flush_appends(self, tmp_path):
history = ListenHistory("aaa")
for name in ("one", "two"):
history.add(self._entry(WHEN, name))
history.flush(tmp_path)
assert [e["n"] for e in listen_history.read_year(tmp_path, 2026)] == \
["one", "two"]
def test_torn_line_is_skipped(self, tmp_path):
history = ListenHistory("aaa")
history.add(self._entry(WHEN))
(path,) = history.flush(tmp_path)
with open(path, "a") as f:
f.write('{"t": "2026-10-0')
assert len(listen_history.read_year(tmp_path, 2026)) == 1
def test_other_years_are_left_out(self, tmp_path):
history = ListenHistory("aaa")
history.add(self._entry(WHEN.replace(year=2025)))
history.add(self._entry(WHEN))
history.flush(tmp_path)
assert len(listen_history.read_year(tmp_path, 2026)) == 1
def test_trim_for_sharing(self):
entry = self._entry(WHEN)
entry.update(ctx="playlist:x", shuf=True)
(trimmed,) = listen_history.trim_for_sharing([entry])
assert not {"id", "ctx", "src", "shuf"} & set(trimmed)
assert trimmed["a"] == "Ahmad Jamal" and trimmed["ms"] == 60_000
def test_conflict_merge_is_a_union(self, tmp_path):
folder = tmp_path / "history" / "andtunes-cafe"
folder.mkdir(parents=True)
a = json.dumps(self._entry(WHEN, "a"))
b = json.dumps(self._entry(WHEN + timedelta(minutes=5), "b"))
c = json.dumps(self._entry(WHEN + timedelta(minutes=9), "c"))
original = folder / "2026-10.jsonl"
original.write_text(f"{a}\n{b}\n")
conflict = folder / "2026-10.sync-conflict-20261002-211500-ABCDEFG.jsonl"
conflict.write_text(f"{a}\n{c}\n")
conflict_resolver.resolve_conflicts(tmp_path)
assert not conflict.exists()
assert [e["n"] for e in listen_history.read_lines(original)] == \
["a", "b", "c"]
class TestManagerAndGate:
def test_manager_writes_with_the_next_save(self, qapp, tmp_path):
from lintunes.library_manager import LibraryManager
manager = LibraryManager(Library(), tmp_path)
manager.record_listen(entry_for(_track(), started=WHEN, heard_ms=9000,
how="skip", src="desktop", track_id=7))
manager.record_listen(None)
manager.flush()
assert len(listen_history.read_year(tmp_path, 2026)) == 1
@pytest.mark.parametrize("on", [False, True])
def test_the_preference_gates_it(self, qapp, tmp_path, on):
from lintunes.gui.main_window import MainWindow
from lintunes.library_manager import LibraryManager
from lintunes.preferences import Preferences
manager = LibraryManager(Library(), tmp_path / "data")
prefs = Preferences(tmp_path / "data")
prefs.update_wrapped(track_history=on)
window = MainWindow(manager, prefs)
try:
window._listen_context = "followed:cassette:tok:pid"
window._on_listen_ended("cassette:tok:7", _track(), WHEN, 9000,
"skip")
pending = manager.listen_history.pending
if not on:
assert pending == []
else:
(entry,) = pending
assert entry["ctx"] == "friend:tok" and "id" not in entry
assert entry["src"] == "desktop"
finally:
window.close()
class TestRabbit:
def _line(self, name):
return json.dumps({"t": "2026-10-02T09:00:00-07:00", "ms": 9000,
"n": name, "src": "r1"}) + "\n"
def test_history_comes_home_when_longer(self, tmp_path):
root, data = tmp_path / "andTunes", tmp_path / "data"
month = root / "history" / "andtunes-cafe" / "2026-10.jsonl"
month.parent.mkdir(parents=True)
month.write_text(self._line("one"))
assert plays_mod.bring_back_history(root, data) == 1
dest = data / "history" / "andtunes-cafe" / "2026-10.jsonl"
assert dest.read_text() == self._line("one")
assert plays_mod.bring_back_history(root, data) == 0 # same length
month.write_text(self._line("one") + self._line("two") + '{"t": "20')
assert plays_mod.bring_back_history(root, data) == 1
assert dest.read_text() == self._line("one") + self._line("two")
def test_no_history_folder(self, tmp_path):
assert plays_mod.bring_back_history(tmp_path, tmp_path / "data") == 0
def test_manifest_flag(self):
assert "history" not in build_manifest([], [], generator="t")
assert build_manifest([], [], generator="t", history=True)["history"] is True
def test_sync_never_prunes_the_history(self):
from lintunes.andtunes.layout import PRESERVED_DIRS
assert "history" in PRESERVED_DIRS
def _utc(*args):
return datetime(*args, tzinfo=timezone.utc)
class TestSharingCadence:
def test_monthly_off_season_daily_in_season(self):
oct2 = _utc(2026, 10, 2)
assert wrapped.is_due("", oct2)
assert not wrapped.is_due("2026-09-20T00:00:00+00:00", oct2)
assert wrapped.is_due("2026-09-01T00:00:00+00:00", oct2)
nov = _utc(2026, 11, 20, 12)
assert not wrapped.is_due("2026-11-20T00:00:00+00:00", nov)
assert wrapped.is_due("2026-11-19T11:00:00+00:00", nov)
def test_january_finishes_last_year(self):
assert wrapped.years_to_write(_utc(2027, 1, 3)) == [2026, 2027]
assert wrapped.years_to_write(_utc(2026, 10, 3)) == [2026]
def _service(self, tmp_path, tokens=("t1", "t2")):
from lintunes.cassette.service import CassetteService
from lintunes.cassette.state import Friend
service = CassetteService(root=tmp_path / "cassette",
config_loader=lambda: {})
for token in tokens:
service._state.friends[token] = Friend(token=token, device_id="D",
name=token)
return service
def _data(self, tmp_path):
history = ListenHistory("aaa")
history.add(entry_for(_track(), started=WHEN, heard_ms=60_000,
how="finish", src="desktop", ctx="playlist:x",
track_id=7))
history.flush(tmp_path / "data")
return tmp_path / "data"
def test_publish_then_quiet_then_off(self, qapp, tmp_path):
from lintunes.cassette.state import out_dir
service = self._service(tmp_path)
data = self._data(tmp_path)
now = _utc(2026, 10, 2, 12)
assert service.publish_wrapped(data, True, now) == 2
out = out_dir(service.root, "t1") / "wrapped-2026.json"
doc = json.loads(out.read_text())
assert doc["year"] == 2026 and len(doc["plays"]) == 1
assert "ctx" not in doc["plays"][0] and "id" not in doc["plays"][0]
mtime = out.stat().st_mtime_ns
# Not due again for a month, and even when due, unchanged plays
# aren't rewritten.
assert service.publish_wrapped(data, True, now + timedelta(days=3)) == 0
assert service.publish_wrapped(data, True, now + timedelta(days=31)) == 0
assert out.stat().st_mtime_ns == mtime
# Switching it off takes it back.
assert service.publish_wrapped(data, False, now) == 2
assert not out.exists()
assert service.state().wrapped_published_at == ""
def test_new_friend_gets_theirs_without_waiting(self, qapp, tmp_path):
from lintunes.cassette.state import Friend, out_dir
service = self._service(tmp_path, tokens=("t1",))
data = self._data(tmp_path)
now = _utc(2026, 10, 2, 12)
service.publish_wrapped(data, True, now)
service._state.friends["t2"] = Friend(token="t2", device_id="E", name="t2")
assert service.publish_wrapped(data, True, now + timedelta(hours=1)) == 1
assert (out_dir(service.root, "t2") / "wrapped-2026.json").exists()