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