From ad1d152948d4025ec90a87df28cd64b65c2ab0f6 Mon Sep 17 00:00:00 2001 From: trav Date: Fri, 2 Oct 2026 16:47:40 -0700 Subject: [PATCH] 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//.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 --- CLAUDE.md | 26 ++ .../main/java/me/teafry/andtunes/History.java | 116 ++++++ .../main/java/me/teafry/andtunes/Library.java | 3 + .../me/teafry/andtunes/PlaybackService.java | 6 + .../main/java/me/teafry/andtunes/Plays.java | 7 +- andtunes/build.py | 4 +- lintunes/__init__.py | 2 +- lintunes/android/andTunes.apk | Bin 66967 -> 66967 bytes lintunes/android/andTunes.json | 2 +- lintunes/andtunes/layout.py | 8 +- lintunes/andtunes/manifest.py | 11 +- lintunes/andtunes/plays.py | 52 ++- lintunes/andtunes/sync.py | 8 +- lintunes/cassette/service.py | 23 ++ lintunes/cassette/state.py | 8 +- lintunes/cassette/wrapped.py | 93 +++++ lintunes/gui/cassette_ui.py | 32 ++ lintunes/gui/main_window.py | 21 + lintunes/gui/preferences_dialog.py | 43 +- lintunes/library_manager.py | 15 + lintunes/player.py | 60 +++ lintunes/preferences.py | 16 + lintunes/storage/conflict_resolver.py | 33 ++ lintunes/storage/listen_history.py | 159 ++++++++ scripts/make_dev_library.py | 35 ++ tests/test_round74.py | 380 ++++++++++++++++++ 26 files changed, 1148 insertions(+), 15 deletions(-) create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/History.java create mode 100644 lintunes/cassette/wrapped.py create mode 100644 lintunes/storage/listen_history.py create mode 100644 tests/test_round74.py diff --git a/CLAUDE.md b/CLAUDE.md index 252cbd4..07b98cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,32 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `journal.base_fields(tid)`, never the `Track`'s effective count. Those two places are the whole hazard; `tests/test_round38.py` pins both. +- **`lintunes/storage/listen_history.py`** — the yearly Wrapped's raw data + (Round 74). The journals keep *totals*, which can't say "top songs of + 2026", minutes listened, month or time of day; this keeps **one line per + listen** in `/history//.jsonl` — the + journals' owner-only-writer rule, append-only, a finished month never + changes. `t` is *local* time with its offset (time-of-day is the listener's + clock); `ms` is time actually **heard** — `Player._account_heard` adds + position steps of 0–1.5 s, so seeks and pauses don't count; `end` is + finish/skip/stop/quit; the artist/album snapshot outlives a delete or + retag. Under 5 s is noise and never written. Only while the synced pref + `wrapped.track_history` is on: `Player.listen_ended` → `MainWindow` (adds + `src` desktop/cast and `ctx`, which it latches at `track_changed` because + `_now_playing_context` moves before the old listen closes) → + `LibraryManager.record_listen`, flushed with the journal. The Rabbit writes + the same lines (`History.java`) only when sync put `"history": true` in its + `library.json`; `andtunes/plays.bring_back_history` copies a month home when + the device's copy is longer (two desktops can both do that, hence the + union merge in `conflict_resolver._merge_listen_history`). + `cassette/wrapped.py` puts this year's **trimmed** log + (`trim_for_sharing`: no id/ctx/src/shuf) in every friend's `out/` as + `wrapped-.json` when `wrapped.share_with_friends` is also on — + checked hourly by `CassetteUi`, written daily in Nov–Dec and monthly + otherwise, never rewritten when the plays didn't change. No Wrapped page + yet: that's built after Spotify's 2026 one drops, plus a Last.fm backfill + for Jan–Sep (scrobbling is on; the R1 never scrobbled). + - **`lintunes/library_manager.py`** — `LibraryManager(QObject)` owns the `Library`, is the single funnel for all mutations, and persists them **debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/History.java b/andtunes/app/src/main/java/me/teafry/andtunes/History.java new file mode 100644 index 0000000..d2843cc --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/History.java @@ -0,0 +1,116 @@ +package me.teafry.andtunes; + +import android.content.Context; +import android.os.SystemClock; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * This Rabbit's listen history, for LinTunes' yearly Wrapped. + * + *

One line per listen in + * {@code Music/andTunes/history/andtunes-/.jsonl}, in the + * shape of LinTunes' {@code storage/listen_history.py}: when (local time with + * its offset), how long was actually heard, how it ended, and what the song + * was. Append-only and only ever written here, so LinTunes simply copies a + * month file home whenever the device's copy is longer. + * + *

