v0.35.0: a play history for the yearly Wrapped

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 16:47:40 -07:00
co-authored by Claude Opus 5.5
parent b3d6c01774
commit ad1d152948
26 changed files with 1148 additions and 15 deletions
+26
View File
@@ -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 `journal.base_fields(tid)`, never the `Track`'s effective count. Those two
places are the whole hazard; `tests/test_round38.py` pins both. 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 `<data_dir>/history/<machine id>/<YYYY-MM>.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-<year>.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 - **`lintunes/library_manager.py`** — `LibraryManager(QObject)` owns the
`Library`, is the single funnel for all mutations, and persists them `Library`, is the single funnel for all mutations, and persists them
**debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites **debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites
@@ -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.
*
* <p>One line per listen in
* {@code Music/andTunes/history/andtunes-<install id>/<YYYY-MM>.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.
*
* <p>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());
}
}
@@ -71,6 +71,7 @@ final class Library {
final HashMap<String, Artist> artistsByName = new HashMap<>(); final HashMap<String, Artist> artistsByName = new HashMap<>();
final HashMap<String, Album> albumsByKey = new HashMap<>(); final HashMap<String, Album> albumsByKey = new HashMap<>();
long stamp; // library.json mtime + size, to notice a re-sync 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 ---- // ---- process-wide instance ----
@@ -156,6 +157,8 @@ final class Library {
r.beginArray(); r.beginArray();
while (r.hasNext()) lib.addTrack(readTrack(r)); while (r.hasNext()) lib.addTrack(readTrack(r));
r.endArray(); r.endArray();
} else if (key.equals("history")) {
lib.history = r.nextBoolean();
} else if (key.equals("playlists")) { } else if (key.equals("playlists")) {
r.beginArray(); r.beginArray();
while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds)); while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds));
@@ -77,6 +77,7 @@ public class PlaybackService extends Service implements
private boolean prepared, playing, playWhenReady, resumeOnFocus, foreground; private boolean prepared, playing, playWhenReady, resumeOnFocus, foreground;
private int pendingSeek; private int pendingSeek;
private int failures; private int failures;
private final History history = new History();
// ---- the static front door ---- // ---- the static front door ----
@@ -176,6 +177,7 @@ public class PlaybackService extends Service implements
} }
@Override public void onDestroy() { @Override public void onDestroy() {
history.end(this, "quit");
saveState(); saveState();
abandonFocus(); abandonFocus();
unregisterNoisy(); unregisterNoisy();
@@ -228,6 +230,7 @@ public class PlaybackService extends Service implements
mp.setVolume(1f, 1f); mp.setVolume(1f, 1f);
mp.start(); mp.start();
playing = true; playing = true;
history.playing(current());
registerNoisy(); registerNoisy();
goForeground(); goForeground();
tick(); tick();
@@ -240,6 +243,7 @@ public class PlaybackService extends Service implements
try { mp.pause(); } catch (IllegalStateException ignored) { } try { mp.pause(); } catch (IllegalStateException ignored) { }
} }
playing = false; playing = false;
history.paused();
unregisterNoisy(); unregisterNoisy();
if (foreground) { if (foreground) {
// Keep the notification (and the session) around while paused, but // 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) { 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; pos = newPos;
Library.Track t = current(); Library.Track t = current();
if (t == null) return; 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. // A natural finish is a play — LinTunes' rule, so the counts agree.
Library.Track done = current(); Library.Track done = current();
if (done != null) Plays.record(this, done.id); if (done != null) Plays.record(this, done.id);
history.end(this, "finish");
advance(true); advance(true);
} }
@@ -72,6 +72,11 @@ final class Plays {
/** One stable id per install, so this device's file is only ever ours. */ /** One stable id per install, so this device's file is only ever ours. */
private static File file(Context c) { 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); SharedPreferences p = c.getSharedPreferences("andtunes", Context.MODE_PRIVATE);
String id = p.getString("install_id", null); String id = p.getString("install_id", null);
if (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]); id = String.format(Locale.ROOT, "%02x%02x%02x%02x", b[0], b[1], b[2], b[3]);
p.edit().putString("install_id", id).apply(); 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). */ /** UTC, the same ISO form LinTunes stamps plays with (so they compare). */
+2 -2
View File
@@ -26,8 +26,8 @@ import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
VERSION_NAME = "0.2.3" VERSION_NAME = "0.3.0"
VERSION_CODE = 5 VERSION_CODE = 6
PACKAGE = "me.teafry.andtunes" PACKAGE = "me.teafry.andtunes"
MIN_SDK = 26 MIN_SDK = 26
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.34.0" __version__ = "0.35.0"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version_name": "0.2.3", "version_code": 5} {"version_name": "0.3.0", "version_code": 6}
+5 -3
View File
@@ -14,10 +14,11 @@ reduced to m3u files pointing into it.
Playlists/<Name>.m3u Playlists/<Name>.m3u
Buttons/*.png the user's own menu artwork Buttons/*.png the user's own menu artwork
plays/ counts the device writes back plays/ counts the device writes back
history/andtunes-<id>/YYYY-MM.jsonl one line per listen (Wrapped)
Two of those directories are the *device's*, not ours: `Buttons` holds artwork 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 the user dropped there by hand and `plays` and `history` are written by the
deletes inside either — see PRESERVED_DIRS. app. Sync never deletes inside any of them — see PRESERVED_DIRS.
""" """
import hashlib import hashlib
@@ -36,10 +37,11 @@ ART_DIR = "Art"
PLAYLISTS_DIR = "Playlists" PLAYLISTS_DIR = "Playlists"
BUTTONS_DIR = "Buttons" BUTTONS_DIR = "Buttons"
PLAYS_DIR = "plays" PLAYS_DIR = "plays"
HISTORY_DIR = "history"
MANIFEST_NAME = "library.json" MANIFEST_NAME = "library.json"
# Subtrees of the andTunes root that sync reads but never writes or prunes. # 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_ARTIST = "Unknown Artist"
UNKNOWN_ALBUM = "Unknown Album" UNKNOWN_ALBUM = "Unknown Album"
+9 -2
View File
@@ -48,7 +48,7 @@ def track_entry(track, media_rel: str, art_rel: str | None = None) -> dict:
def build_manifest(entries, playlists, *, generator: str, def build_manifest(entries, playlists, *, generator: str,
generated: str | None = None) -> dict: generated: str | None = None, history: bool = False) -> dict:
"""The complete index. """The complete index.
`entries` is a list of ``(track, media_rel, art_rel_or_None)`` in no `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 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 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. 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] rows = [track_entry(track, rel, art) for track, rel, art in entries]
known = {row["id"] for row in rows} known = {row["id"] for row in rows}
rows.sort(key=lambda r: r["id"]) rows.sort(key=lambda r: r["id"])
return { manifest = {
"format": FORMAT_VERSION, "format": FORMAT_VERSION,
"generated": generated or _utc_now(), "generated": generated or _utc_now(),
"generator": generator, "generator": generator,
@@ -71,3 +75,6 @@ def build_manifest(entries, playlists, *, generator: str,
for name, pid, track_ids in playlists for name, pid, track_ids in playlists
], ],
} }
if history:
manifest["history"] = True
return manifest
+51 -1
View File
@@ -19,7 +19,8 @@ from pathlib import Path
from ..storage.json_storage import read_json, write_json from ..storage.json_storage import read_json, write_json
from ..storage.play_journal import JOURNAL_DIR, merge_totals 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-" 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])))) write_json(dest, dict(sorted(merged.items(), key=lambda kv: int(kv[0]))))
moved += changed moved += changed
return moved 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-<id>/<YYYY-MM>.jsonl`` on the device lands at the same
path under ``<data_dir>/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
+7 -1
View File
@@ -84,6 +84,7 @@ class AndTunesPlan:
bytes_freed: int = 0 bytes_freed: int = 0
refused: list = field(default_factory=list) # (display, reason) refused: list = field(default_factory=list) # (display, reason)
data_dir: Path | None = None # where the device's play counts come back to 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 @property
def conversions(self) -> list: def conversions(self) -> list:
@@ -479,6 +480,10 @@ class AndTunesSyncWorker(QObject):
self._plays = plays_mod.bring_back(self._plan.root, self._plan.data_dir) self._plays = plays_mod.bring_back(self._plan.root, self._plan.data_dir)
except OSError: except OSError:
self._plays = 0 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: def _convert(self, item) -> Path | None:
"""Transcode into the FLAC cache; None when ffmpeg can't. """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) playlists = [(e.name, e.persistent_id, e.track_ids)
for e in plan.playlists] for e in plan.playlists]
payload = build_manifest(entries, playlists, payload = build_manifest(entries, playlists,
generator=f"lintunes {__version__}") generator=f"lintunes {__version__}",
history=plan.history)
(plan.root / MANIFEST_NAME).write_text( (plan.root / MANIFEST_NAME).write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")), json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8") encoding="utf-8")
+23
View File
@@ -237,6 +237,29 @@ class CassetteService(QObject):
changed.append(token) changed.append(token)
return changed return changed
def publish_wrapped(self, data_dir: Path, enabled: bool,
now: datetime | None = None) -> int:
"""Keep each friend's ``wrapped-<year>.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 ---- # ---- requests ----
def save_requests(self, token: str, cassetted, followed): def save_requests(self, token: str, cassetted, followed):
+6 -2
View File
@@ -171,6 +171,8 @@ class CassetteState:
# Devices that turned out not to hold an open invite: device id -> when. # Devices that turned out not to hold an open invite: device id -> when.
# They stay pending in Syncthing, untouched, and aren't probed again. # They stay pending in Syncthing, untouched, and aren't probed again.
rejected: dict = field(default_factory=dict) 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: def to_dict(self) -> dict:
return {"version": FORMAT_VERSION, return {"version": FORMAT_VERSION,
@@ -179,7 +181,8 @@ class CassetteState:
"general": self.general.to_dict(), "general": self.general.to_dict(),
"invites": [i.to_dict() for i in self.invites.values()], "invites": [i.to_dict() for i in self.invites.values()],
"friends": [f.to_dict() for f in self.friends.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 @classmethod
def from_dict(cls, d: dict | None) -> "CassetteState": 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)), share_same_for_all=bool(d.get("share_same_for_all", False)),
general=Selection.from_dict(d.get("general")), general=Selection.from_dict(d.get("general")),
probing=dict(d.get("probing", {})), 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", []): for raw in d.get("invites", []):
invite = Invite.from_dict(raw) invite = Invite.from_dict(raw)
state.invites[invite.token] = invite state.invites[invite.token] = invite
+93
View File
@@ -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-<year>.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
+32
View File
@@ -19,6 +19,9 @@ NOTICE_MS = 10000
# Library edits are batched before friends' copies are rebuilt: an import of # Library edits are batched before friends' copies are rebuilt: an import of
# a hundred files is one rewrite, not a hundred. # a hundred files is one rewrite, not a hundred.
REPUBLISH_DELAY_MS = 5000 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): class CassetteUi(QObject):
@@ -32,6 +35,10 @@ class CassetteUi(QObject):
self._republish_timer.setSingleShot(True) self._republish_timer.setSingleShot(True)
self._republish_timer.setInterval(REPUBLISH_DELAY_MS) self._republish_timer.setInterval(REPUBLISH_DELAY_MS)
self._republish_timer.timeout.connect(self._republish) 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._elsewhere_action: QAction | None = None
self._actions: list[QAction] = [] self._actions: list[QAction] = []
self._service_factory = service_factory self._service_factory = service_factory
@@ -86,8 +93,33 @@ class CassetteUi(QObject):
self.delivery.start() self.delivery.start()
self._hook_window() self._hook_window()
self._refresh_selector() 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 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): def _library(self):
return self._manager.library if self._manager is not None else None return self._manager.library if self._manager is not None else None
+21
View File
@@ -22,6 +22,7 @@ from lintunes.export import exporter
from lintunes.fingerprint import TrackIdentifier, fpcalc_available from lintunes.fingerprint import TrackIdentifier, fpcalc_available
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.player import Player from lintunes.player import Player
from lintunes.storage import listen_history
from lintunes.importers import file_importer from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.art_ops import embed_artwork from lintunes.gui.art_ops import embed_artwork
@@ -109,6 +110,9 @@ class MainWindow(QMainWindow):
# Context playback started from: "library" or "playlist:<pid>". Scopes # Context playback started from: "library" or "playlist:<pid>". Scopes
# the now-playing speaker icon to where the song is actually playing. # the now-playing speaker icon to where the song is actually playing.
self._now_playing_context = "" 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 # Friend sharing's menu group and dialogs. Its background service is
# started by run_gui (host machine only), never by constructing the # started by run_gui (host machine only), never by constructing the
# window — tests build windows by the dozen. # window — tests build windows by the dozen.
@@ -271,6 +275,7 @@ class MainWindow(QMainWindow):
lambda msg: QMessageBox.warning(self, "Can't play track", msg)) lambda msg: QMessageBox.warning(self, "Can't play track", msg))
self.player.track_missing.connect(self._on_track_missing) self.player.track_missing.connect(self._on_track_missing)
self.player.track_changed.connect(self._on_track_changed) 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._update_now_playing_icon)
self.player.playing_changed.connect(self._on_playing_changed) self.player.playing_changed.connect(self._on_playing_changed)
manager.track_updated.connect(self._on_track_meta_updated) manager.track_updated.connect(self._on_track_meta_updated)
@@ -708,6 +713,7 @@ class MainWindow(QMainWindow):
def _on_andtunes_planned(self, plan, free): def _on_andtunes_planned(self, plan, free):
device = plan.device device = plan.device
plan.history = bool(self._prefs.wrapped.get("track_history"))
playlists = plan.playlists playlists = plan.playlists
self._hide_sync_widgets() self._hide_sync_widgets()
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available(): if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
@@ -1812,9 +1818,24 @@ class MainWindow(QMainWindow):
if track is None: if track is None:
self.setWindowTitle("LinTunes") self.setWindowTitle("LinTunes")
else: else:
self._listen_context = self._now_playing_context
self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes") self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes")
self._update_now_playing_icon() 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:<token>:<pid>
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): def _on_track_meta_updated(self, track_id: int):
current = self.player.current_track current = self.player.current_track
if current is not None and current.track_id == track_id: if current is not None and current.track_id == track_id:
+42 -1
View File
@@ -54,7 +54,7 @@ class PreferencesDialog(QDialog):
self._color_save_timer.setInterval(400) self._color_save_timer.setInterval(400)
self._color_save_timer.timeout.connect(self._prefs.save) 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: if self._music_folder is not None:
general.insert(0, self._build_music_group()) general.insert(0, self._build_music_group())
self.pages_list = QListWidget() self.pages_list = QListWidget()
@@ -139,6 +139,47 @@ class PreferencesDialog(QDialog):
row.addWidget(preview) row.addWidget(preview)
return group 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 ---- # ---- Appearance ----
def _build_general_group(self) -> QGroupBox: def _build_general_group(self) -> QGroupBox:
+15
View File
@@ -14,6 +14,7 @@ from lintunes.models import Library, Playlist, PlaylistType
from lintunes.paths import to_relative from lintunes.paths import to_relative
from lintunes.perf import timed from lintunes.perf import timed
from lintunes.storage import json_storage from lintunes.storage import json_storage
from lintunes.storage.listen_history import ListenHistory
from lintunes.storage.play_journal import PlayJournal from lintunes.storage.play_journal import PlayJournal
from lintunes.undo import Command, UndoStack from lintunes.undo import Command, UndoStack
@@ -75,6 +76,9 @@ class LibraryManager(QObject):
self.play_journal = PlayJournal() self.play_journal = PlayJournal()
self.play_journal.load(self.data_dir, self.library) self.play_journal.load(self.data_dir, self.library)
self._dirty_journal = False 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 = QTimer(self)
self._save_timer.setSingleShot(True) self._save_timer.setSingleShot(True)
@@ -850,6 +854,12 @@ class LibraryManager(QObject):
self.track_updated.emit(track_id) self.track_updated.emit(track_id)
self._touch_smart({"play_count", "play_date_utc"}) 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): def record_skip(self, track_id: int):
track = self.library.tracks.get(track_id) track = self.library.tracks.get(track_id)
if not track: if not track:
@@ -975,6 +985,11 @@ class LibraryManager(QObject):
self._dirty_journal = False self._dirty_journal = False
self._record_sig(self.data_dir / "plays" self._record_sig(self.data_dir / "plays"
/ f"{self.play_journal.machine}.json") / 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: if self._dirty_tracks:
with timed("flush library.json (%d tracks)", with timed("flush library.json (%d tracks)",
len(self.library.tracks)): len(self.library.tracks)):
+60
View File
@@ -8,6 +8,7 @@ from PyQt6.QtMultimedia import (
from lintunes.eventlog import log_control from lintunes.eventlog import log_control
from lintunes.models import Track from lintunes.models import Track
from lintunes.storage import listen_history
# Hitting "previous" more than this far into a song restarts it instead # 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. # on pause — if we ever move to a GStreamer sink, all of this goes away.
PARK_AFTER_IDLE_S = 30 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]: def make_shuffle_order(count: int, start_index: int) -> list[int]:
"""A random permutation of range(count) that begins at start_index.""" """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_finished = pyqtSignal(object) # Track played to the very end
track_unplayable = pyqtSignal(object, str) # skipped: sink can't decode it track_unplayable = pyqtSignal(object, str) # skipped: sink can't decode it
sink_changed = pyqtSignal(object) # the new PlaybackSink 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. # Signals relayed from whichever sink is active.
_SINK_SIGNALS = ("position_changed", "duration_changed", "state_changed", _SINK_SIGNALS = ("position_changed", "duration_changed", "state_changed",
@@ -425,6 +447,7 @@ class Player(QObject):
# journal. # journal.
self.track_resolver = None # Callable[[str], Track | None] self.track_resolver = None # Callable[[str], Track | None]
self._current_key = None self._current_key = None
self._listen: _Listen | None = None
self._volume = 1.0 # logical 0..1; what the volume slider shows self._volume = 1.0 # logical 0..1; what the volume slider shows
self._local = LocalSink(self) self._local = LocalSink(self)
@@ -455,6 +478,11 @@ class Player(QObject):
def active_sink(self) -> PlaybackSink: def active_sink(self) -> PlaybackSink:
return self._sink 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: def provides_pcm(self) -> bool:
return self._sink.provides_pcm return self._sink.provides_pcm
@@ -497,6 +525,7 @@ class Player(QObject):
if self._shutdown_done: if self._shutdown_done:
return return
self._shutdown_done = True self._shutdown_done = True
self._close_listen(listen_history.QUIT)
self._sink.shutdown() self._sink.shutdown()
if self._sink is not self._local: if self._sink is not self._local:
# The local sink is kept alive across a swap so switching back is # The local sink is kept alive across a swap so switching back is
@@ -614,6 +643,10 @@ class Player(QObject):
if self.is_playing(): if self.is_playing():
self._sink.pause() self._sink.pause()
elif self._current_track is not None: 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() self._sink.play()
elif self._queue: elif self._queue:
self._index = max(self._index, 0) self._index = max(self._index, 0)
@@ -629,6 +662,7 @@ class Player(QObject):
self._sink.pause() self._sink.pause()
def stop(self): def stop(self):
self._close_listen(listen_history.STOP)
self._sink.stop() self._sink.stop()
self._current_track = None self._current_track = None
self._current_key = None self._current_key = None
@@ -708,6 +742,9 @@ class Player(QObject):
self._index = next_index self._index = next_index
continue 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_track = track
self._current_key = key self._current_key = key
# Arm the custom stop time for this track. It's honored only when # 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): def _on_position(self, position):
"""Stop a track at its custom stop time and move on, mirroring a """Stop a track at its custom stop time and move on, mirroring a
natural finish (play count + scrobble).""" natural finish (play count + scrobble)."""
self._account_heard(position)
if self._stop_at_ms and position >= self._stop_at_ms: if self._stop_at_ms and position >= self._stop_at_ms:
self._stop_at_ms = 0 # one-shot: don't re-fire while advancing self._stop_at_ms = 0 # one-shot: don't re-fire while advancing
track = self._current_track track = self._current_track
@@ -773,8 +811,30 @@ class Player(QObject):
self._counted_finish_id = key self._counted_finish_id = key
if not isinstance(key, str): if not isinstance(key, str):
self._manager.record_play(track.track_id) self._manager.record_play(track.track_id)
if track is self._current_track:
self._close_listen(listen_history.FINISH)
self.track_finished.emit(track) 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): def _resolve(self, key):
if isinstance(key, str): if isinstance(key, str):
return self.track_resolver(key) if self.track_resolver else None return self.track_resolver(key) if self.track_resolver else None
+16
View File
@@ -49,6 +49,13 @@ DEFAULTS = {
"cassette_host": None, "cassette_host": None,
# The quiet "tink" when a sync, export or URL import finishes. # The quiet "tink" when a sync, export or URL import finishes.
"sound_effects": True, "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: def device_sync(self) -> dict:
return self._data["device_sync"] 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): def save(self):
self._path.parent.mkdir(parents=True, exist_ok=True) self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(".json.tmp") tmp = self._path.with_suffix(".json.tmp")
+33
View File
@@ -1,3 +1,4 @@
import json
import re import re
import shutil import shutil
from dataclasses import dataclass, field 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) summary = _merge_playlist(original_path, conflict_path, item, label)
elif original_path.parent.name == "plays": elif original_path.parent.name == "plays":
summary = _merge_play_journal(original_path, conflict_path) 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: else:
summary = _keep_newer(original_path, conflict_path) summary = _keep_newer(original_path, conflict_path)
except Exception as e: # never let a bad file abort startup/live reload 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) 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. # What library_metadata.json actually holds, in words a person recognizes.
_METADATA_LABELS = { _METADATA_LABELS = {
"library_settings": "column layout", "library_settings": "column layout",
+159
View File
@@ -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: ``<data_dir>/history/<machine id>/<YYYY-MM>.jsonl``. The no-conflict
rule is the journals' — each machine (and each Rabbit, as
``andtunes-<install id>``) 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]
+35
View File
@@ -259,6 +259,7 @@ def build(out: Path, seed: int = 47):
shutil.rmtree(data_dir) shutil.rmtree(data_dir)
json_storage.save_library(library, data_dir) json_storage.save_library(library, data_dir)
_write_plays(data_dir, ids) _write_plays(data_dir, ids)
_write_history(data_dir, library)
_write_prefs(data_dir, library) _write_prefs(data_dir, library)
shutil.rmtree(covers, ignore_errors=True) 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") 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): def _write_prefs(data_dir: Path, library: Library):
(data_dir / "preferences.json").write_text(json.dumps({ (data_dir / "preferences.json").write_text(json.dumps({
"ui_scale": "medium", "ui_scale": "medium",
+380
View File
@@ -0,0 +1,380 @@
"""Round 74: a listen history for the yearly Wrapped.
The play journals keep totals; a Wrapped needs one timestamped line per
listen. Pins: what Player counts as heard (seeks and pauses don't count), how
a listen ends, the 5 s noise floor, the per-machine month files, the Rabbit's
history coming home, the merge, and what friends receive and how often.
"""
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from lintunes import player as player_module
from lintunes.andtunes import plays as plays_mod
from lintunes.andtunes.manifest import build_manifest
from lintunes.cassette import wrapped
from lintunes.models import Library, Track
from lintunes.player import PlaybackSink, Player
from lintunes.storage import conflict_resolver, listen_history
from lintunes.storage.listen_history import ListenHistory, entry_for
class _Sink(PlaybackSink):
def __init__(self, parent=None):
super().__init__(parent)
self._playing = False
self._position = 0
def load(self, track, autoplay, start_ms):
self._playing = autoplay
self._position = start_ms
def play(self):
self._playing = True
def pause(self):
self._playing = False
def stop(self):
self._playing = False
def seek(self, position_ms):
self._position = position_ms
def position_ms(self):
return self._position
def duration_ms(self):
return 0
def is_playing(self):
return self._playing
def set_volume(self, level):
pass
def can_play(self, track):
return True
def unplayable_reason(self, track):
return ""
def shutdown(self):
pass
def tick(self, *positions):
for p in positions:
self._position = p
self.position_changed.emit(p)
def _player(qapp, tmp_path, count=2):
library = Library()
for tid in range(1, count + 1):
path = tmp_path / f"{tid}.mp3"
path.write_bytes(b"x")
library.tracks[tid] = Track(track_id=tid, name=f"Song {tid}",
artist="Artist", album="Album",
genre="Jazz", year=1961,
total_time=200_000, location=str(path))
manager = MagicMock(library=library)
with patch.multiple(player_module, QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock()):
player = Player(manager)
sink = _Sink()
player.set_sink(sink)
ended = []
player.listen_ended.connect(lambda *args: ended.append(args))
return player, sink, ended, manager
def _steps(start, stop, step=500):
return range(start, stop + 1, step)
class TestPlayerAccounting:
def test_skip_reports_time_heard(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(*_steps(500, 10_000))
player.next()
(key, track, started, heard, how), = ended
assert (key, track.track_id, heard, how) == (1, 1, 10_000, "skip")
assert started.tzinfo is not None # local time, with its offset
def test_a_seek_is_not_listening(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(500, 1000, 120_000, 120_500, 3000, 3500) # forward, then back
player.next()
assert ended[0][3] == 2000
def test_a_natural_finish(self, qapp, tmp_path):
player, sink, ended, manager = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(*_steps(500, 6000))
sink.ended.emit()
assert [(e[1].track_id, e[4]) for e in ended] == [(1, "finish")]
manager.record_play.assert_called_once_with(1)
def test_stop_and_quit(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
sink.tick(*_steps(500, 6000))
player.stop()
player.play_queue([2], 0)
sink.tick(*_steps(500, 6000))
player.shutdown()
assert [e[4] for e in ended] == ["stop", "quit"]
def test_nothing_heard_reports_nothing(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path)
player.play_queue([1, 2], 0)
player.next()
assert ended == []
def test_replaying_the_last_track_opens_a_new_listen(self, qapp, tmp_path):
player, sink, ended, _ = _player(qapp, tmp_path, count=1)
player.play_queue([1], 0)
sink.tick(*_steps(500, 6000))
sink.ended.emit() # queue ran out; track stays current
sink._position = 0
player.toggle_play()
sink.tick(*_steps(500, 7000))
player.stop()
assert [(e[3], e[4]) for e in ended] == [(6000, "finish"), (7000, "stop")]
def _track(**fields):
base = dict(track_id=7, name="Alhambra", artist="Ahmad Jamal",
album_artist="Ahmad Jamal", album="Alhambra", genre="Jazz",
year=1961, total_time=300_000)
base.update(fields)
return Track(**base)
WHEN = datetime(2026, 10, 2, 21, 14, 3, tzinfo=timezone(timedelta(hours=-7)))
class TestEntry:
def test_shape(self):
entry = entry_for(_track(), started=WHEN, heard_ms=200_000,
how="finish", src="desktop", ctx="playlist:ab12",
shuffle=True, track_id=7)
assert entry == {"t": "2026-10-02T21:14:03-07:00", "ms": 200_000,
"len": 300_000, "end": "finish", "src": "desktop",
"id": 7, "ctx": "playlist:ab12", "shuf": True,
"n": "Alhambra", "a": "Ahmad Jamal",
"aa": "Ahmad Jamal", "al": "Alhambra", "g": "Jazz",
"y": 1961}
def test_under_five_seconds_is_noise(self):
assert entry_for(_track(), started=WHEN, heard_ms=4999, how="skip",
src="desktop") is None
def test_friend_track_has_no_id(self):
entry = entry_for(_track(), started=WHEN, heard_ms=9000, how="skip",
src="desktop", track_id="cassette:tok:7")
assert "id" not in entry
class TestHistoryFiles:
def _entry(self, when, name="x"):
return entry_for(_track(name=name), started=when, heard_ms=60_000,
how="finish", src="desktop", track_id=7)
def test_month_files_per_machine(self, tmp_path):
mine, theirs = ListenHistory("aaa"), ListenHistory("bbb")
mine.add(self._entry(WHEN))
mine.add(self._entry(WHEN + timedelta(days=40), "november"))
theirs.add(self._entry(WHEN - timedelta(hours=1), "earlier"))
mine.flush(tmp_path)
theirs.flush(tmp_path)
names = sorted(str(p.relative_to(tmp_path))
for p in (tmp_path / "history").rglob("*.jsonl"))
assert names == ["history/aaa/2026-10.jsonl", "history/aaa/2026-11.jsonl",
"history/bbb/2026-10.jsonl"]
assert [e["n"] for e in listen_history.read_year(tmp_path, 2026)] == \
["earlier", "x", "november"]
assert not mine.dirty
def test_flush_appends(self, tmp_path):
history = ListenHistory("aaa")
for name in ("one", "two"):
history.add(self._entry(WHEN, name))
history.flush(tmp_path)
assert [e["n"] for e in listen_history.read_year(tmp_path, 2026)] == \
["one", "two"]
def test_torn_line_is_skipped(self, tmp_path):
history = ListenHistory("aaa")
history.add(self._entry(WHEN))
(path,) = history.flush(tmp_path)
with open(path, "a") as f:
f.write('{"t": "2026-10-0')
assert len(listen_history.read_year(tmp_path, 2026)) == 1
def test_other_years_are_left_out(self, tmp_path):
history = ListenHistory("aaa")
history.add(self._entry(WHEN.replace(year=2025)))
history.add(self._entry(WHEN))
history.flush(tmp_path)
assert len(listen_history.read_year(tmp_path, 2026)) == 1
def test_trim_for_sharing(self):
entry = self._entry(WHEN)
entry.update(ctx="playlist:x", shuf=True)
(trimmed,) = listen_history.trim_for_sharing([entry])
assert not {"id", "ctx", "src", "shuf"} & set(trimmed)
assert trimmed["a"] == "Ahmad Jamal" and trimmed["ms"] == 60_000
def test_conflict_merge_is_a_union(self, tmp_path):
folder = tmp_path / "history" / "andtunes-cafe"
folder.mkdir(parents=True)
a = json.dumps(self._entry(WHEN, "a"))
b = json.dumps(self._entry(WHEN + timedelta(minutes=5), "b"))
c = json.dumps(self._entry(WHEN + timedelta(minutes=9), "c"))
original = folder / "2026-10.jsonl"
original.write_text(f"{a}\n{b}\n")
conflict = folder / "2026-10.sync-conflict-20261002-211500-ABCDEFG.jsonl"
conflict.write_text(f"{a}\n{c}\n")
conflict_resolver.resolve_conflicts(tmp_path)
assert not conflict.exists()
assert [e["n"] for e in listen_history.read_lines(original)] == \
["a", "b", "c"]
class TestManagerAndGate:
def test_manager_writes_with_the_next_save(self, qapp, tmp_path):
from lintunes.library_manager import LibraryManager
manager = LibraryManager(Library(), tmp_path)
manager.record_listen(entry_for(_track(), started=WHEN, heard_ms=9000,
how="skip", src="desktop", track_id=7))
manager.record_listen(None)
manager.flush()
assert len(listen_history.read_year(tmp_path, 2026)) == 1
@pytest.mark.parametrize("on", [False, True])
def test_the_preference_gates_it(self, qapp, tmp_path, on):
from lintunes.gui.main_window import MainWindow
from lintunes.library_manager import LibraryManager
from lintunes.preferences import Preferences
manager = LibraryManager(Library(), tmp_path / "data")
prefs = Preferences(tmp_path / "data")
prefs.update_wrapped(track_history=on)
window = MainWindow(manager, prefs)
try:
window._listen_context = "followed:cassette:tok:pid"
window._on_listen_ended("cassette:tok:7", _track(), WHEN, 9000,
"skip")
pending = manager.listen_history.pending
if not on:
assert pending == []
else:
(entry,) = pending
assert entry["ctx"] == "friend:tok" and "id" not in entry
assert entry["src"] == "desktop"
finally:
window.close()
class TestRabbit:
def _line(self, name):
return json.dumps({"t": "2026-10-02T09:00:00-07:00", "ms": 9000,
"n": name, "src": "r1"}) + "\n"
def test_history_comes_home_when_longer(self, tmp_path):
root, data = tmp_path / "andTunes", tmp_path / "data"
month = root / "history" / "andtunes-cafe" / "2026-10.jsonl"
month.parent.mkdir(parents=True)
month.write_text(self._line("one"))
assert plays_mod.bring_back_history(root, data) == 1
dest = data / "history" / "andtunes-cafe" / "2026-10.jsonl"
assert dest.read_text() == self._line("one")
assert plays_mod.bring_back_history(root, data) == 0 # same length
month.write_text(self._line("one") + self._line("two") + '{"t": "20')
assert plays_mod.bring_back_history(root, data) == 1
assert dest.read_text() == self._line("one") + self._line("two")
def test_no_history_folder(self, tmp_path):
assert plays_mod.bring_back_history(tmp_path, tmp_path / "data") == 0
def test_manifest_flag(self):
assert "history" not in build_manifest([], [], generator="t")
assert build_manifest([], [], generator="t", history=True)["history"] is True
def test_sync_never_prunes_the_history(self):
from lintunes.andtunes.layout import PRESERVED_DIRS
assert "history" in PRESERVED_DIRS
def _utc(*args):
return datetime(*args, tzinfo=timezone.utc)
class TestSharingCadence:
def test_monthly_off_season_daily_in_season(self):
oct2 = _utc(2026, 10, 2)
assert wrapped.is_due("", oct2)
assert not wrapped.is_due("2026-09-20T00:00:00+00:00", oct2)
assert wrapped.is_due("2026-09-01T00:00:00+00:00", oct2)
nov = _utc(2026, 11, 20, 12)
assert not wrapped.is_due("2026-11-20T00:00:00+00:00", nov)
assert wrapped.is_due("2026-11-19T11:00:00+00:00", nov)
def test_january_finishes_last_year(self):
assert wrapped.years_to_write(_utc(2027, 1, 3)) == [2026, 2027]
assert wrapped.years_to_write(_utc(2026, 10, 3)) == [2026]
def _service(self, tmp_path, tokens=("t1", "t2")):
from lintunes.cassette.service import CassetteService
from lintunes.cassette.state import Friend
service = CassetteService(root=tmp_path / "cassette",
config_loader=lambda: {})
for token in tokens:
service._state.friends[token] = Friend(token=token, device_id="D",
name=token)
return service
def _data(self, tmp_path):
history = ListenHistory("aaa")
history.add(entry_for(_track(), started=WHEN, heard_ms=60_000,
how="finish", src="desktop", ctx="playlist:x",
track_id=7))
history.flush(tmp_path / "data")
return tmp_path / "data"
def test_publish_then_quiet_then_off(self, qapp, tmp_path):
from lintunes.cassette.state import out_dir
service = self._service(tmp_path)
data = self._data(tmp_path)
now = _utc(2026, 10, 2, 12)
assert service.publish_wrapped(data, True, now) == 2
out = out_dir(service.root, "t1") / "wrapped-2026.json"
doc = json.loads(out.read_text())
assert doc["year"] == 2026 and len(doc["plays"]) == 1
assert "ctx" not in doc["plays"][0] and "id" not in doc["plays"][0]
mtime = out.stat().st_mtime_ns
# Not due again for a month, and even when due, unchanged plays
# aren't rewritten.
assert service.publish_wrapped(data, True, now + timedelta(days=3)) == 0
assert service.publish_wrapped(data, True, now + timedelta(days=31)) == 0
assert out.stat().st_mtime_ns == mtime
# Switching it off takes it back.
assert service.publish_wrapped(data, False, now) == 2
assert not out.exists()
assert service.state().wrapped_published_at == ""
def test_new_friend_gets_theirs_without_waiting(self, qapp, tmp_path):
from lintunes.cassette.state import Friend, out_dir
service = self._service(tmp_path, tokens=("t1",))
data = self._data(tmp_path)
now = _utc(2026, 10, 2, 12)
service.publish_wrapped(data, True, now)
service._state.friends["t2"] = Friend(token="t2", device_id="E", name="t2")
assert service.publish_wrapped(data, True, now + timedelta(hours=1)) == 1
assert (out_dir(service.root, "t2") / "wrapped-2026.json").exists()