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
@@ -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
View File
@@ -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