Kept only while the desktop has "Keep a play history" on — sync writes + * {@code "history": true} into library.json, and {@link Library#history} is + * that flag. Listens under five seconds are noise and aren't written. + */ +final class History { + private static final String TAG = "andTunes"; + private static final int MIN_HEARD_MS = 5000; + private static final ExecutorService IO = Executors.newSingleThreadExecutor(); + + private Library.Track track; + private String started; // local ISO time of the first audible moment + private long heard; // ms heard before the current stretch + private long runningSince; // elapsedRealtime of the current stretch, 0 = paused + + /** Playback (re)started on {@code t}. */ + void playing(Library.Track t) { + if (t == null) return; + if (track != t) { track = t; started = null; heard = 0; runningSince = 0; } + if (started == null) started = now(); + if (runningSince == 0) runningSince = SystemClock.elapsedRealtime(); + } + + /** Playback paused; the listen stays open. */ + void paused() { + if (runningSince != 0) { + heard += SystemClock.elapsedRealtime() - runningSince; + runningSince = 0; + } + } + + /** The listen is over: "finish", "skip", "stop" or "quit". */ + void end(Context c, String how) { + paused(); + Library.Track t = track; + long ms = heard; + String when = started; + track = null; + started = null; + heard = 0; + if (t == null || when == null || ms < MIN_HEARD_MS) return; + Library lib = Library.get(); + if (lib == null || !lib.history) return; + final String line; + try { + JSONObject o = new JSONObject(); + o.put("t", when); + o.put("id", t.id); + o.put("ms", ms); + o.put("len", t.secs * 1000L); + o.put("end", how); + o.put("src", "r1"); + if (!t.title.isEmpty()) o.put("n", t.title); + if (!t.artist.isEmpty()) o.put("a", t.artist); + if (!t.albumArtist.isEmpty()) o.put("aa", t.albumArtist); + if (!t.album.isEmpty()) o.put("al", t.album); + if (t.year > 0) o.put("y", t.year); + line = o.toString() + "\n"; + } catch (JSONException e) { + return; + } + final File file = new File(new File(Library.root(), "history/andtunes-" + + Plays.installId(c)), when.substring(0, 7) + ".jsonl"); + IO.execute(() -> append(file, line)); + } + + private static void append(File file, String line) { + File dir = file.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs()) { + Log.w(TAG, "can't create " + dir); + return; + } + try (Writer w = new OutputStreamWriter(new FileOutputStream(file, true), + StandardCharsets.UTF_8)) { + w.write(line); + } catch (IOException e) { + Log.w(TAG, "couldn't record a listen", e); + } + } + + /** Local time with its UTC offset: "what time of day" is the listener's clock. */ + private static String now() { + return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT) + .format(new Date()); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Library.java b/andtunes/app/src/main/java/me/teafry/andtunes/Library.java index b5e9bf9..465e325 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/Library.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Library.java @@ -71,6 +71,7 @@ final class Library { final HashMap artistsByName = new HashMap<>(); final HashMap albumsByKey = new HashMap<>(); long stamp; // library.json mtime + size, to notice a re-sync + boolean history; // the desktop asks for a listen history (History.java) // ---- process-wide instance ---- @@ -156,6 +157,8 @@ final class Library { r.beginArray(); while (r.hasNext()) lib.addTrack(readTrack(r)); r.endArray(); + } else if (key.equals("history")) { + lib.history = r.nextBoolean(); } else if (key.equals("playlists")) { r.beginArray(); while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds)); diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java index 210ceb9..786d63f 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java @@ -77,6 +77,7 @@ public class PlaybackService extends Service implements private boolean prepared, playing, playWhenReady, resumeOnFocus, foreground; private int pendingSeek; private int failures; + private final History history = new History(); // ---- the static front door ---- @@ -176,6 +177,7 @@ public class PlaybackService extends Service implements } @Override public void onDestroy() { + history.end(this, "quit"); saveState(); abandonFocus(); unregisterNoisy(); @@ -228,6 +230,7 @@ public class PlaybackService extends Service implements mp.setVolume(1f, 1f); mp.start(); playing = true; + history.playing(current()); registerNoisy(); goForeground(); tick(); @@ -240,6 +243,7 @@ public class PlaybackService extends Service implements try { mp.pause(); } catch (IllegalStateException ignored) { } } playing = false; + history.paused(); unregisterNoisy(); if (foreground) { // Keep the notification (and the session) around while paused, but @@ -343,6 +347,7 @@ public class PlaybackService extends Service implements } private void load(int newPos, boolean play, int seekMs) { + history.end(this, "skip"); // a no-op after a finish, which ended it already pos = newPos; Library.Track t = current(); if (t == null) return; @@ -392,6 +397,7 @@ public class PlaybackService extends Service implements // A natural finish is a play — LinTunes' rule, so the counts agree. Library.Track done = current(); if (done != null) Plays.record(this, done.id); + history.end(this, "finish"); advance(true); } diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java b/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java index 6bd3be4..97236db 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java @@ -72,6 +72,11 @@ final class Plays { /** One stable id per install, so this device's file is only ever ours. */ private static File file(Context c) { + return new File(Library.root(), "plays/andtunes-" + installId(c) + ".json"); + } + + /** Also names this device's listen history folder ({@link History}). */ + static String installId(Context c) { SharedPreferences p = c.getSharedPreferences("andtunes", Context.MODE_PRIVATE); String id = p.getString("install_id", null); if (id == null) { @@ -80,7 +85,7 @@ final class Plays { id = String.format(Locale.ROOT, "%02x%02x%02x%02x", b[0], b[1], b[2], b[3]); p.edit().putString("install_id", id).apply(); } - return new File(Library.root(), "plays/andtunes-" + id + ".json"); + return id; } /** UTC, the same ISO form LinTunes stamps plays with (so they compare). */ diff --git a/andtunes/build.py b/andtunes/build.py index 95c380d..560c11f 100644 --- a/andtunes/build.py +++ b/andtunes/build.py @@ -26,8 +26,8 @@ import sys import zipfile from pathlib import Path -VERSION_NAME = "0.2.3" -VERSION_CODE = 5 +VERSION_NAME = "0.3.0" +VERSION_CODE = 6 PACKAGE = "me.teafry.andtunes" MIN_SDK = 26 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 5b09605..bd73aac 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.34.0" +__version__ = "0.35.0" diff --git a/lintunes/android/andTunes.apk b/lintunes/android/andTunes.apk index 56aae0ca637d85b5f5571b99897bf399dfe23658..fd2b1a0708296161becedbd15507f86e2321f488 100644 GIT binary patch delta 32847 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 diff --git a/lintunes/android/andTunes.json b/lintunes/android/andTunes.json index 488f188..e83647e 100644 --- a/lintunes/android/andTunes.json +++ b/lintunes/android/andTunes.json @@ -1 +1 @@ -{"version_name": "0.2.3", "version_code": 5} +{"version_name": "0.3.0", "version_code": 6} diff --git a/lintunes/andtunes/layout.py b/lintunes/andtunes/layout.py index 9b1417a..c02afee 100644 --- a/lintunes/andtunes/layout.py +++ b/lintunes/andtunes/layout.py @@ -14,10 +14,11 @@ reduced to m3u files pointing into it. Playlists/.m3u Buttons/*.png the user's own menu artwork plays/ counts the device writes back + history/andtunes-/YYYY-MM.jsonl one line per listen (Wrapped) Two of those directories are the *device's*, not ours: `Buttons` holds artwork -the user dropped there by hand and `plays` is written by the app. Sync never -deletes inside either — see PRESERVED_DIRS. +the user dropped there by hand and `plays` and `history` are written by the +app. Sync never deletes inside any of them — see PRESERVED_DIRS. """ import hashlib @@ -36,10 +37,11 @@ ART_DIR = "Art" PLAYLISTS_DIR = "Playlists" BUTTONS_DIR = "Buttons" PLAYS_DIR = "plays" +HISTORY_DIR = "history" MANIFEST_NAME = "library.json" # Subtrees of the andTunes root that sync reads but never writes or prunes. -PRESERVED_DIRS = frozenset({BUTTONS_DIR, PLAYS_DIR}) +PRESERVED_DIRS = frozenset({BUTTONS_DIR, PLAYS_DIR, HISTORY_DIR}) UNKNOWN_ARTIST = "Unknown Artist" UNKNOWN_ALBUM = "Unknown Album" diff --git a/lintunes/andtunes/manifest.py b/lintunes/andtunes/manifest.py index 4192064..ed71b32 100644 --- a/lintunes/andtunes/manifest.py +++ b/lintunes/andtunes/manifest.py @@ -48,7 +48,7 @@ def track_entry(track, media_rel: str, art_rel: str | None = None) -> dict: def build_manifest(entries, playlists, *, generator: str, - generated: str | None = None) -> dict: + generated: str | None = None, history: bool = False) -> dict: """The complete index. `entries` is a list of ``(track, media_rel, art_rel_or_None)`` in no @@ -56,11 +56,15 @@ def build_manifest(entries, playlists, *, generator: str, with the ids in playing order. A playlist's ids are filtered to tracks that actually made it onto the device, so the app never resolves a dangling reference — the m3u is filtered the same way by the worker. + + ``history`` tells the app to keep a listen history (the desktop's + "Keep a play history for my yearly Wrapped"); absent means off, which is + what an app reading an older manifest assumes too. """ rows = [track_entry(track, rel, art) for track, rel, art in entries] known = {row["id"] for row in rows} rows.sort(key=lambda r: r["id"]) - return { + manifest = { "format": FORMAT_VERSION, "generated": generated or _utc_now(), "generator": generator, @@ -71,3 +75,6 @@ def build_manifest(entries, playlists, *, generator: str, for name, pid, track_ids in playlists ], } + if history: + manifest["history"] = True + return manifest diff --git a/lintunes/andtunes/plays.py b/lintunes/andtunes/plays.py index 5b57506..71979d3 100644 --- a/lintunes/andtunes/plays.py +++ b/lintunes/andtunes/plays.py @@ -19,7 +19,8 @@ from pathlib import Path from ..storage.json_storage import read_json, write_json from ..storage.play_journal import JOURNAL_DIR, merge_totals -from .layout import PLAYS_DIR +from ..storage.listen_history import HISTORY_DIR as DATA_HISTORY_DIR +from .layout import HISTORY_DIR, PLAYS_DIR PREFIX = "andtunes-" @@ -71,3 +72,52 @@ def bring_back(root: Path, data_dir: Path) -> int: write_json(dest, dict(sorted(merged.items(), key=lambda kv: int(kv[0])))) moved += changed return moved + + +def bring_back_history(root: Path, data_dir: Path) -> int: + """Copy the app's listen history (`storage/listen_history.py`) home. + + ``history/andtunes-/.jsonl`` on the device lands at the same + path under ``/history/``. The app is the only writer and only + appends, so a device file **longer** than ours is newer and replaces it + whole; same length or shorter means nothing to do, and nothing is written. + One listing per folder and a stat per month file; only a month that + grew is read. + Returns the files copied. + """ + copied = 0 + device_dir = Path(root) / HISTORY_DIR + try: + folders = [d for d in device_dir.iterdir() + if d.name.startswith(PREFIX) and d.is_dir()] + except OSError: + return 0 + for folder in folders: + try: + months = [p for p in folder.iterdir() if p.suffix == ".jsonl"] + except OSError: + continue + dest_dir = Path(data_dir) / DATA_HISTORY_DIR / folder.name + for month in months: + dest = dest_dir / month.name + try: + have = dest.stat().st_size + except OSError: + have = -1 + try: + if month.stat().st_size <= have: + continue # a finished month: one stat, no read + data = month.read_bytes() + except OSError: + continue + # A torn last line is the app mid-append: keep only whole lines, + # the rest arrives next sync. + data = data[:data.rfind(b"\n") + 1] + if len(data) <= have: + continue + dest_dir.mkdir(parents=True, exist_ok=True) + tmp = dest.with_name(dest.name + ".tmp") + tmp.write_bytes(data) + tmp.replace(dest) + copied += 1 + return copied diff --git a/lintunes/andtunes/sync.py b/lintunes/andtunes/sync.py index b5c28fc..8a5e6b7 100644 --- a/lintunes/andtunes/sync.py +++ b/lintunes/andtunes/sync.py @@ -84,6 +84,7 @@ class AndTunesPlan: bytes_freed: int = 0 refused: list = field(default_factory=list) # (display, reason) data_dir: Path | None = None # where the device's play counts come back to + history: bool = False # ask the app to keep a listen history @property def conversions(self) -> list: @@ -479,6 +480,10 @@ class AndTunesSyncWorker(QObject): self._plays = plays_mod.bring_back(self._plan.root, self._plan.data_dir) except OSError: self._plays = 0 + try: + plays_mod.bring_back_history(self._plan.root, self._plan.data_dir) + except OSError: + pass # next sync: the files only grow def _convert(self, item) -> Path | None: """Transcode into the FLAC cache; None when ffmpeg can't. @@ -655,7 +660,8 @@ class AndTunesSyncWorker(QObject): playlists = [(e.name, e.persistent_id, e.track_ids) for e in plan.playlists] payload = build_manifest(entries, playlists, - generator=f"lintunes {__version__}") + generator=f"lintunes {__version__}", + history=plan.history) (plan.root / MANIFEST_NAME).write_text( json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") diff --git a/lintunes/cassette/service.py b/lintunes/cassette/service.py index 84e7be6..3cace45 100644 --- a/lintunes/cassette/service.py +++ b/lintunes/cassette/service.py @@ -237,6 +237,29 @@ class CassetteService(QObject): changed.append(token) return changed + def publish_wrapped(self, data_dir: Path, enabled: bool, + now: datetime | None = None) -> int: + """Keep each friend's ``wrapped-.json`` on its cadence + (`cassette/wrapped.py`). Cheap when nothing is due: one date + comparison and a stat per friend. Returns files written or removed.""" + from lintunes.cassette import wrapped + now = now or datetime.now(timezone.utc) + with self._lock: + outs = [out_dir(self.root, token) for token in self._state.friends] + if not enabled: + removed = wrapped.remove(outs) + if self._state.wrapped_published_at: + self._state.wrapped_published_at = "" + self._save() + return removed + if not outs or not (wrapped.is_due(self._state.wrapped_published_at, now) + or wrapped.missing(outs, now)): + return 0 + changed = wrapped.publish(outs, data_dir, self._state.display_name, now) + self._state.wrapped_published_at = now.isoformat(timespec="seconds") + self._save() + return changed + # ---- requests ---- def save_requests(self, token: str, cassetted, followed): diff --git a/lintunes/cassette/state.py b/lintunes/cassette/state.py index 23adb68..34c5e77 100644 --- a/lintunes/cassette/state.py +++ b/lintunes/cassette/state.py @@ -171,6 +171,8 @@ class CassetteState: # Devices that turned out not to hold an open invite: device id -> when. # They stay pending in Syncthing, untouched, and aren't probed again. rejected: dict = field(default_factory=dict) + # When this year's play history last went to friends (cassette/wrapped.py). + wrapped_published_at: str = "" def to_dict(self) -> dict: return {"version": FORMAT_VERSION, @@ -179,7 +181,8 @@ class CassetteState: "general": self.general.to_dict(), "invites": [i.to_dict() for i in self.invites.values()], "friends": [f.to_dict() for f in self.friends.values()], - "probing": dict(self.probing), "rejected": dict(self.rejected)} + "probing": dict(self.probing), "rejected": dict(self.rejected), + "wrapped_published_at": self.wrapped_published_at} @classmethod def from_dict(cls, d: dict | None) -> "CassetteState": @@ -189,7 +192,8 @@ class CassetteState: share_same_for_all=bool(d.get("share_same_for_all", False)), general=Selection.from_dict(d.get("general")), probing=dict(d.get("probing", {})), - rejected=dict(d.get("rejected", {}))) + rejected=dict(d.get("rejected", {})), + wrapped_published_at=d.get("wrapped_published_at", "")) for raw in d.get("invites", []): invite = Invite.from_dict(raw) state.invites[invite.token] = invite diff --git a/lintunes/cassette/wrapped.py b/lintunes/cassette/wrapped.py new file mode 100644 index 0000000..650c735 --- /dev/null +++ b/lintunes/cassette/wrapped.py @@ -0,0 +1,93 @@ +"""This year's play history in each friend's folder, for their own Wrapped. + +With "Share this year's play history with friends" on, every friend's +``out/`` gets ``wrapped-.json``: the trimmed listen history +(`storage/listen_history.trim_for_sharing` — what, when, how long, how it +ended; no track ids, playlists or devices), for them to build a Wrapped from +however they like. + +Deliberately not kept live. It's rewritten at most **daily in November and +December** (Wrapped season) and **monthly** the rest of the year, so the cost +of having it on is one date comparison per check. A friend added since the +last write gets theirs on the next check, and in January last year's file is +written once more so it ends on December 31 rather than on the last daily +refresh. Turning it off removes the files on the next check. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from lintunes.cassette.publish import read_json, write_json_if_changed +from lintunes.storage import listen_history + +FORMAT_VERSION = 1 +PREFIX = "wrapped-" +SEASON_MONTHS = (11, 12) + + +def file_name(year: int) -> str: + return f"{PREFIX}{year}.json" + + +def interval(now: datetime) -> timedelta: + return timedelta(days=1 if now.month in SEASON_MONTHS else 30) + + +def is_due(last: str, now: datetime) -> bool: + """``last`` is the UTC stamp of the previous write ("" = never).""" + if not last: + return True + try: + then = datetime.fromisoformat(last) + except ValueError: + return True + if then.tzinfo is None: + then = then.replace(tzinfo=timezone.utc) + return now - then >= interval(now) + + +def years_to_write(now: datetime) -> list[int]: + return [now.year - 1, now.year] if now.month == 1 else [now.year] + + +def build(data_dir: Path, year: int, display_name: str, now: datetime) -> dict: + return {"format": FORMAT_VERSION, "name": display_name, "year": year, + "generated": now.isoformat(timespec="seconds"), + "plays": listen_history.trim_for_sharing( + listen_history.read_year(data_dir, year))} + + +def publish(out_dirs: list[Path], data_dir: Path, display_name: str, + now: datetime) -> int: + """Write the file(s) into every friend's folder; returns files changed.""" + changed = 0 + for year in years_to_write(now): + doc = build(data_dir, year, display_name, now) + if not doc["plays"]: + continue + for out in out_dirs: + # "generated" moves every write; only rewrite when the plays did, + # or Syncthing re-sends an unchanged megabyte daily. + existing = out / file_name(year) + old = read_json(existing, {}) + if isinstance(old, dict) and old.get("plays") == doc["plays"] \ + and old.get("name") == display_name: + continue + if write_json_if_changed(existing, doc): + changed += 1 + return changed + + +def missing(out_dirs: list[Path], now: datetime) -> bool: + """A friend with no file for this year yet (one added since last time).""" + return any(not (out / file_name(now.year)).exists() for out in out_dirs) + + +def remove(out_dirs: list[Path]) -> int: + removed = 0 + for out in out_dirs: + for path in out.glob(f"{PREFIX}*.json"): + path.unlink(missing_ok=True) + removed += 1 + return removed diff --git a/lintunes/gui/cassette_ui.py b/lintunes/gui/cassette_ui.py index 5fce4e6..e24298b 100644 --- a/lintunes/gui/cassette_ui.py +++ b/lintunes/gui/cassette_ui.py @@ -19,6 +19,9 @@ NOTICE_MS = 10000 # Library edits are batched before friends' copies are rebuilt: an import of # a hundred files is one rewrite, not a hundred. REPUBLISH_DELAY_MS = 5000 +# How often to ask whether this year's play history is due to go to friends. +# The cadence itself (daily in Nov–Dec, else monthly) is cassette/wrapped.py's. +WRAPPED_CHECK_MS = 60 * 60 * 1000 class CassetteUi(QObject): @@ -32,6 +35,10 @@ class CassetteUi(QObject): self._republish_timer.setSingleShot(True) self._republish_timer.setInterval(REPUBLISH_DELAY_MS) self._republish_timer.timeout.connect(self._republish) + self._wrapped_was = None + self._wrapped_timer = QTimer(self) + self._wrapped_timer.setInterval(WRAPPED_CHECK_MS) + self._wrapped_timer.timeout.connect(self._check_wrapped) self._elsewhere_action: QAction | None = None self._actions: list[QAction] = [] self._service_factory = service_factory @@ -86,8 +93,33 @@ class CassetteUi(QObject): self.delivery.start() self._hook_window() self._refresh_selector() + if self._manager is not None: + self._prefs.changed.connect(self._on_prefs_for_wrapped) + self.service.friends_added.connect(self._check_wrapped) + self._wrapped_timer.start() + self._check_wrapped() return self.service + def _wrapped_enabled(self) -> bool: + wrapped = self._prefs.get("wrapped") or {} + return bool(wrapped.get("track_history") + and wrapped.get("share_with_friends")) + + def _on_prefs_for_wrapped(self): + # prefs.changed fires on every color-slider step; only the switch + # itself is news here. + if self._wrapped_enabled() != self._wrapped_was: + self._check_wrapped() + + def _check_wrapped(self, *_): + if self.service is None or self._manager is None: + return + enabled = self._wrapped_was = self._wrapped_enabled() + try: + self.service.publish_wrapped(self._manager.data_dir, enabled) + except OSError as exc: + self._on_step_failed("Sharing your play history", str(exc)) + def _library(self): return self._manager.library if self._manager is not None else None diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 87b0e62..d93c901 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -22,6 +22,7 @@ from lintunes.export import exporter from lintunes.fingerprint import TrackIdentifier, fpcalc_available from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor from lintunes.player import Player +from lintunes.storage import listen_history from lintunes.importers import file_importer from lintunes.gui.album_art_dialog import AlbumArtDialog from lintunes.gui.art_ops import embed_artwork @@ -109,6 +110,9 @@ class MainWindow(QMainWindow): # Context playback started from: "library" or "playlist:". Scopes # the now-playing speaker icon to where the song is actually playing. self._now_playing_context = "" + # The context the *playing* track came from. _now_playing_context + # moves to the new one before the old listen is closed. + self._listen_context = "" # Friend sharing's menu group and dialogs. Its background service is # started by run_gui (host machine only), never by constructing the # window — tests build windows by the dozen. @@ -271,6 +275,7 @@ class MainWindow(QMainWindow): lambda msg: QMessageBox.warning(self, "Can't play track", msg)) self.player.track_missing.connect(self._on_track_missing) self.player.track_changed.connect(self._on_track_changed) + self.player.listen_ended.connect(self._on_listen_ended) self.player.playing_changed.connect(self._update_now_playing_icon) self.player.playing_changed.connect(self._on_playing_changed) manager.track_updated.connect(self._on_track_meta_updated) @@ -708,6 +713,7 @@ class MainWindow(QMainWindow): def _on_andtunes_planned(self, plan, free): device = plan.device + plan.history = bool(self._prefs.wrapped.get("track_history")) playlists = plan.playlists self._hide_sync_widgets() if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available(): @@ -1812,9 +1818,24 @@ class MainWindow(QMainWindow): if track is None: self.setWindowTitle("LinTunes") else: + self._listen_context = self._now_playing_context self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes") self._update_now_playing_icon() + def _on_listen_ended(self, key, track, started, heard_ms, how): + """One listen for the Wrapped history, if the user keeps one.""" + if not self._prefs.get("wrapped", {}).get("track_history"): + return + ctx = self._listen_context + if ctx.startswith("followed:"): + ctx = "friend:" + ctx.split(":")[2] # followed:cassette:: + self._manager.record_listen(listen_history.entry_for( + track, started=started or listen_history.local_now(), + heard_ms=heard_ms, how=how, + src="desktop" if self.player.is_local else "cast", + ctx=ctx, shuffle=self.player.is_shuffle(), + track_id=None if isinstance(key, str) else key)) + def _on_track_meta_updated(self, track_id: int): current = self.player.current_track if current is not None and current.track_id == track_id: diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 03213e7..77c8d29 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -54,7 +54,7 @@ class PreferencesDialog(QDialog): self._color_save_timer.setInterval(400) self._color_save_timer.timeout.connect(self._prefs.save) - general = [self._build_sound_group()] + general = [self._build_sound_group(), self._build_wrapped_group()] if self._music_folder is not None: general.insert(0, self._build_music_group()) self.pages_list = QListWidget() @@ -139,6 +139,47 @@ class PreferencesDialog(QDialog): row.addWidget(preview) return group + # ---- Wrapped ---- + + def _build_wrapped_group(self) -> QGroupBox: + group = QGroupBox("Yearly Wrapped") + column = QVBoxLayout(group) + wrapped = self._prefs.wrapped + self._history_check = QCheckBox( + "Keep a play history for my yearly Wrapped") + self._history_check.setChecked(bool(wrapped.get("track_history"))) + column.addWidget(self._history_check) + column.addWidget(self._note( + "Every song you listen to, here and on the Rabbit: when, for how " + "long, and whether you finished it. Kept in the library folder; " + "nothing leaves your machines.")) + self._share_history_check = QCheckBox( + "Share this year's play history with friends") + self._share_history_check.setChecked( + bool(wrapped.get("share_with_friends"))) + column.addWidget(self._share_history_check) + column.addWidget(self._note( + "Goes in your cassette folder for each friend: what you played, " + "when and for how long — no playlists or devices. Refreshed " + "monthly, and daily in November and December.")) + self._share_history_check.setEnabled(self._history_check.isChecked()) + self._history_check.toggled.connect(self._on_history_toggled) + self._share_history_check.toggled.connect( + lambda on: self._prefs.update_wrapped(share_with_friends=on)) + return group + + @staticmethod + def _note(text: str) -> QLabel: + label = QLabel(text) + label.setWordWrap(True) + label.setEnabled(False) # the dimmed sub-label look + label.setContentsMargins(24, 0, 0, 6) + return label + + def _on_history_toggled(self, on: bool): + self._share_history_check.setEnabled(on) + self._prefs.update_wrapped(track_history=on) + # ---- Appearance ---- def _build_general_group(self) -> QGroupBox: diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index b793eab..fa32bcc 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -14,6 +14,7 @@ from lintunes.models import Library, Playlist, PlaylistType from lintunes.paths import to_relative from lintunes.perf import timed from lintunes.storage import json_storage +from lintunes.storage.listen_history import ListenHistory from lintunes.storage.play_journal import PlayJournal from lintunes.undo import Command, UndoStack @@ -75,6 +76,9 @@ class LibraryManager(QObject): self.play_journal = PlayJournal() self.play_journal.load(self.data_dir, self.library) self._dirty_journal = False + # One line per listen, for the yearly Wrapped — only fed while the + # user has it switched on (MainWindow checks the preference). + self.listen_history = ListenHistory(self.play_journal.machine) self._save_timer = QTimer(self) self._save_timer.setSingleShot(True) @@ -850,6 +854,12 @@ class LibraryManager(QObject): self.track_updated.emit(track_id) self._touch_smart({"play_count", "play_date_utc"}) + def record_listen(self, entry: dict | None): + """Queue one listen for the history; written with the next save.""" + if entry: + self.listen_history.add(entry) + self._schedule_save() + def record_skip(self, track_id: int): track = self.library.tracks.get(track_id) if not track: @@ -975,6 +985,11 @@ class LibraryManager(QObject): self._dirty_journal = False self._record_sig(self.data_dir / "plays" / f"{self.play_journal.machine}.json") + if self.listen_history.dirty: + try: + self.listen_history.flush(self.data_dir) + except OSError: + log.exception("couldn't write the listen history") if self._dirty_tracks: with timed("flush library.json (%d tracks)", len(self.library.tracks)): diff --git a/lintunes/player.py b/lintunes/player.py index 67fe403..0308aa2 100644 --- a/lintunes/player.py +++ b/lintunes/player.py @@ -8,6 +8,7 @@ from PyQt6.QtMultimedia import ( from lintunes.eventlog import log_control from lintunes.models import Track +from lintunes.storage import listen_history # Hitting "previous" more than this far into a song restarts it instead @@ -36,6 +37,23 @@ RESUME_NUDGE_THRESHOLD_S = 30 # on pause — if we ever move to a GStreamer sink, all of this goes away. PARK_AFTER_IDLE_S = 30 +# Listen accounting (the Wrapped history): a position step longer than this is +# a seek, not listening. Comfortably above the cast sink's 500 ms poll. +MAX_HEARD_STEP_MS = 1500 + + +class _Listen: + """How much of the current track has actually been heard.""" + + __slots__ = ("key", "track", "started", "heard", "last_pos") + + def __init__(self, key, track, start_ms: int): + self.key = key + self.track = track + self.started = None # local time of the first audible step + self.heard = 0 + self.last_pos = start_ms + def make_shuffle_order(count: int, start_index: int) -> list[int]: """A random permutation of range(count) that begins at start_index.""" @@ -401,6 +419,10 @@ class Player(QObject): track_finished = pyqtSignal(object) # Track played to the very end track_unplayable = pyqtSignal(object, str) # skipped: sink can't decode it sink_changed = pyqtSignal(object) # the new PlaybackSink + # One listen ended: (queue key, Track, local start datetime, ms heard, + # "finish" | "skip" | "stop" | "quit"). Feeds the Wrapped history; the + # listener decides whether it's kept and adds the context. + listen_ended = pyqtSignal(object, object, object, int, str) # Signals relayed from whichever sink is active. _SINK_SIGNALS = ("position_changed", "duration_changed", "state_changed", @@ -425,6 +447,7 @@ class Player(QObject): # journal. self.track_resolver = None # Callable[[str], Track | None] self._current_key = None + self._listen: _Listen | None = None self._volume = 1.0 # logical 0..1; what the volume slider shows self._local = LocalSink(self) @@ -455,6 +478,11 @@ class Player(QObject): def active_sink(self) -> PlaybackSink: return self._sink + @property + def is_local(self) -> bool: + """Playing through this computer's own output (not a Chromecast).""" + return self._sink is self._local + def provides_pcm(self) -> bool: return self._sink.provides_pcm @@ -497,6 +525,7 @@ class Player(QObject): if self._shutdown_done: return self._shutdown_done = True + self._close_listen(listen_history.QUIT) self._sink.shutdown() if self._sink is not self._local: # The local sink is kept alive across a swap so switching back is @@ -614,6 +643,10 @@ class Player(QObject): if self.is_playing(): self._sink.pause() elif self._current_track is not None: + if self._listen is None: + # Replaying the last track after the queue ran out. + self._listen = _Listen(self._current_key, self._current_track, + self._sink.position_ms()) self._sink.play() elif self._queue: self._index = max(self._index, 0) @@ -629,6 +662,7 @@ class Player(QObject): self._sink.pause() def stop(self): + self._close_listen(listen_history.STOP) self._sink.stop() self._current_track = None self._current_key = None @@ -708,6 +742,9 @@ class Player(QObject): self._index = next_index continue + self._close_listen(listen_history.SKIP) + start_ms = track.start_time if track.start_time > 0 else 0 + self._listen = _Listen(key, track, start_ms) self._current_track = track self._current_key = key # Arm the custom stop time for this track. It's honored only when @@ -755,6 +792,7 @@ class Player(QObject): def _on_position(self, position): """Stop a track at its custom stop time and move on, mirroring a natural finish (play count + scrobble).""" + self._account_heard(position) if self._stop_at_ms and position >= self._stop_at_ms: self._stop_at_ms = 0 # one-shot: don't re-fire while advancing track = self._current_track @@ -773,8 +811,30 @@ class Player(QObject): self._counted_finish_id = key if not isinstance(key, str): self._manager.record_play(track.track_id) + if track is self._current_track: + self._close_listen(listen_history.FINISH) self.track_finished.emit(track) + # ---- listen accounting ---- + + def _account_heard(self, position: int): + listen = self._listen + if listen is None: + return + step = position - listen.last_pos + listen.last_pos = position + if 0 < step <= MAX_HEARD_STEP_MS: + if listen.started is None: + listen.started = listen_history.local_now() + listen.heard += step + + def _close_listen(self, how: str): + listen, self._listen = self._listen, None + if listen is None or not listen.heard: + return + self.listen_ended.emit(listen.key, listen.track, listen.started, + listen.heard, how) + def _resolve(self, key): if isinstance(key, str): return self.track_resolver(key) if self.track_resolver else None diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 7519f8d..f0e0fb0 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -49,6 +49,13 @@ DEFAULTS = { "cassette_host": None, # The quiet "tink" when a sync, export or URL import finishes. "sound_effects": True, + # The yearly Wrapped (storage/listen_history.py). Synced on purpose: one + # tick and both desktops — and the Rabbit, via the sync — keep a history. + "wrapped": { + "track_history": False, + # This year's trimmed history in every friend's cassette folder. + "share_with_friends": False, + }, } @@ -114,6 +121,15 @@ class Preferences(QObject): def device_sync(self) -> dict: return self._data["device_sync"] + def update_wrapped(self, **fields): + self._data["wrapped"].update(fields) + self.save() + self.changed.emit() + + @property + def wrapped(self) -> dict: + return self._data["wrapped"] + def save(self): self._path.parent.mkdir(parents=True, exist_ok=True) tmp = self._path.with_suffix(".json.tmp") diff --git a/lintunes/storage/conflict_resolver.py b/lintunes/storage/conflict_resolver.py index 3776e2e..19a0d73 100644 --- a/lintunes/storage/conflict_resolver.py +++ b/lintunes/storage/conflict_resolver.py @@ -1,3 +1,4 @@ +import json import re import shutil from dataclasses import dataclass, field @@ -133,6 +134,9 @@ def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]: summary = _merge_playlist(original_path, conflict_path, item, label) elif original_path.parent.name == "plays": summary = _merge_play_journal(original_path, conflict_path) + elif (original_path.suffix == ".jsonl" + and original_path.parent.parent.name == "history"): + summary = _merge_listen_history(original_path, conflict_path) else: summary = _keep_newer(original_path, conflict_path) except Exception as e: # never let a bad file abort startup/live reload @@ -745,6 +749,35 @@ def _merge_play_journal(original_path: Path, conflict_path: Path) -> ConflictSum level=WARNING) +def _merge_listen_history(original_path: Path, + conflict_path: Path) -> ConflictSummary: + """A listen-history month file (`listen_history.py`). Lines only ever get + appended, so the merge is the union of both copies' lines, oldest first. + The one way to get here is the Rabbit's history: both desktops bring it + back, and one can be a sync behind the other — so it grades INFO.""" + from lintunes.storage import listen_history + seen = set() + entries = [] + for path in (original_path, conflict_path): + for entry in listen_history.read_lines(path): + line = json.dumps(entry, ensure_ascii=False, separators=(",", ":"), + sort_keys=True) + if line not in seen: + seen.add(line) + entries.append(entry) + entries.sort(key=listen_history._sort_key) + text = "".join(json.dumps(e, ensure_ascii=False, separators=(",", ":")) + "\n" + for e in entries) + name = f"history/{original_path.parent.name}/{original_path.name}" + if original_path.read_text(encoding="utf-8") != text: + tmp = original_path.with_name(original_path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + tmp.replace(original_path) + return ConflictSummary(name, "history", + [f"Play history merged ({len(entries)} listens)."], + level=INFO) + + # What library_metadata.json actually holds, in words a person recognizes. _METADATA_LABELS = { "library_settings": "column layout", diff --git a/lintunes/storage/listen_history.py b/lintunes/storage/listen_history.py new file mode 100644 index 0000000..41bfc79 --- /dev/null +++ b/lintunes/storage/listen_history.py @@ -0,0 +1,159 @@ +"""Listen history — one line per listen, for a yearly Wrapped (Round 74). + +The play journals (`play_journal.py`) keep *totals*: how many times, and the +last time. That answers "most played ever" and nothing a Wrapped asks — top +songs *of this year*, minutes listened, which month, what time of day, what +got skipped. All of those come from one timestamped record per listen, so +this keeps exactly that, and only while the user has ticked "Keep a play +history for my yearly Wrapped". + +Layout: ``/history//.jsonl``. The no-conflict +rule is the journals' — each machine (and each Rabbit, as +``andtunes-``) writes only its own folder, so Syncthing never sees +two writers. Month files mean a finished month never changes again, and +appending means a listen costs one line, not a rewrite. + +A line:: + + {"t": "2026-10-02T21:14:03-07:00", "id": 4812, "ms": 201300, "len": 214000, + "end": "finish", "src": "desktop", "ctx": "playlist:3fa9c1d2", + "shuf": true, "n": "Title", "a": "Artist", "aa": "Album Artist", + "al": "Album", "g": "Genre", "y": 1994} + +``t`` is *local* time with its offset, because "what time of day do I listen" +is a question about the listener's clock. ``ms`` is time actually heard +(pauses and seeks don't count); ``end`` is how it ended. The name/artist/album +snapshot is there so a song deleted or retagged by December still shows up +under the name it had when it was played. +""" +import json +from datetime import datetime +from pathlib import Path + +HISTORY_DIR = "history" + +# Shorter than this is noise — a held arrow key, a fast skip-through. Wrapped +# can still apply Spotify's 30 s rule when it reads the data. +MIN_HEARD_MS = 5000 + +FINISH, SKIP, STOP, QUIT = "finish", "skip", "stop", "quit" + +# What a friend receives: when, how long, how it ended, and what it was. +# No track id, playlist, device or shuffle — those describe my library and my +# machines, not my listening. +SHARED_FIELDS = ("t", "ms", "len", "end", "n", "a", "aa", "al", "g", "y") + + +def local_now() -> datetime: + return datetime.now().astimezone() + + +def entry_for(track, *, started: datetime, heard_ms: int, how: str, + src: str, ctx: str = "", shuffle: bool = False, + track_id=None) -> dict | None: + """The line for one listen, or None when too little of it was heard.""" + if track is None or heard_ms < MIN_HEARD_MS: + return None + entry = {"t": started.isoformat(timespec="seconds"), + "ms": int(heard_ms), "len": int(track.total_time or 0), + "end": how, "src": src} + if isinstance(track_id, int): + entry["id"] = track_id + if ctx: + entry["ctx"] = ctx + if shuffle: + entry["shuf"] = True + for key, field in (("n", "name"), ("a", "artist"), ("aa", "album_artist"), + ("al", "album"), ("g", "genre")): + value = getattr(track, field, "") or "" + if value: + entry[key] = value + if getattr(track, "year", 0): + entry["y"] = track.year + return entry + + +def month_of(entry: dict) -> str: + """``YYYY-MM`` of the listen, on the listener's own clock.""" + return entry["t"][:7] + + +class ListenHistory: + """This machine's unwritten listens. ``flush`` appends them to the month + files; nothing else ever writes this machine's folder.""" + + def __init__(self, machine: str): + self.machine = machine + self.pending: list[dict] = [] + + def add(self, entry: dict | None): + if entry: + self.pending.append(entry) + + @property + def dirty(self) -> bool: + return bool(self.pending) + + def folder(self, data_dir: Path) -> Path: + return Path(data_dir) / HISTORY_DIR / self.machine + + def flush(self, data_dir: Path) -> list[Path]: + """Append pending listens; returns the files written.""" + if not self.pending: + return [] + by_month: dict[str, list[dict]] = {} + for entry in self.pending: + by_month.setdefault(month_of(entry), []).append(entry) + folder = self.folder(data_dir) + folder.mkdir(parents=True, exist_ok=True) + written = [] + for month, entries in sorted(by_month.items()): + path = folder / f"{month}.jsonl" + with open(path, "a", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry, ensure_ascii=False, + separators=(",", ":")) + "\n") + written.append(path) + self.pending = [] + return written + + +def read_lines(path: Path) -> list[dict]: + """A month file's listens. A torn last line (a crash mid-append, or a copy + Syncthing hasn't finished) is skipped, never fatal.""" + out = [] + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + return out + for line in text.splitlines(): + try: + entry = json.loads(line) + except ValueError: + continue + if isinstance(entry, dict) and isinstance(entry.get("t"), str): + out.append(entry) + return out + + +def _sort_key(entry: dict): + try: + return datetime.fromisoformat(entry["t"]).timestamp() + except ValueError: + return 0.0 + + +def read_year(data_dir: Path, year: int) -> list[dict]: + """Every machine's listens in ``year``, oldest first.""" + root = Path(data_dir) / HISTORY_DIR + entries = [] + if root.is_dir(): + for path in sorted(root.glob(f"*/{year:04d}-*.jsonl")): + entries.extend(e for e in read_lines(path) + if e["t"].startswith(f"{year:04d}-")) + entries.sort(key=_sort_key) + return entries + + +def trim_for_sharing(entries: list[dict]) -> list[dict]: + return [{k: e[k] for k in SHARED_FIELDS if k in e} for e in entries] diff --git a/scripts/make_dev_library.py b/scripts/make_dev_library.py index a3685a6..6ef2ae8 100644 --- a/scripts/make_dev_library.py +++ b/scripts/make_dev_library.py @@ -259,6 +259,7 @@ def build(out: Path, seed: int = 47): shutil.rmtree(data_dir) json_storage.save_library(library, data_dir) _write_plays(data_dir, ids) + _write_history(data_dir, library) _write_prefs(data_dir, library) shutil.rmtree(covers, ignore_errors=True) @@ -371,6 +372,40 @@ def _write_plays(data_dir: Path, ids: list): json.dumps(entries, indent=2), encoding="utf-8") +def _write_history(data_dir: Path, library: Library): + """A Wrapped-shaped listen history (storage/listen_history.py): a desktop + across three months and a Rabbit across one, with a torn last line on the + Rabbit's — what a sync caught mid-append looks like.""" + from datetime import datetime, timedelta, timezone + from lintunes.storage.listen_history import entry_for + tracks = [t for t in library.tracks.values() if t.total_time] + zone = timezone(timedelta(hours=-7)) + for machine, src, months, count in (("devbox", "desktop", (8, 9, 10), 40), + ("andtunes-dev0r1", "r1", (10,), 15)): + folder = data_dir / "history" / machine + folder.mkdir(parents=True, exist_ok=True) + for month in months: + lines = [] + when = datetime(2026, month, 1, 8, 0, tzinfo=zone) + for _ in range(count): + track = random.choice(tracks) + finished = random.random() < 0.7 + heard = (track.total_time if finished + else random.randint(6000, max(6001, track.total_time))) + entry = entry_for(track, started=when, heard_ms=heard, + how="finish" if finished else "skip", src=src, + ctx="" if src == "r1" else "library", + track_id=track.track_id) + if entry: # clips under 5 s heard are noise, as in the app + lines.append(json.dumps(entry, ensure_ascii=False, + separators=(",", ":"))) + when += timedelta(hours=random.randint(1, 30)) + text = "\n".join(lines) + "\n" + if src == "r1": + text += '{"t":"2026-10-3' + (folder / f"2026-{month:02d}.jsonl").write_text(text, encoding="utf-8") + + def _write_prefs(data_dir: Path, library: Library): (data_dir / "preferences.json").write_text(json.dumps({ "ui_scale": "medium", diff --git a/tests/test_round74.py b/tests/test_round74.py new file mode 100644 index 0000000..64df979 --- /dev/null +++ b/tests/test_round74.py @@ -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()