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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user