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:
@@ -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 `<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
|
||||
`Library`, is the single funnel for all mutations, and persists them
|
||||
**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, Album> 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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.34.0"
|
||||
__version__ = "0.35.0"
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
{"version_name": "0.2.3", "version_code": 5}
|
||||
{"version_name": "0.3.0", "version_code": 6}
|
||||
|
||||
@@ -14,10 +14,11 @@ reduced to m3u files pointing into it.
|
||||
Playlists/<Name>.m3u
|
||||
Buttons/*.png the user's own menu artwork
|
||||
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
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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-<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 ----
|
||||
|
||||
def save_requests(self, token: str, cassetted, followed):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:<pid>". 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:<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):
|
||||
current = self.player.current_track
|
||||
if current is not None and current.track_id == track_id:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user