v0.20.0: the Rabbit's plays come home
andTunes phase 3. The R1 is now one more machine in the Round 38 play-count model, and nothing lands on it that Android can't play. Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes' rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly the per-machine-totals journal shape, written beside the old one and renamed over it. Each sync first folds it into <data_dir>/plays/ with a new play_journal.merge_totals, the per-track max: the file now has two homes and both desktops may bring it back, so the max converges and an older copy can never pull a count down. Nothing is written when nothing moved, and PlayJournal.load needed no change. Format gate: the planner reuses export/web_support.conversion_for outright, since Android's MediaPlayer decodes the browser's set. FairPlay is refused and reported; ALAC, AIFF and oddities land as FLAC, converted once into a per-track cache and size-diffed after that. With no ffmpeg, the export's "sync without them?" question. A file ffmpeg can't read costs that song, not the sync. "Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's helpers stay because export and andTunes import them. The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as FLAC and played, the FairPlay track was refused, four plays came back on the next sync and each track's effective count rose by exactly one, and a third sync copied nothing. Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which anything touching the mount (find_device, the GUI tests) hangs in an uninterruptible wait. CLAUDE.md now has the recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
This commit is contained in:
+3
-2
@@ -19,8 +19,9 @@ instead of indexing a filesystem. That is the entire architectural bet.
|
||||
desktop half shipped in LinTunes v0.17.0 (round 47); the app itself landed in
|
||||
round 50 (LinTunes v0.19.0), built in plain Java with no Gradle and no
|
||||
download — see *Toolchain*. Cold start to the menu measures 313–338 ms on the
|
||||
R1 (target < 400). Play counts coming back and the format gate are next
|
||||
(`TASKS.md`, phase 3).
|
||||
R1 (target < 400). Round 51 (andTunes 0.2.0, LinTunes v0.20.0) finished
|
||||
phase 3: play counts come back to LinTunes on every sync, and songs Android
|
||||
can't play are converted to FLAC or, for FairPlay, left out and reported.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+17
-10
@@ -6,7 +6,9 @@ A music player for the Rabbit R1 that never scans anything: LinTunes writes
|
||||
|
||||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
|
||||
**Status: 0.1.0 plays music (2026-09-11, LinTunes round 50).** Built with
|
||||
**Status: phases 1–3 done (2026-09-11, LinTunes rounds 50–51, andTunes
|
||||
0.2.0).** What's left is the *Parked* list, pulled in as trav finds he needs
|
||||
it. Built with
|
||||
`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see
|
||||
`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round
|
||||
each lands in is decided when it starts.
|
||||
@@ -81,15 +83,20 @@ each lands in is decided when it starts.
|
||||
Reinstall from the version on the device; with no adb, copies the APK
|
||||
to `Download/` over MTP for a manual tap. APK committed at
|
||||
`lintunes/android/andTunes.apk` (+ `andTunes.json`, its version).
|
||||
- [ ] Format gate shaped like `export/web_support.py`: deny-by-default on
|
||||
suffix, protected AAC refused, anything else unplayable converted to
|
||||
**FLAC** (never a lossy re-encode).
|
||||
- [ ] Play counts back: the app writes `plays/andtunes-<installid>.json` in
|
||||
the per-machine-totals shape of `storage/play_journal.py`; sync copies
|
||||
it back and folds it in. The R1 becomes one more machine in the Round 38
|
||||
model — no new merge rules.
|
||||
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` now that andTunes covers it.
|
||||
(trav's R1 has no old `Music/<Playlist>/` folders, so no cleanup.)
|
||||
- [x] Format gate (LinTunes 0.20.0): sync reuses
|
||||
`export/web_support.conversion_for` outright, because Android's
|
||||
MediaPlayer decodes the same set a browser does. Protected AAC is
|
||||
refused and reported; ALAC, AIFF and anything else unplayable becomes
|
||||
**FLAC** (never a lossy re-encode). Conversions are cached per track, so
|
||||
they're converted once and size-diffed after that.
|
||||
- [x] Play counts back (andTunes 0.2.0): a natural finish counts a play, as
|
||||
it does in LinTunes. The app keeps `plays/andtunes-<install id>.json` in
|
||||
the per-machine-totals shape of `storage/play_journal.py`. Sync folds it
|
||||
into `<data_dir>/plays/` with the per-track max *before* copying
|
||||
anything, so the R1 is one more machine in the Round 38 model and there
|
||||
are no new merge rules.
|
||||
- [x] Retired `Sync Playlist to Rabbit (Auxio)` from the menu. (trav's R1
|
||||
has no old `Music/<Playlist>/` folders, so no cleanup was needed.)
|
||||
|
||||
## Parked
|
||||
|
||||
|
||||
@@ -388,7 +388,11 @@ public class PlaybackService extends Service implements
|
||||
}
|
||||
|
||||
@Override public void onCompletion(MediaPlayer player) {
|
||||
if (prepared) advance(true);
|
||||
if (!prepared) return;
|
||||
// A natural finish is a play — LinTunes' rule, so the counts agree.
|
||||
Library.Track done = current();
|
||||
if (done != null) Plays.record(this, done.id);
|
||||
advance(true);
|
||||
}
|
||||
|
||||
@Override public boolean onError(MediaPlayer player, int what, int extra) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package me.teafry.andtunes;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.JsonReader;
|
||||
import android.util.JsonToken;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* This Rabbit's play journal, which LinTunes brings back on the next sync.
|
||||
*
|
||||
* <p>{@code Music/andTunes/plays/andtunes-<install id>.json}, in exactly the
|
||||
* shape LinTunes' own per-machine journals use —
|
||||
* {@code {"<track id>": {"plays": n, "last_played": "<UTC ISO>"}}} — so the R1
|
||||
* is simply one more machine and no new merge rule exists anywhere. Running
|
||||
* totals, not a log: LinTunes takes the higher total per track, so a copy
|
||||
* that's behind can never pull a count down.
|
||||
*
|
||||
* <p>A play counts on a <b>natural finish</b>, the same rule LinTunes' player
|
||||
* uses. Skips aren't recorded (LinTunes doesn't record them either), but any
|
||||
* already in the file are carried through untouched.
|
||||
*/
|
||||
final class Plays {
|
||||
private static final String TAG = "andTunes";
|
||||
private static final ExecutorService IO = Executors.newSingleThreadExecutor();
|
||||
private static TreeMap<Long, Map<String, Object>> sEntries; // IO thread only
|
||||
private static File sFile;
|
||||
|
||||
private Plays() {}
|
||||
|
||||
static void record(Context c, long trackId) {
|
||||
final File file = file(c);
|
||||
final String now = now();
|
||||
IO.execute(() -> {
|
||||
try {
|
||||
if (sEntries == null || !file.equals(sFile)) {
|
||||
sFile = file;
|
||||
sEntries = load(file);
|
||||
}
|
||||
Map<String, Object> e = sEntries.get(trackId);
|
||||
if (e == null) {
|
||||
e = new TreeMap<>();
|
||||
sEntries.put(trackId, e);
|
||||
}
|
||||
Object n = e.get("plays");
|
||||
e.put("plays", (n instanceof Long ? (Long) n : 0L) + 1);
|
||||
e.put("last_played", now);
|
||||
save(file, sEntries);
|
||||
} catch (IOException | RuntimeException ex) {
|
||||
Log.w(TAG, "couldn't record a play", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** One stable id per install, so this device's file is only ever ours. */
|
||||
private static File file(Context c) {
|
||||
SharedPreferences p = c.getSharedPreferences("andtunes", Context.MODE_PRIVATE);
|
||||
String id = p.getString("install_id", null);
|
||||
if (id == null) {
|
||||
byte[] b = new byte[4];
|
||||
new SecureRandom().nextBytes(b);
|
||||
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");
|
||||
}
|
||||
|
||||
/** UTC, the same ISO form LinTunes stamps plays with (so they compare). */
|
||||
private static String now() {
|
||||
SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT);
|
||||
f.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
return f.format(new Date());
|
||||
}
|
||||
|
||||
private static TreeMap<Long, Map<String, Object>> load(File file) throws IOException {
|
||||
TreeMap<Long, Map<String, Object>> out = new TreeMap<>();
|
||||
if (!file.isFile()) return out;
|
||||
try (JsonReader r = new JsonReader(new InputStreamReader(
|
||||
new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
long id;
|
||||
try { id = Long.parseLong(key); } catch (NumberFormatException bad) {
|
||||
r.skipValue();
|
||||
continue;
|
||||
}
|
||||
TreeMap<String, Object> e = new TreeMap<>();
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String field = r.nextName();
|
||||
JsonToken t = r.peek();
|
||||
if (t == JsonToken.NUMBER) e.put(field, r.nextLong());
|
||||
else if (t == JsonToken.STRING) e.put(field, r.nextString());
|
||||
else r.skipValue();
|
||||
}
|
||||
r.endObject();
|
||||
out.put(id, e);
|
||||
}
|
||||
r.endObject();
|
||||
} catch (IllegalStateException | NumberFormatException corrupt) {
|
||||
// Better to start counting again than to stop counting: LinTunes
|
||||
// keeps the higher total per track, so nothing already brought
|
||||
// back is lost.
|
||||
Log.w(TAG, "unreadable play journal, starting fresh", corrupt);
|
||||
return new TreeMap<>();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Write beside, then rename over — a sync never reads half a file. */
|
||||
private static void save(File file, TreeMap<Long, Map<String, Object>> entries)
|
||||
throws IOException {
|
||||
File dir = file.getParentFile();
|
||||
if (dir != null && !dir.isDirectory() && !dir.mkdirs()) {
|
||||
throw new IOException("can't create " + dir);
|
||||
}
|
||||
File tmp = new File(dir, file.getName() + ".tmp");
|
||||
try (Writer w = new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8)) {
|
||||
w.write('{');
|
||||
boolean first = true;
|
||||
for (Map.Entry<Long, Map<String, Object>> row : entries.entrySet()) {
|
||||
if (!first) w.write(',');
|
||||
first = false;
|
||||
w.write('"' + row.getKey().toString() + "\":{");
|
||||
boolean firstField = true;
|
||||
for (Map.Entry<String, Object> f : row.getValue().entrySet()) {
|
||||
if (!firstField) w.write(',');
|
||||
firstField = false;
|
||||
w.write('"' + f.getKey() + "\":");
|
||||
Object v = f.getValue();
|
||||
w.write(v instanceof Long ? v.toString() : '"' + v.toString() + '"');
|
||||
}
|
||||
w.write('}');
|
||||
}
|
||||
w.write("}\n");
|
||||
}
|
||||
if (!tmp.renameTo(file)) {
|
||||
throw new IOException("can't replace " + file);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -26,8 +26,8 @@ import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
VERSION_NAME = "0.1.0"
|
||||
VERSION_CODE = 1
|
||||
VERSION_NAME = "0.2.0"
|
||||
VERSION_CODE = 2
|
||||
|
||||
PACKAGE = "me.teafry.andtunes"
|
||||
MIN_SDK = 26
|
||||
|
||||
Reference in New Issue
Block a user