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 56aae0c..fd2b1a0 100644 Binary files a/lintunes/android/andTunes.apk and b/lintunes/android/andTunes.apk differ 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()