From 81b7f6767cc98ea1e38c4d084590043691dd75e8 Mon Sep 17 00:00:00 2001 From: trav Date: Fri, 11 Sep 2026 15:20:44 -0500 Subject: [PATCH] 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-.json in exactly the per-machine-totals journal shape, written beside the old one and renamed over it. Each sync first folds it into /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 Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv --- CLAUDE.md | 28 +- andtunes/README.md | 5 +- andtunes/TASKS.md | 27 +- .../me/teafry/andtunes/PlaybackService.java | 6 +- .../main/java/me/teafry/andtunes/Plays.java | 160 ++++++++++ andtunes/build.py | 4 +- lintunes/__init__.py | 2 +- lintunes/android/andTunes.apk | Bin 62871 -> 62871 bytes lintunes/android/andTunes.json | 2 +- lintunes/andtunes/plays.py | 73 +++++ lintunes/andtunes/sync.py | 148 ++++++++- lintunes/gui/main_window.py | 119 +++----- lintunes/storage/play_journal.py | 31 ++ scripts/make_dev_library.py | 48 ++- tasks-done.md | 49 +++ tests/test_round37.py | 4 +- tests/test_round51.py | 285 ++++++++++++++++++ 17 files changed, 864 insertions(+), 127 deletions(-) create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/Plays.java create mode 100644 lintunes/andtunes/plays.py create mode 100644 tests/test_round51.py diff --git a/CLAUDE.md b/CLAUDE.md index d0eb360..78da055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,11 +289,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal copystat and never trust mtimes (diff by name+size). Sync owns exactly `Music//` on the device (creates/overwrites/deletes there, plus an Auxio-importable `.m3u`); it never deletes outside that folder and - only ever *reads* local library files. Since Round 47 this is the *older* - of two syncs — kept on the menu as "Sync Playlist to Rabbit (Auxio)" until - the andTunes app can play a song — and its `sanitize_name` / `track_display` - / `track_filename` / `build_m3u` / `CHUNK` are imported by both - `export/exporter.py` and `andtunes/`, so their signatures are load-bearing. + only ever *reads* local library files. Its menu item ("Sync Playlist to + Rabbit (Auxio)") was **retired in Round 51**, once andTunes could play what + it syncs; the module stays because `find_device` and its `sanitize_name` / + `track_display` / `track_filename` / `build_m3u` / `CHUNK` are imported by + both `export/exporter.py` and `andtunes/`, so their signatures are + load-bearing. - **`lintunes/andtunes/`** — the desktop half of andTunes, a music player for the Rabbit R1 (Round 47; the app itself lives in `andtunes/` at the repo @@ -343,6 +344,23 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal where the file is missing** — those names are a contract with the app's `Ui.button()`, which prefers the device copy. `proguard.pro` keeps no debug attributes because R8 8.2 NPEs on javac 21's. + Round 51 closed the loop. The planner runs every track through + `export/web_support.conversion_for` (Android decodes the browser set), so + FairPlay is refused and ALAC/AIFF land as `.flac` — converted once into + `$XDG_CACHE_HOME/lintunes/andtunes-flac/.flac` and size-diffed + from there on, so a converted song isn't re-copied every sync. And the app + keeps `plays/andtunes-.json` in the play-journal shape, counted + on a natural finish like `Player`; `plays.bring_back` folds it into + `/plays/` with `play_journal.merge_totals` (per-track max — that + file has two homes and both desktops may bring it back) **before** anything + is copied, writing nothing when nothing moved. `PlayJournal.load` then + treats the R1 as one more machine. + **A wedged gvfs-MTP mount blocks in uninterruptible FUSE waits**: `timeout` + can't kill a process stuck on it, and `find_device()` — called on the GUI + thread every time the Connections menu opens, and by the GUI tests — + freezes with it. USB re-enumeration (lock/unlock with `mtp,adb`, an + `adb install`) is what wedged it in Round 51. Recovery: `kill` the + `gvfsd-mtp` process, then `gio mount mtp:///`. - **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's right-click menu). A sibling of `device_sync`, reusing its filename helpers diff --git a/andtunes/README.md b/andtunes/README.md index 3ac3907..5885197 100644 --- a/andtunes/README.md +++ b/andtunes/README.md @@ -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. --- diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md index a5917b8..52a8edc 100644 --- a/andtunes/TASKS.md +++ b/andtunes/TASKS.md @@ -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-.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//` 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-.json` in + the per-machine-totals shape of `storage/play_journal.py`. Sync folds it + into `/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//` folders, so no cleanup was needed.) ## Parked diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java index bcc8317..210ceb9 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java @@ -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) { diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java b/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java new file mode 100644 index 0000000..6bd3be4 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Plays.java @@ -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. + * + *

{@code Music/andTunes/plays/andtunes-.json}, in exactly the + * shape LinTunes' own per-machine journals use — + * {@code {"": {"plays": n, "last_played": ""}}} — 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. + * + *

A play counts on a natural finish, 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> 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 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> load(File file) throws IOException { + TreeMap> 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 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> 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> row : entries.entrySet()) { + if (!first) w.write(','); + first = false; + w.write('"' + row.getKey().toString() + "\":{"); + boolean firstField = true; + for (Map.Entry 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); + } + } +} diff --git a/andtunes/build.py b/andtunes/build.py index dc04c40..5c37394 100644 --- a/andtunes/build.py +++ b/andtunes/build.py @@ -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 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 646d6a0..1693ef7 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.19.0" +__version__ = "0.20.0" diff --git a/lintunes/android/andTunes.apk b/lintunes/android/andTunes.apk index 82b79549000cf3dd925c9889c0f5c5e5d8c073a2..5293081e98b88e39bea3cfbb00cbf24a3cda58bb 100644 GIT binary patch delta 30870 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 delta 28724 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

W>;yXS zNam%hCuEuzr3H=|<&#)>PFQ!qQ3(AtO-DE~x3K&Z8+F}WCVZ21mZlzjq`S(*3$(zW zPYpucd_R;_JP*po3ZUc)YhCa!{n$`T=GoXiSAlI$!q!L0K8HLB_3c4v z{ViDFiyf~CwU5)QbDB?#&%qZ+(U_OM6-d`C*$7ZE%MN%Cq8}Zvuc>62BU3wl zNEJnOWRM+HPE~o(km;o>E;gx+|3Ea-B_p_1e@K;=jTt!2U=KYQt?^Q&0miWZ9^x(O z>@LK*hF8aQ5LRO-jlvCQZCj(NWkgdyE1X$haj+F@^1(2X*XTUoGg`eC`A?qc)1*ET zZHRD^GRWNbw~9pJlNX75`4~P*CAk)i%W@wn2&sq^O*O?s*eW#JN&bniJ;h8b?^P_I zq&^b6-_4hj+83L}m9K2m1VEDB>I;p?sBDvzrW@gc&XxPt%0&p<{@1n7LDX^UbT z8)D>X5-R;+II1L=pp-DNdy!{0Te5=+lt!{F=)~j}|3XIrFg$8vosu8%=!17p5*GD8 zEG^a1n5&eJ3}A`NDCkIT5r_O%Hrw%A+dm(12eiBslANeWkC5{Z0ei43wqER#=0wHo zH#b}@H{!7$%u%yl*eOaM-lHk1mkT@?Y}ln)PAQ;r>f~}=E0&4VQZ>%G=!(r4jQKka zUmLaiRLLOL7()mL=?>+e3Jb>HllZ{7XJacH2v8JyOv8TQi#FAaz%fNEBFG zT6VNq2CDKYDfZc8p=n~nMV}K7>cZsQsi?G{8_f_qLjyN8?7LATynt^)()$r@STEg3 z!iP-$5HR6*Hofz=J^jXL0fYWW%pD@_0}zWh;h}_T3Lee7&W+oRT6eF z-sP@IpX*v|tIBgwRHVgHXYe0qszx@FaC zrZ-v7Rf@l{W~PXB^3Ts)CR~5ekL`3%R)equAVmD^Rh zopEm}U`s@8O_r?iV&f(2Wh3L86}j+)f|j0nY^k2`e#>GnJFecS9TIV$Sl2d4Ldyt& zS~6L|bfU1=wx1!^v#kHz4VvQ7II5GSG7b-xnsxPFMI$Pb1)&u4W1##`!9I!v=pen) zbXxKK`pxREXgeUwg}OzE5EBm+JsNe%%~*o?2PW1NU zTx~Y{;zd2^2Lxrz97$O(ezqH^3Fq4q{Npi7~w24|%mdgM-gVWKqTu2dZZ67Fiqa$r*({8l#6sPdYv@VpATt(lEm+C>5Y zFxP%d8nLRpsyAmvvjgWAX(ZG6=4eY}zJ)`mciAzvmuGAF5ZApkNv*S_;Qi!EWh^M| z5&xBzcaB(vmodc(gte_|GtWBF+ElUHnWRGJpa!N0x!pUCFi~FlQO%&~Jto!%_OfI;+-YW;N-HAcdO%mj2`QozDD6wc74SNax8lBaxRTjxH#}eY_^d zE$rdtn0E}qu-Ll&F$ErwSf|TBi6)bIE_GuCLmVAC{oSX(-tix$)RCm7;IxTDJ?e@e z^+ovwjW~vr1o9$NhAr&z7U<5Bi$`@8`9{}S_XbZ&Rg@doC6$*X&`S^&DA5s6WY3u_ z!=#2>(p0A|W#2(YM|7gJ$g0+oAE-8L5DtcS%ls%0aif2#%Ve&6=Ze!xhAKinEdQ`#Q}B$IHso93Pnuan}3RN3~AvW?R*b zHq|S({gI_R<6{y++4`(pFE?<@oEc~A0Y{X8l;w9_K%7J7vH2UwtIoB9m5rMpK|mb#WrP`CaP5+hdPY3%QxCIr#_4 zUqPwTYJq*LiyzmzP5c9$&+cAJtiQeYElp?Arh4ezq$l2>?xqpf=Do$nf^OZl{f&0~~27i6t{KhHh#d|$H8IpV9h;B^6*cI7@2b*3kf8^16M3eJ2^BhwSDy;^G9Pec3@=Q2QmL4Q>pA!7AceojSBM!>toIfI;bGs2$#pa29!avI^^8vzhs!aOABo{u;&>4kfaS~p5 z2w@KhX{`iS(xX|q`3v@VoOsLj=3bCw1iWzg!8lOP%fCIIP=N5Pz7aO^nJG;jK|5ui zY%Q=J<3%7H@MS$VT#bE+nU$$Vmbp?+`(VePd;FdHL7-R+h$ozEZ5Y3Buoc{S1!Pa$ zZBPezp#yv`?%B~fDj?WW_q%PBo6LDP;ibO2PW0lfdc_7*0)1Iigw7APFq&`U0}e-F z`f8A76UlP5eA{wVzx;N|^6fr71ld=0DI~+`&5!^P8G7gbK7C_IK)7(jOt4k8htBDZoD3Y+`ZNPRfs5v%N z0O+-dpDj{a5-h0Tlq5VpY|3!bXFfg0QI$%+6fvEc-+2uKG{3}{cUW7R;r?SY4RW#e~hQ2ZP zh)06X$HTbSfHw&y)XjuO3aow0m(9E)Db4S+P175^Y%KP5EwObm8!nNI_|zJD_YG&6 zS|dq+w2sA;tcL8gbvxhv4pg?I@Lf%v7jU(7hWVJh3xh8Ab%&tWPeyA;tlJU=fC2O5 zF~??A_=S150~Pp9K*N8E2 z>E@~m`WP9R!^uLW=5@^VS)n1;p+z;h*N+M|`jiNC<7eng+I5!3b8~gAGg_0&oo4zC zb(x#lTIG{CR`3Yr-uR@M4CqS>AkTAqc-8_iaCW&!etu3cv^~H9nf7WmB&!Ed8*qF* z3(-I0XtheKGYLr@R@FgG&puO`_-Rl3Bc&sEyok%GrJnU79e0g99R0B5s4bt!W(skF zga00v^&&W)N6wMp5P#~(#vNz*%N@eqy=;WS13hL?1DGD&sx2zg4`BixfbcpOPIsc4 zrFlb5V!JZ9av>-vH-0K27xq;B8}f>NK(cU8fDmjOo^O0}di@wl;IZKrJs1yIEwPfC z0VR=7$n0>8&^Z!jJrA+R(TsNVPcpWY0FBWR^-*G{LVR(nr_IDYw{i+b3=te5PnuX4 zD&f!=mBPDqdz59WhNxmOKt8%Jd*0_kcfq!56T*hn{u{QR28=72hIsB)2u~k;auv-PqM7EZX(1Pr(l#c zgc?aEia7%rGhb7pWXY&V^Ev|)v7X^VVt=O@8IRv!eKJt1Ofms+KpI3qU0N6<*;pZS zRxwGMBmxV{go%pJ*T``&owS`QOt&VsVQ+v*HTEH#zP!&~8QyC(;UQjkyF|j9diGCO zT=<0s2~IF_2jL0R-KC6D^`S7&oDA&Pp{AyDKT|4Cf9 zlJcd7*m1a}x(%aMAgMR0&t4Rsh1MUlohW!Gz}dcsbhIa~dr!?TO~`$P!*O>PUa}E2|+7nzWA_Jiqp?UyT`f zEg9GjmdG-P?aC~ClhrZSs^RgP5JsrI{k&rb5p635E>Geb5C&d)C~~X{R#r?LZPfi9 zuL}b$ZmoIv!L;6jZt0I*h^_K!=9P|>0olxF>If`R|5nwfb2wi{wiDI*s(;`ve(W*m z)a0&WQ&cp_A@S-$Y@=}hN$ghdrq^z%6YP|gKAS%Qi;mH+QciRjM=YjEz9=e0IQJ3_ zuVL1z-pZ;0T1#ai(Dm}rCc|E7Ijikh{my@KBQ)EH=MNcgMXpi#^|eTxlIm2vuIj?E zPjSIXqfaEpuJ6Wk;B?q>)ERQt5qK=_sO}<)*N`eKebezaSwhPK^R42L}TnCVNn`P#V#h%J?mTWb`t z(9=gvz>rZ!)5Kfxm#uk#!61S2dGBvw2V`q#FYZCvRN6 zdz#i^fF(tAl%DhyowzM~@JSZHDn}J+5KznlYYDEJ?lP-v4OemSxiYe-ZT59P} z(qLh|rM4(BwDL1bMyZz51p9IY$`~o{uWV^ciHKJv2rVNE>d}nCrhCIEv^4{9H>w|^ z^&Y?IEX|_F!@4F)oYJr%Yvbtc#kawhE7CJc(~pp%#+IwL{Z7MWH(WPt8`c`YGC+>B zz_+?hC42Vka6g#7i=fK(JiEb#(uuJVRi4Dx@sfy8$D;p&YiX<=8gZjT5=C_prsv9r1!vqsI|GiKzRc?C%3^|7Q z?tc)y%kG`>dtXeDU^Ob3m%8QGmC$_ZS66aj_^G^A7gJj7*gTI5h97-Q9enJGjr^;X z*giMC!v6$DvR>KBp(FR4H&)0KPVr(o%~qV1gZ*(4fv|Jh2GCpL?Bd20EZs!iQxbD0Ny!VZ&5tj;VWBA}O*0@Y;=fI^ zXQh9e5^{RM;0FE-qz6t~R3Eknul?bxk0c*2i@kF7h^=gcRQ9+>4+xmcJ~^}C{?S*a zje@lZeq$luzKT4rG;u6Em;J+LDUn>!xPRZa@f;E?elDAC#dd~n4#490v84fkJ;->L z7pn;_hv4d)%B4=TkJ0A&a*8g(qUTG%+-R8#iQACF67%DQVy=}3qGV;HjR;!HJ^4!? ztJIDV{K{Qc(tY5x$HaWV+cOrmU;A`A-7%bD_97>wJF;WcL;oJ!<+}w09 zF)=I5hzU`!VQ>l7tiiavKXZ=XFs~nr3R!|&xlAxi$*Gp2SWcD_T-Mv^3_~(5h1CI`r)O0QJ zpP0WnZ(A!E11!XP3z0imL2sRoG@%i zr->FRqwJl!Nyi0}F0_kclB}~4>*av6YrJRKo>soX(bXeD$0x{M@Wb*ro zFdSQ*0Cwo5E8p)tY$>Bf=EbgFJ`7o7k$oYS8u}2X8Tk1j=H_*+11D)Z9wdBH&L%s9 zHp7R5%El3UkB5Ut#mZ>|*0yF0&P$x@Y6S>EiA~xF$nr(S%C1q2#b)N%%4y@fBHXOm zx-wDG(dP9L^?Q!WCv+Z4#KGdy44;Hk9@XPRz)yk;$)mK_VHHLjW#_A<1<{{Q)o?|t z#}dxI+g`s;n)(v8NSCS@bLoW7K&md-DDL)zT7^~8|G>>g$?dx zB4|AVA1%QdbG`~uPJxUg_1T8zrc{${~K1X-rB@(-i`vS0UJQQEUa(? zX=sd}f2C^bxBALXan-_~uPF7x-^>VHH0llZnp_+NO*B=A+pOEM&K=yS=BmgS&5{im z&5C))^y-2jnZ;W~HT@sb7rN9bq3~n0@9}j-{HU~|QOFT!g%d9i`^UV}4rM|UpTCKW zHo;%~pl%fnO;%J8TY)aX7n%n|-;Z4eP6InB=niZ%?|Gr~8SW8-OG+JNav4>NBr3U? zi(We??$2xBUa8a3MH|Aw3o4-cxg{ry~gj=#NEDl>ex9e zW_+MHj)=o=Y$o68hq^O-9~Nx?gOp}az2t-f?=VGBM2*hP>nan-a$N?*UTfjsYb47V z8OAJey7qvrRt7e1GstMb%n#{mNZCjjWlQ756^1*|D(TmBywuX?-CvjI@m$A7R|R$x z%gpXCX^8Z4p{n7B#SE!v8o*5v|NhR7-b~09BPOU$ufFv_#>+wLP9)y>JFOk-?wD7z zv`Ks1JxK^wb4qF|1e^x26}`M&$45>%5?Rqk#WKxU+;y4a(?#p`3p|2v-r@9Rk4au? zQIGp$Bc1H~jPke;=>)dwkVtY7{=zBW_$*b15hXZ}ZG`*qbb5){^gX|U zs@aeeiU6lHdO2idj$Dv3M>X`dG0Mi1AslrJp7;`2GJ7ex%G>bYN$u`) zf?s|AS@QL`d{buUJNB+mrCos!ks4KXA>j?}Q)sx#qfjn-g2#Kly1`@FG-X ze{rSO_b=fk`pk7yP2>%*rIiRygnnG}!rF}Dht3A|NhmlFW0e`=AzOhrvQ{vEBT;de zGtk!nwVH-CiamTsyZO*+YTDZh*+WVjh2*3h>RUa3z4?oEYZhoRbc6!l+)ozi%a z7kROiHtI^mGSg(DQNmcqsai3;Ra#xPjTfY$(;WR0-z=K9Rp^*^T7M^N7Yca7o~x_$ zF{SJXDsGF?7>Slr-_xy_^5K-L_I|4$`{m@x0Gs;^1S2?0Uf81Eu$#8Kv??vib}?Z5 zK7yB@AIV@)>lTDu`;81+Me#}HoNLQY&XBMt?`hHzBzm^)421}R>9`Q&9x)%J_b?$e z;_l9{?5Mh6!!>@@u6l`eJAF8HmQ4$`JhE2bP1yd4-i9PXuP%(=Ug4AC2c_180<%LC zI4zzA*drcFZI*ravxp~SqLWIf^;~qc?0mhUpm1Eu(WBBEIi7TcDqzW>H2J zLL}oi*`{CeRTYm#iVMQGgmR2SjrEkZQcON8Lc86hu6g%L~({S4) z?DE4bN&Ixw18vB1n`=~G6f^5^%wHpVx6}F;&^H|fA!^ZFJaCbhEc{1qJHAHz5B7}w zD%Pr19Rwdn9!wvF6|odJcks?Qnp5ytlK@)m%9fvnX^3lX-%r_12N@;pwM1;E`WSR@cpbQ+;Hr_-pt%o6kIGbY%?)A{3w5}&7hbrs%%-w%DT8t!Hgb{*KTq_4T5!Uhyd3`g&H_N|)kinN3O zx}P22ywz&kH3j!y><(W98A~@4G}_YQ(_R}l6E;WRKRi~2eqpP56uG+T6GtW_0BZWB zt^^<-J~Kb!zn|3v-bqlk086|)W{*L|D{}&$*+=co_R2B*fO*vZ014Be$9L8o;pvPA zj>vrv*i~GeJkZUcuQT%#y0JH3n-Ad!+I*uf+!L>Vb}KRof(%%ucz4EQ5C0(lGF8XP zl|ISL^MTzr-57@E0d)I)1y4$#4*HZ(Z2OnL}UXpUSh4(gm-e(|`= zavqG7MrK)jexk&Q(C^>AVTk}e!VYpzscIZyFKWD9m|_9zhBx=}yMF|ht9i{nzTh?A zSh(~^@V{GtZ9b`@+5g^d{8`!hHo}4Vrsw}pefJi5Sw3pNRe;DXdmD(k{xeno&yUkLX(a}Y~?Q@3ZS02->wDf zxsR`h^188me3(9HkUgJ{%@;2q-m+>O&W@ee$L$vg7|=aqKy3|d%OWf?G9`QMf~nRv z8Tz1>PU#zf2qe2a{Iai$h65Zz3C{VSsCfFhtpL@4QjqOC0NfJxtUAAhNes0v;heQR zeG|56xi%jBt`pPsZaf$nqHjp|u?R9AUw*PuiH48Fo$8oOy_p1=&pZ`WGxxb&k*r|@ zkDwZfxAnF0P|!8I@!&JaG`Q9$Of>T7BbpEQ#N$A9u#G{de>-IS*y9z`%<26Y@tgs{ z=MJv;1Kb!GVeAN7`4`CdB0Lyh{ejr~foMEZZ^yiN4+b^$W^U%x`KTW0tosJb+BOLq zCDE;ZOT^VpVfg%xulDZ}Fh(&wzm4=zdVh7q_*luQ1%;C7zu*SI7=v_&ypbJJdF^{J<@Hdn05v>+tV61BQKU?{r?Ej7Opb4C&l2 z-3LANfb81$o8wvR38RR=Hflp5B#dc5$_GZ8&l?X8zsO0*KZJot^Sj)8Y>kYY{v0Fl zu09{u+1m^3&)M%hIZ?f0YO|wKa64V$GByX^!@h2xxnlf-?5b{4?`(e}ee-?@^ccEz zy*{`opXcb&GyYNwLS)(@o6R8ixDq!=e~)oMI2E#pjTD z3x4 z7y6(Wdyrfgd^aSjriw_*Nll_cC?pmS$Y;Eevc7@fwMwb9Lq2Q1v_6FSZ~3=?8`N#I z!((rePFX#D6NEQ75#!Q-R>pT3p(L4o$B#=Xog~o~w%pl$IEf3NI&*GajGN+084&CS zba6}@yZyR+qsym2bkz(jLz;`a>{3mAsEB^(nSM3npBwXijUOC)@9*D7?f^;0_OBJ|;tssaH#%b1bOBWYe)ye8%wK5kFj_ zlKJ8j`P>7D>Eh6hpve_BZTvp;)pU$%yBkN+ac2$~6yoqYw9I~o4gL$4{W^p(W#K21 z_MFGgX{B8xw8`wS5qdOoKfGhwW33({Tb+JxbdxBQTmGH)vbOLjJIf|<*+qzkw+b$1 zC;>q`#IdVjA!_$qQFC$eGjd|bI~5BkgvuwFrRkgs;p{waGCG(oflDPH>^=02Z|bm=#X7n(bli-xJGm^({Uj>D1ZVA)P%8VR+S8c{e4m_U9;JNWS|&k`KB-gBCh6g zDZ|8iCc8xY5oA*Lkl|lhY@PiPrdr$EdhjIgT!q(#+35F)^d=FND&%_2vwOCEWCi6QD)kJ(s?r2_|CQ-@C-#=K71fz-8>CE~P1SV^0u8%H|v8xF?( zyL{7ihT2U^k8E0!Y78&5AYY2<*jS}Q4V@)vmkzKUAgyC?$6MvK1p!pNnG8ne8q9{V zL>$oRK@^zyWqv-myo}x#61Od<{;Ak2`cn-0fB%4+a2AThpB6{;UP}z%09$pl_2`q^ z-x3++x{b(O4lA@iT8#sB>$wP@bcUoqozM59)&kmc zg^&cU!r?BJ`+sZBl&)|Dz_hv*f}VRT|EQtgDevZ|Cs0%6CQcSE-|NiQ;jh8avju&h z?|OG?yW{8PHz4bBWSg6V3U2P3r8P!93%xkK_RA4TH%zWXwad2}=dArdzBL3`?u7rU zjbhR~{IV$Rp{L~2=(B9fb1YVpc)fToq)svhjUyP!yga@Bz4d?u$z@?J)#q`V>iJSR z>YtP@)GWUI?`SnBc3+?*Os)|F+MjTR@(EXxhXUh#4xAq8CX6a5Dky=1i5ZgM6C+J{ z?OZQsk82YY?JEwuu4|ugRaUNq)h!CA$;T};3g<7d+3&h@e{gS#Ty?!?7Ythk zBDDXt3ZMBe2>g`1Oa~O;C!~)(g+gz_tzph5=d=}Rv|MtA3uCHGvI}Pqs8#y)VSaE< z5_?B(*jyM~Tg|xks&Mb5ZZ^4-QvROXjQ()wyb&={>Y7DTdf>sKaE?QYDgC|ToK;+u zC6i4dRg~c;4#>7{ zncg?uMDN@t(@ZlxrOw=t#Hv%a4= z?M_`feTO&5WCJGOrmylU)k59XzHtRJ;-#H9q|s+G9l?N#a&y$Z2nGhR$lEHECu8tV z<&v{3?fW!?d7NGT_{)Y?)sk)5W-DVf#bC5E!Shygh zgzmb^d9j})fkl+AGm*^)XO)fz-^FXBUSXRI8~|UCTThxLW`C3w)LS8GjN%`QLDkN; zBKvEqNv5?g0-u4&b)4z&ntgNHrxYFM^y?dp*hm4qNPoNT9_GEH{{k3#s&lPPHw zro>M)EAdc`HNH7!(pH=m`^5WeqwzlIO!ge8gTE0t_B=LA4(`igP^Xg`>%k-u;3nE% zGlB7b7|xhbJTyPT?W<$T{3MjlO?)0vD{_|L!M_ln9%~zRCT84MHwGZIJ4k6i-cYY= z+$o9(>NtWde#SGuoiIUXlE8wW7Oeif<73jm4OzQ9;svkOsg7e|P2FLt#K&58@Ea#J z(G%$jqfY<_ifRe*%Z12~vh`0|@jXwq3D^(+dbm@XuL;=)RR7;w5Z=|=x`v$*L!VO03&5|`vRr$J#u;wzsd)}rSj%YrBTe59(v=YA^j2e{ zK0<1Uyn7U~EQ8O*G$dPpELC09XpkJgSG(?Kw&_j<1jPh>-~3YLoh88j#; z$p3ouC175wfn`D@%-sIR6br6C=K2Ht|8>O5xTA&rKPl(b9VVF9Si<2Q$$uOFyA39w n-eZEHPbE;@lM?->=YP>2{L2?ds4rhWQ`Y~hn9n6x-P8RKnfxWW diff --git a/lintunes/android/andTunes.json b/lintunes/android/andTunes.json index b7b1950..4afad13 100644 --- a/lintunes/android/andTunes.json +++ b/lintunes/android/andTunes.json @@ -1 +1 @@ -{"version_name": "0.1.0", "version_code": 1} +{"version_name": "0.2.0", "version_code": 2} diff --git a/lintunes/andtunes/plays.py b/lintunes/andtunes/plays.py new file mode 100644 index 0000000..5b57506 --- /dev/null +++ b/lintunes/andtunes/plays.py @@ -0,0 +1,73 @@ +"""Play counts coming back from the Rabbit. + +andTunes records a play the way LinTunes does — on a natural finish — and +keeps ``Music/andTunes/plays/andtunes-.json`` in exactly the +per-machine-totals shape of `storage/play_journal.py`. So the R1 is simply one +more machine in the Round 38 model: sync copies its journal into +``/plays/`` and `PlayJournal.load` folds it in with the others. No +new merge rules. + +The one thing that's different is that the file now has two homes, the device +and the data dir, and both desktops may bring it back. `merge_totals` (the +per-track max) makes that harmless: totals only grow, so both machines +converge on the same bytes, and a copy that's behind can never pull a count +down. The write is skipped when nothing moved, so an idle sync doesn't touch +the synced share at all. +""" + +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 + +PREFIX = "andtunes-" + + +def _entries(data) -> dict: + """Only the ``{track id: totals}`` rows; anything else is ignored.""" + if not isinstance(data, dict): + return {} + return {str(k): v for k, v in data.items() + if str(k).isdigit() and isinstance(v, dict)} + + +def device_journals(root: Path) -> dict[str, dict]: + """Every andTunes journal on the device, by filename. + + A journal the app is halfway through writing, or one MTP hands back + truncated, is skipped: it'll be complete next sync, and counts only grow. + """ + folder = Path(root) / PLAYS_DIR + try: + paths = sorted(folder.glob(f"{PREFIX}*.json")) + except OSError: + return {} + found = {} + for path in paths: + try: + found[path.name] = _entries(read_json(path)) + except (OSError, ValueError): + continue + return found + + +def bring_back(root: Path, data_dir: Path) -> int: + """Fold the device's journals into ``/plays/``. + + Returns how many tracks' totals moved (0 means nothing was written). + """ + moved = 0 + dest_dir = Path(data_dir) / JOURNAL_DIR + for name, incoming in device_journals(root).items(): + dest = dest_dir / name + try: + mine = _entries(read_json(dest)) if dest.exists() else {} + except (OSError, ValueError): + mine = {} + merged, changed = merge_totals(mine, incoming) + if changed: + dest_dir.mkdir(parents=True, exist_ok=True) + write_json(dest, dict(sorted(merged.items(), key=lambda kv: int(kv[0])))) + moved += changed + return moved diff --git a/lintunes/andtunes/sync.py b/lintunes/andtunes/sync.py index 9afb529..b566c9d 100644 --- a/lintunes/andtunes/sync.py +++ b/lintunes/andtunes/sync.py @@ -29,8 +29,10 @@ from PyQt6.QtCore import QObject, pyqtSignal from .. import __version__ from ..device_sync import CHUNK, build_m3u, track_display +from ..export import web_support from . import art as art_mod from . import layout +from . import plays as plays_mod from .layout import ( BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, assert_inside, media_relpath, playlist_relpath, @@ -45,6 +47,11 @@ class MediaItem: rel: str # "Media/Artist/Album/04 Song.mp3", posix, under the root size: int track: object = None + # "flac" when the worker must transcode first (no fresh cached FLAC yet); + # then `size` is only the source's, and `device_size` is what's already + # on the device, so an unchanged conversion isn't copied again. + convert_to: str | None = None + device_size: int | None = None @dataclass @@ -70,6 +77,30 @@ class AndTunesPlan: copy_art: bool = True bytes_to_copy: int = 0 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 + + @property + def conversions(self) -> list: + return [item for item in self.copies if item.convert_to] + + @property + def needs_ffmpeg(self) -> bool: + return bool(self.conversions) + + def drop_conversions(self, reason: str): + """Leave out every track that still needs transcoding (no ffmpeg). + + They simply aren't present this run: the index is written from what + landed, so the app never lists them. Anything already on the device + from an earlier sync is left alone rather than counted stale. + """ + dropped = {item.track_id for item in self.conversions} + for item in self.conversions: + self.refused.append((track_display(item.track), reason)) + self.bytes_to_copy -= item.size + self.copies = [i for i in self.copies if i.track_id not in dropped] + self.items = [i for i in self.items if i.track_id not in dropped] def default_buttons() -> list: @@ -86,13 +117,31 @@ def default_buttons() -> list: return [] +def flac_cache(track) -> Path: + """Where a track's FLAC conversion is kept between syncs. + + Keyed by track id, so a rename doesn't orphan it; stale once the source + is newer (a re-tag rewrites the file), exactly like the art cache. + """ + base = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")) + return Path(base) / "lintunes" / "andtunes-flac" / f"{track.track_id}.flac" + + +def _fresh(cache: Path, src: Path) -> bool: + try: + return cache.stat().st_mtime >= src.stat().st_mtime + except OSError: + return False + + def _relative_to_playlists(media_rel: str) -> str: """A media path as seen from inside the Playlists/ folder.""" return "../" + media_rel def plan_andtunes_sync(playlists, tracks_by_id, root: Path, device=None, - copy_art: bool = True) -> AndTunesPlan: + copy_art: bool = True, + data_dir: Path | None = None) -> AndTunesPlan: """Diff the selected playlists against the andTunes tree already there. Pure planning — nothing is created, copied, or deleted. Album art is @@ -101,22 +150,43 @@ def plan_andtunes_sync(playlists, tracks_by_id, root: Path, device=None, progress bar an accurate byte total for a few hundred KB of JPEG and buys a window that doesn't freeze. """ - plan = AndTunesPlan(device=device, root=Path(root), copy_art=copy_art) + plan = AndTunesPlan(device=device, root=Path(root), copy_art=copy_art, + data_dir=Path(data_dir) if data_dir else None) - # Resolve every unique track across every selected playlist to a real file. + # Resolve every unique track across every selected playlist to a real + # file, through the same format gate the web-mix export uses: Android's + # MediaPlayer decodes the browser set (MP3, AAC, FLAC, Vorbis, Opus, WAV), + # so ALAC, AIFF and oddities become FLAC — lossless, never a lossy + # re-encode — and FairPlay is refused, never attempted. items: dict[int, MediaItem] = {} skipped: set[int] = set() + refused: set[int] = set() for playlist in playlists: for tid in playlist.track_ids: - if tid in items or tid in skipped: + if tid in items or tid in skipped or tid in refused: continue track = tracks_by_id.get(tid) src = Path(track.location) if track and track.location else None if src is None or not src.is_file(): skipped.add(tid) continue - items[tid] = MediaItem(tid, src, media_relpath(track), - src.stat().st_size, track) + convert_to, reason = web_support.conversion_for(track.location, track.kind) + if reason: + refused.add(tid) + plan.refused.append((track_display(track), reason)) + continue + rel, size, item_src = media_relpath(track), src.stat().st_size, src + if convert_to: + # media_relpath ends in the source's own suffix (maybe none). + ext = Path(track.location).suffix + rel = rel[: len(rel) - len(ext)] + ".flac" + cached = flac_cache(track) + if _fresh(cached, src): + # Converted on an earlier sync: a plain byte copy now, + # diffed by the FLAC's own size like any other file. + item_src, size, convert_to = cached, cached.stat().st_size, None + items[tid] = MediaItem(tid, item_src, rel, size, track, + convert_to=convert_to) plan.skipped = len(skipped) # Same collision rule as the per-playlist sync: every member of a @@ -162,7 +232,12 @@ def plan_andtunes_sync(playlists, tracks_by_id, root: Path, device=None, expected.add(MANIFEST_NAME) for item in plan.items: - if on_device.get(item.rel) == item.size: + if item.convert_to: + # Its FLAC size is unknown until the worker converts it. + item.device_size = on_device.get(item.rel) + plan.copies.append(item) + plan.bytes_to_copy += item.size + elif on_device.get(item.rel) == item.size: plan.kept += 1 else: plan.copies.append(item) @@ -256,6 +331,8 @@ class AndTunesSyncWorker(QObject): self._art: dict[str, str] = {} # album key -> art rel path self._total_kib = max(plan.bytes_to_copy // 1024, 1) self._done = 0 + self._plays = 0 # tracks whose play counts came back + self._unconverted = 0 # conversions ffmpeg couldn't do try: self._check_cancel() @@ -263,6 +340,7 @@ class AndTunesSyncWorker(QObject): to_copy = {item.track_id for item in plan.copies} self._present = {item.track_id for item in plan.items if item.track_id not in to_copy} + self._bring_back_plays() self._delete_stale() self._copy_media() self._sync_art() @@ -278,6 +356,9 @@ class AndTunesSyncWorker(QObject): "skipped": plan.skipped, "albums": len(self._art), "tracks": len(self._present), + "plays": self._plays, + "refused": len(plan.refused), + "unconverted": self._unconverted, }) except _Cancelled: self._drop_partial() @@ -293,6 +374,7 @@ class AndTunesSyncWorker(QObject): "copied": len(self._present) - plan.kept, "total": len(plan.copies), "removed": self._removed, + "plays": self._plays, }) except OSError as e: self._drop_partial() @@ -311,6 +393,37 @@ class AndTunesSyncWorker(QObject): def _emit(self, label: str): self.progress.emit(self._done // 1024, self._total_kib, label) + def _bring_back_plays(self): + """First, before anything is copied: even a cancelled sync should + bring the Rabbit's play counts home. A journal we can't read now is + read next time — counts only grow.""" + if self._plan.data_dir is None: + return + try: + self._plays = plays_mod.bring_back(self._plan.root, self._plan.data_dir) + except OSError: + self._plays = 0 + + def _convert(self, item) -> Path | None: + """Transcode into the FLAC cache; None when ffmpeg can't. + + One file ffmpeg chokes on costs that song, not the sync — the same + way a vanished source does. A cancel mid-conversion leaves no cache + entry behind. + """ + cache = flac_cache(item.track) + tmp = cache.with_suffix(".tmp.flac") + try: + cache.parent.mkdir(parents=True, exist_ok=True) + web_support.convert_to_flac(item.src, tmp, self._cancel) + self._check_cancel() + tmp.replace(cache) + except OSError: + tmp.unlink(missing_ok=True) + self._unconverted += 1 + return None + return cache + def _delete_stale(self): plan = self._plan for rel in plan.stale: @@ -329,11 +442,30 @@ class AndTunesSyncWorker(QObject): for i, item in enumerate(plan.copies, start=1): self._check_cancel() label = f"{i}/{len(plan.copies)} {Path(item.rel).stem}" + src = item.src + if item.convert_to: + # ffmpeg reports no byte progress; the bar holds, then jumps. + self._emit(f"{label} (converting to FLAC)") + if not item.src.is_file(): + self._done += item.size + continue + src = self._convert(item) + if src is None: + self._done += item.size + continue + size = src.stat().st_size + # Swap the source's size for the FLAC's in the running total. + self._total_kib = max(self._total_kib + (size - item.size) // 1024, 1) + if item.device_size == size: + # Already on the device from an earlier sync, identical. + self._done += size + self._present.add(item.track_id) + continue # A source gone since planning means Syncthing moved it under us # (a metadata edit on the other machine relocates files) — skip # it, don't abort. Once open, the fd pins the inode. try: - fsrc = open(item.src, "rb") + fsrc = open(src, "rb") except FileNotFoundError: self._done += item.size self._emit(label) diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index fbfa0de..e92aeda 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -161,7 +161,6 @@ class MainWindow(QMainWindow): self._sync_progress.setTextVisible(False) self._sync_progress.hide() self.statusBar().addPermanentWidget(self._sync_progress) - self._sync_worker = None self._andtunes_worker = None self._install_worker = None self._sync_device_name = "" @@ -362,15 +361,9 @@ class MainWindow(QMainWindow): self._install_action = self._add_action( connections_menu, "Install andTunes on Rabbit…", "", self._install_andtunes) - connections_menu.addSeparator() - # The per-playlist sync stays until andTunes can actually play a - # song: it is what feeds Auxio, and pulling it in the same round that - # adds the new tree would leave the device full of files and nothing - # able to open them. - self._sync_action = self._add_action( - connections_menu, "Sync Playlist to Rabbit (Auxio)", "", - self._sync_playlist_to_device) - self._sync_action.setEnabled(False) + # "Sync Playlist to Rabbit (Auxio)" was retired in Round 51, once + # andTunes could play what it syncs. device_sync's helpers stay: the + # export and andTunes code import them. connections_menu.addSeparator() self._cast_action = self._add_action( connections_menu, "Connect to Chromecast…", "", self._toggle_cast) @@ -403,11 +396,7 @@ class MainWindow(QMainWindow): # An export in flight owns the same status-bar widgets, so it blocks # a sync just as another sync would. busy = self._busy_worker() is not None - on_playlist = (self._content.currentWidget() is self._playlist_view - and bool(self._playlist_view.playlist_id)) device = device_sync.find_device() - self._sync_action.setEnabled( - not busy and on_playlist and device is not None) # Whole-library sync doesn't care which view is open — only that a # device is plugged in and at least one playlist is ticked. self._andtunes_action.setEnabled(not busy and device is not None) @@ -464,49 +453,6 @@ class MainWindow(QMainWindow): self.statusBar().showMessage( f"Skipped “{track.name}” — {reason}", 6000) - def _sync_playlist_to_device(self): - # Everything may have changed since the menu opened — re-verify. - if self._sync_worker is not None and self._sync_worker.busy(): - return - device = device_sync.find_device() - playlist = self._manager.library.playlists.get( - self._playlist_view.playlist_id) - if device is None or playlist is None: - return - tracks = [self._manager.library.tracks[tid] - for tid in playlist.track_ids - if tid in self._manager.library.tracks] - try: - plan = device_sync.plan_sync( - playlist.name, tracks, device.music_dir, device) - free = shutil.disk_usage(device.root).free - except OSError as e: - QMessageBox.warning(self, "Sync failed", - f"Couldn't read the {device.name}: {e}") - return - needed = plan.bytes_to_copy + device_sync.SPACE_MARGIN - if free + plan.bytes_freed < needed: - QMessageBox.warning( - self, "Not enough space", - f"Syncing “{playlist.name}” needs " - f"{device_sync.format_bytes(needed - plan.bytes_freed)}, but the " - f"{device.name} only has {device_sync.format_bytes(free)} free.") - return - self._sync_worker = device_sync.DeviceSyncWorker(plan, self) - self._sync_worker.progress.connect(self._on_sync_progress) - self._sync_worker.finished.connect(self._on_sync_finished) - self._sync_worker.cancelled.connect(self._on_sync_cancelled) - self._sync_worker.failed.connect(self._on_sync_failed) - self._sync_device_name = device.name - self._sync_label.setText(f"Copying to {device.name}") - self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1)) - self._sync_progress.setValue(0) - self._sync_cancel.show() - self._sync_label.show() - self._sync_progress.show() - self._sync_inhibitor.inhibit() - self._sync_worker.start() - def _hide_sync_widgets(self): self._sync_cancel.hide() self._sync_label.hide() @@ -514,8 +460,8 @@ class MainWindow(QMainWindow): def _busy_worker(self): """The transfer currently owning the status-bar progress widgets.""" - for worker in (self._sync_worker, self._andtunes_worker, - self._export_worker, self._url_worker): + for worker in (self._andtunes_worker, self._export_worker, + self._url_worker): if worker is not None and worker.busy(): return worker return None @@ -527,9 +473,7 @@ class MainWindow(QMainWindow): if self._export_worker is not None and self._export_worker.busy(): self._confirm_cancel_export() return - worker = self._sync_worker - if self._andtunes_worker is not None and self._andtunes_worker.busy(): - worker = self._andtunes_worker + worker = self._andtunes_worker if worker is None or not worker.busy(): return box = QMessageBox(self) @@ -553,27 +497,10 @@ class MainWindow(QMainWindow): self.statusBar().showMessage( f"Sync cancelled — {summary['copied']} of {summary['total']} " "tracks copied; sync again to finish.", 8000) - - def _on_sync_progress(self, done_kib, total_kib, label): - self._sync_progress.setRange(0, total_kib) - self._sync_progress.setValue(done_kib) - self._sync_label.setText( - f"Copying to {self._sync_device_name} · " - f"{device_sync.format_bytes(done_kib * 1024)} / " - f"{device_sync.format_bytes(total_kib * 1024)}") - - def _on_sync_finished(self, summary): - self._sync_inhibitor.release() - self._hide_sync_widgets() - msg = (f"Synced “{summary['playlist']}” to the {summary['device']}: " - f"{summary['copied']} copied, {summary['kept']} up to date, " - f"{summary['removed']} removed") - if summary["skipped"]: - msg += f", {summary['skipped']} skipped (no local file)" - if summary.get("vanished"): - msg += (f", {summary['vanished']} changed under us " - "(re-sync to pick them up)") - self.statusBar().showMessage(msg, 8000) + if summary.get("plays"): + # Counts are brought back before anything is copied, so even a + # cancelled sync has news for the journals. + self._manager.reload_from_disk() def _on_sync_failed(self, message): self._sync_inhibitor.release() @@ -620,12 +547,27 @@ class MainWindow(QMainWindow): try: plan = andtunes_sync.plan_andtunes_sync( playlists, self._manager.library.tracks, root, device, - copy_art=bool(self._prefs.device_sync.get("copy_art", True))) + copy_art=bool(self._prefs.device_sync.get("copy_art", True)), + data_dir=self._manager.data_dir) free = shutil.disk_usage(device.root).free except OSError as e: QMessageBox.warning(self, "Sync failed", f"Couldn't read the {device.name}: {e}") return + if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available(): + conversions = plan.conversions + names = "\n".join(f" • {device_sync.track_display(i.track)}" + for i in conversions[:8]) + more = (f"\n … and {len(conversions) - 8} more" + if len(conversions) > 8 else "") + answer = QMessageBox.question( + self, "FFmpeg not found", + f"{len(conversions)} song(s) need converting to FLAC before " + f"the {device.name} can play them, but the ffmpeg command " + f"isn't installed:\n\n{names}{more}\n\nSync without them?") + if answer != QMessageBox.StandardButton.Yes: + return + plan.drop_conversions("it needs ffmpeg to convert") needed = plan.bytes_to_copy + device_sync.SPACE_MARGIN if free + plan.bytes_freed < needed: QMessageBox.warning( @@ -667,6 +609,15 @@ class MainWindow(QMainWindow): f"{summary['albums']} covers, {summary['removed']} removed") if summary["skipped"]: msg += f", {summary['skipped']} skipped (no local file)" + if summary.get("refused"): + msg += f", {summary['refused']} left out (can't play there)" + if summary.get("unconverted"): + msg += f", {summary['unconverted']} couldn't be converted" + if summary.get("plays"): + msg += f"; play counts back for {summary['plays']} song(s)" + # The journal landed in the data dir; fold it in now rather than + # waiting for the file watcher. + self._manager.reload_from_disk() self.statusBar().showMessage(msg, 10000) # ---- connections: install the andTunes app ---- diff --git a/lintunes/storage/play_journal.py b/lintunes/storage/play_journal.py index 031e7bb..f7b1830 100644 --- a/lintunes/storage/play_journal.py +++ b/lintunes/storage/play_journal.py @@ -194,3 +194,34 @@ class PlayJournal: write_json(journal_dir / f"{self.machine}.json", {str(k): v for k, v in sorted(self._own.items())}) self.dirty = False + + +def merge_totals(mine: dict, incoming: dict) -> tuple[dict, int]: + """Two copies of *one machine's* journal → the higher total per track. + + Totals only ever grow, so the larger count is always the newer one and + taking it can never double-count. Used for the Rabbit's journal, which + lives both on the device and in the data dir (`andtunes/plays.py`); the + same rule as `conflict_resolver._merge_play_journal`, which predates it. + Returns ``(merged, tracks that changed)``. + """ + merged = dict(mine) + changed = 0 + for tid, entry in incoming.items(): + have = merged.get(tid) + if have is None: + merged[tid] = dict(entry) + changed += 1 + continue + combined = dict(have) + for count in ("plays", "skips"): + if count in have or count in entry: + combined[count] = max(have.get(count, 0), entry.get(count, 0)) + for stamp in ("last_played", "last_skipped"): + combined[stamp] = _newer(have.get(stamp), entry.get(stamp)) + if combined[stamp] is None: + del combined[stamp] + if combined != have: + changed += 1 + merged[tid] = combined + return merged, changed diff --git a/scripts/make_dev_library.py b/scripts/make_dev_library.py index 59dabf9..7fc9bc8 100644 --- a/scripts/make_dev_library.py +++ b/scripts/make_dev_library.py @@ -29,7 +29,10 @@ The edge cases here are not decoration. Each one has bitten something: * a dangling location → the "skipped" counters; * a 200-character title → the 150-char truncation; * non-ASCII and an emoji → UTF-8 all the way to - the device's m3u. + the device's m3u; +* AIFF, ALAC-in-.m4a and a FairPlay .m4p → the andTunes / web-mix + format gate (FLAC or + refuse), in "Odd Formats". """ import argparse @@ -70,7 +73,14 @@ CODECS = { ".wav": ["-codec:a", "pcm_s16le"], ".aiff": ["-codec:a", "pcm_s16be"], ".ogg": ["-codec:a", "libvorbis"], + # Pseudo-suffixes for what a suffix alone can't say: Apple Lossless lives + # in an ordinary .m4a, and a FairPlay .m4p is (for our purposes) AAC in an + # MP4 container that iTunes calls "Protected". Neither is really DRM'd + # here — the *kind* is what every format gate reads. + "alac": ["-codec:a", "alac"], + "m4p": ["-codec:a", "aac", "-b:a", "64k", "-f", "mp4"], } +FILE_SUFFIX = {"alac": ".m4a", "m4p": ".m4p"} def _ffmpeg(args) -> bool: @@ -78,10 +88,10 @@ def _ffmpeg(args) -> bool: capture_output=True).returncode == 0 -def make_audio(path: Path, seconds: float, freq: int) -> bool: +def make_audio(path: Path, seconds: float, freq: int, codec_key: str = "") -> bool: """A short sine wave in whatever container the suffix asks for.""" path.parent.mkdir(parents=True, exist_ok=True) - codec = CODECS.get(path.suffix) + codec = CODECS.get(codec_key or path.suffix) if codec is None: return False return _ffmpeg(["-f", "lavfi", "-i", @@ -131,6 +141,7 @@ ALBUMS = [ # Formats Android or the browser may refuse — the future format gate. ("Analog Ghost", "Tape Hiss", "", 1987, [ ("Reel One", 1, 1, ".aiff"), ("Reel Two", 1, 2, ".ogg"), + ("Reel Three", 1, 3, "alac"), ], "rebeccapurple"), ] @@ -144,6 +155,8 @@ EDGE_TRACKS = [ ("わたしの音楽 🎧", "Sakura Denwa", "東京の夜", ".mp3", "non-ASCII + emoji"), ("A Title That Simply Refuses To Stop Going On And On " * 4, "Verbose", "Excess", ".mp3", "200+ characters, hits the 150-char truncation"), + ("Locked Away", "Fair Play", "Protected", "m4p", + "FairPlay-protected: refused, never attempted"), ] @@ -183,9 +196,9 @@ def build(out: Path, seed: int = 47): folder = tree / _safe(album_artist or artist) / _safe(album) stem = (f"{disc}-{number:02d}" if disc_count > 1 else f"{number:02d}") - path = folder / f"{stem} {_safe(title)}{suffix}" + path = folder / f"{stem} {_safe(title)}{FILE_SUFFIX.get(suffix, suffix)}" seconds = _clip_seconds(suffix) - if not make_audio(path, seconds, random.randint(180, 900)): + if not make_audio(path, seconds, random.randint(180, 900), suffix): failed.append(str(path)) continue if cover: @@ -211,11 +224,12 @@ def build(out: Path, seed: int = 47): # ---- edge cases ---- for title, artist, album, suffix, _note in EDGE_TRACKS: folder = tree / _safe(artist or "Unknown Artist") / _safe(album or "Unknown Album") - path = folder / f"{_safe(title)[:120]}{suffix}" + ext = FILE_SUFFIX.get(suffix, suffix) + path = folder / f"{_safe(title)[:120]}{ext}" while path.exists(): # the deliberate collision pair - path = path.with_name(f"{path.stem}_{random.randint(10, 99)}{suffix}") + path = path.with_name(f"{path.stem}_{random.randint(10, 99)}{ext}") seconds = _clip_seconds(suffix) - if not make_audio(path, seconds, random.randint(180, 900)): + if not make_audio(path, seconds, random.randint(180, 900), suffix): failed.append(str(path)) continue made += 1 @@ -248,8 +262,8 @@ def _clip_seconds(suffix: str) -> float: """Short on purpose. Durations need to *differ* (sorting, smart-playlist limits, m3u #EXTINF), not to be realistic — and a four-minute .wav is 40 MB of sine wave nobody listens to.""" - if suffix in (".wav", ".aiff"): - return round(random.uniform(2, 5), 1) # uncompressed + if suffix in (".wav", ".aiff", "alac"): + return round(random.uniform(2, 5), 1) # (near-)uncompressed return round(random.uniform(4, 20), 1) @@ -257,7 +271,13 @@ def _kind(suffix: str) -> str: return {".mp3": "MPEG audio file", ".m4a": "AAC audio file", ".flac": "FLAC audio file", ".wav": "WAV audio file", ".aiff": "AIFF audio file", - ".ogg": "Ogg Vorbis audio file"}.get(suffix, "") + ".ogg": "Ogg Vorbis audio file", + "alac": "Apple Lossless audio file", + "m4p": "Protected AAC audio file"}.get(suffix, "") + +# What a format gate has to think about; gathered into "Odd Formats". +ODD_KINDS = {"AIFF audio file", "WAV audio file", "Ogg Vorbis audio file", + "Apple Lossless audio file", "Protected AAC audio file"} def _safe(name: str) -> str: @@ -280,6 +300,10 @@ def _build_playlists(library: Library, ids: list): # twice in the m3u. pl("On Repeat", "A3000000", [ids[0], ids[1], ids[0], ids[2], ids[0]]) pl("Empty Shelf", "A4000000", []) + # Every container a player might refuse: AIFF and ALAC must become FLAC, + # the FairPlay one must be reported and never attempted. + pl("Odd Formats", "A8000000", + [tid for tid in ids if library.tracks[tid].kind in ODD_KINDS]) pl("mix: summer/2003 🎧", "A5000000", ids[-8:]) # awkward name folder = pl("Boxes", "F1000000", playlist_type=PlaylistType.FOLDER) @@ -346,7 +370,7 @@ def _write_prefs(data_dir: Path, library: Library): "highlight": "blue", "device_sync": { # Something already ticked, so a sync has work to do out of the box. - "playlist_ids": ["A1000000", "A3000000", "S2000000"], + "playlist_ids": ["A1000000", "A3000000", "S2000000", "A8000000"], "copy_art": True, }, }, indent=2), encoding="utf-8") diff --git a/tasks-done.md b/tasks-done.md index bc74b85..19502a3 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,54 @@ ## Done +### Round 51 (2026-09-11) — counts come home, and only playable files go (v0.20.0) + +andTunes phase 3. The Rabbit is now one more machine in the play-count +model, and nothing lands on it that Android can't play. + +- [x] **Play counts come back** (andTunes 0.2.0). A natural finish counts a + play, which is LinTunes' rule, and LinTunes records no skips either. + The app keeps `Music/andTunes/plays/andtunes-.json` in the + Round 38 per-machine-totals shape and writes it beside the old one then + renames it over, so a sync never reads half a file. Each sync first + folds it into `/plays/` (`andtunes/plays.py`), using a new + `play_journal.merge_totals` (the per-track max). That 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 at all. +- [x] **Format gate.** The planner reuses `export/web_support.conversion_for` + outright, because Android's MediaPlayer decodes the browser's set. + FairPlay is refused and reported, and ALAC, AIFF and oddities land as + `.flac`. Each conversion is cached at + `$XDG_CACHE_HOME/lintunes/andtunes-flac/.flac`, so after the + first sync it's an ordinary size diff and is never re-copied. With no + ffmpeg, the same "sync without them?" question as export. A file + ffmpeg can't read costs that song, not the sync. +- [x] **"Sync Playlist to Rabbit (Auxio)" retired** from the Connections + menu, along with its handlers and worker plumbing. `device_sync`'s + helpers stay, because export and andTunes import them. +- [x] **The dev fixture grew the shapes the gate needs**: a real ALAC + `.m4a`, a "Protected AAC" `.m4p`, and an "Odd Formats" playlist (AIFF, + WAV, OGG, ALAC, FairPlay), ticked for sync by default. +- [x] **Found while verifying: a wedged gvfs-MTP mount hangs everything that + touches it.** A USB re-enumeration left `gvfsd-mtp` holding a dead + session. Every `ls` then sat in an uninterruptible FUSE wait that even + `timeout` couldn't kill. The dev sync and the GUI tests (which call the + real `find_device()`) both hung with it. Recovery was killing + `gvfsd-mtp` and running `gio mount mtp://…/`; the steps are now in + CLAUDE.md. It also explains the earlier 3-minute test runs: the full + suite takes 11 s against a healthy mount. +- [x] Verified with the dev library on the R1: + - AIFF → `01 Reel One.flac` and ALAC → `03 Reel Three.flac` on the + device; the FairPlay track was refused; 18 stale files from the old + fixture were pruned; the default buttons were shipped. + - All four Odd Formats songs played through on the R1. + - The next sync brought back `plays: 4`, and each track's effective + count went up by exactly one, with the device's `last_played`. + - A third sync copied nothing. + - Cold start 338–347 ms. + `tests/test_round51.py` covers the gate, the cache, `bring_back` and + the fold; the full suite passes. + ### Round 50 (2026-09-11) — andTunes plays music on the Rabbit (v0.19.0) The Android half of andTunes had been paused on a 1 GB toolchain download. diff --git a/tests/test_round37.py b/tests/test_round37.py index 45e9610..79a9991 100644 --- a/tests/test_round37.py +++ b/tests/test_round37.py @@ -509,7 +509,9 @@ class TestGuiWiring: window._refresh_export_action() assert not window._export_action.isEnabled() window._refresh_connection_actions() - assert not window._sync_action.isEnabled() + # The per-playlist Auxio sync was retired in Round 51; the + # andTunes sync is the one a running export must block. + assert not window._andtunes_action.isEnabled() finally: window._export_worker._busy = False diff --git a/tests/test_round51.py b/tests/test_round51.py new file mode 100644 index 0000000..3536710 --- /dev/null +++ b/tests/test_round51.py @@ -0,0 +1,285 @@ +"""Round 51: counts come back from the Rabbit, and only playable files go. + +What's pinned: + +* **the format gate** — andTunes sync goes through `web_support.conversion_for` + like the web-mix export: FairPlay is refused and reported, ALAC and AIFF + become FLAC (lossless, never a lossy re-encode), everything else is a byte + copy. A conversion is cached so the next sync is a size diff again, and one + file ffmpeg can't read costs that song, not the sync; +* **play counts** — the app's journal is the Round 38 per-machine shape, so + bringing it back is `merge_totals` (the per-track max) into the data dir, + and `PlayJournal` folds it in like any other machine's. A copy that's + behind can never pull a count down, and an idle sync writes nothing; +* **the Auxio sync is retired** from the menu. +""" + +import json +import shutil +import subprocess + +import pytest + +from lintunes.andtunes import plays +from lintunes.andtunes.sync import ( + AndTunesSyncWorker, flac_cache, plan_andtunes_sync, +) +from lintunes.export import web_support +from lintunes.models import Library, Track +from lintunes.models.playlist import Playlist, PlaylistType +from lintunes.storage.play_journal import PlayJournal, merge_totals + +needs_ffmpeg = pytest.mark.skipif(not web_support.ffmpeg_available(), + reason="ffmpeg not installed") + + +@pytest.fixture(autouse=True) +def _private_cache(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + + +def _file(tmp_path, name, data=b"x" * 64): + path = tmp_path / "local" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _aiff(tmp_path, name="Reel.aiff"): + path = tmp_path / "local" / name + path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", + "-i", "sine=frequency=440:duration=0.5", + "-codec:a", "pcm_s16be", str(path)], check=True) + return path + + +def _track(tid, path, kind="", name=None): + return Track(track_id=tid, name=name or f"Song {tid}", artist="Band", + album="Record", location=str(path), total_time=60_000, + kind=kind) + + +def _playlist(*tids): + return Playlist(name="Mix", persistent_id="AAAA0001", + playlist_type=PlaylistType.REGULAR, track_ids=list(tids)) + + +def _root(tmp_path): + return tmp_path / "device" / "Music" / "andTunes" + + +def _run(plan): + worker = AndTunesSyncWorker(plan) + out = {} + worker.finished.connect(lambda s: out.setdefault("finished", s)) + worker.failed.connect(lambda m: out.setdefault("failed", m)) + worker._run() + assert "failed" not in out, out.get("failed") + return out["finished"] + + +def _manifest(root): + return json.loads((root / "library.json").read_text(encoding="utf-8")) + + +# ---- the format gate ---- + +def test_gate_refuses_fairplay_and_converts_lossless(tmp_path): + tracks = { + 1: _track(1, _file(tmp_path, "a.mp3"), "MPEG audio file"), + 2: _track(2, _file(tmp_path, "b.m4a"), "Apple Lossless audio file"), + 3: _track(3, _file(tmp_path, "c.aiff"), "AIFF audio file"), + 4: _track(4, _file(tmp_path, "d.m4p"), "Protected AAC audio file"), + 5: _track(5, _file(tmp_path, "e.m4a"), "AAC audio file"), + } + plan = plan_andtunes_sync([_playlist(1, 2, 3, 4, 5)], tracks, _root(tmp_path)) + by_id = {item.track_id: item for item in plan.items} + assert set(by_id) == {1, 2, 3, 5} + assert by_id[1].convert_to is None and by_id[1].rel.endswith(".mp3") + assert by_id[5].convert_to is None and by_id[5].rel.endswith(".m4a") + for tid in (2, 3): + assert by_id[tid].convert_to == "flac" + assert by_id[tid].rel.endswith(".flac") + assert plan.refused == [("Band - Song 4", "it's copy-protected AAC")] + assert plan.playlists[0].track_ids == [1, 2, 3, 5] + assert plan.needs_ffmpeg and len(plan.conversions) == 2 + + +def test_a_suffixless_file_still_gets_a_flac_name(tmp_path): + tracks = {1: _track(1, _file(tmp_path, "noext"), "")} + plan = plan_andtunes_sync([_playlist(1)], tracks, _root(tmp_path)) + assert plan.items[0].rel.endswith("Song 1.flac") + + +@needs_ffmpeg +def test_conversion_lands_as_flac_and_is_cached(tmp_path): + src = _aiff(tmp_path) + tracks = {1: _track(1, src, "AIFF audio file")} + root = _root(tmp_path) + plan = plan_andtunes_sync([_playlist(1)], tracks, root, copy_art=False) + summary = _run(plan) + assert summary["tracks"] == 1 and summary["unconverted"] == 0 + + entry = _manifest(root)["tracks"][0] + assert entry["path"].endswith(".flac") + landed = root / entry["path"] + assert landed.read_bytes()[:4] == b"fLaC" + cache = flac_cache(tracks[1]) + assert cache.read_bytes() == landed.read_bytes() + + # Next sync: a plain size diff against the cached FLAC — nothing to do. + again = plan_andtunes_sync([_playlist(1)], tracks, root, copy_art=False) + assert again.items[0].convert_to is None + assert again.items[0].src == cache + assert again.kept == 1 and not again.copies and not again.needs_ffmpeg + + +@needs_ffmpeg +def test_a_retag_makes_the_cached_flac_stale(tmp_path): + src = _aiff(tmp_path) + tracks = {1: _track(1, src, "AIFF audio file")} + root = _root(tmp_path) + _run(plan_andtunes_sync([_playlist(1)], tracks, root, copy_art=False)) + cache = flac_cache(tracks[1]) + stamp = cache.stat().st_mtime + import os + os.utime(src, (stamp + 10, stamp + 10)) # the source got rewritten + plan = plan_andtunes_sync([_playlist(1)], tracks, root, copy_art=False) + assert plan.items[0].convert_to == "flac" + assert plan.items[0].device_size == (root / plan.items[0].rel).stat().st_size + # Re-converting the same audio gives the same FLAC, so it isn't re-copied. + summary = _run(plan) + assert summary["tracks"] == 1 + + +def test_a_file_ffmpeg_cant_read_costs_that_song_not_the_sync(tmp_path, monkeypatch): + def broken(src, dest, cancel=None): + raise OSError("ffmpeg failed on it") + monkeypatch.setattr(web_support, "convert_to_flac", broken) + tracks = {1: _track(1, _file(tmp_path, "a.mp3"), "MPEG audio file"), + 2: _track(2, _file(tmp_path, "b.aiff"), "AIFF audio file")} + root = _root(tmp_path) + summary = _run(plan_andtunes_sync([_playlist(1, 2)], tracks, root, copy_art=False)) + assert summary["unconverted"] == 1 and summary["tracks"] == 1 + manifest = _manifest(root) + assert [t["id"] for t in manifest["tracks"]] == [1] + assert manifest["playlists"][0]["tracks"] == [1] + assert not flac_cache(tracks[2]).exists() + + +def test_without_ffmpeg_conversions_can_be_left_out(tmp_path): + tracks = {1: _track(1, _file(tmp_path, "a.mp3"), "MPEG audio file"), + 2: _track(2, _file(tmp_path, "b.aiff"), "AIFF audio file")} + root = _root(tmp_path) + plan = plan_andtunes_sync([_playlist(1, 2)], tracks, root, copy_art=False) + plan.drop_conversions("it needs ffmpeg to convert") + assert not plan.needs_ffmpeg + assert ("Band - Song 2", "it needs ffmpeg to convert") in plan.refused + assert plan.bytes_to_copy == 64 + summary = _run(plan) + assert summary["refused"] == 1 + assert [t["id"] for t in _manifest(root)["tracks"]] == [1] + + +# ---- play counts coming back ---- + +def _device_journal(tmp_path, entries, name="andtunes-1a2b3c4d.json"): + folder = _root(tmp_path) / "plays" + folder.mkdir(parents=True, exist_ok=True) + (folder / name).write_text(json.dumps(entries), encoding="utf-8") + return folder / name + + +def test_merge_totals_takes_the_higher_total_and_newest_stamp(): + mine = {"1": {"plays": 5, "last_played": "2026-09-01T10:00:00"}, + "2": {"plays": 1, "last_played": "2026-09-02T10:00:00"}} + incoming = {"1": {"plays": 3, "last_played": "2026-09-05T10:00:00"}, + "2": {"plays": 1, "last_played": "2026-09-02T10:00:00"}, + "3": {"plays": 2, "last_played": "2026-09-03T10:00:00"}} + merged, changed = merge_totals(mine, incoming) + assert merged["1"] == {"plays": 5, "last_played": "2026-09-05T10:00:00"} + assert merged["2"] == mine["2"] + assert merged["3"] == incoming["3"] + assert changed == 2 + + +def test_bring_back_writes_only_when_something_moved(tmp_path): + data = tmp_path / "data" + _device_journal(tmp_path, {"1": {"plays": 3, "last_played": "2026-09-11T10:00:00"}}) + assert plays.bring_back(_root(tmp_path), data) == 1 + dest = data / "plays" / "andtunes-1a2b3c4d.json" + assert json.loads(dest.read_text())["1"]["plays"] == 3 + + # A device copy that's behind (reinstalled app, restored backup) can't + # pull the count down, and changes nothing on disk. + before = dest.stat().st_mtime_ns + _device_journal(tmp_path, {"1": {"plays": 2, "last_played": "2026-09-10T10:00:00"}}) + assert plays.bring_back(_root(tmp_path), data) == 0 + assert dest.stat().st_mtime_ns == before + assert json.loads(dest.read_text())["1"]["plays"] == 3 + + _device_journal(tmp_path, {"1": {"plays": 7, "last_played": "2026-09-12T10:00:00"}}) + assert plays.bring_back(_root(tmp_path), data) == 1 + assert json.loads(dest.read_text())["1"] == { + "plays": 7, "last_played": "2026-09-12T10:00:00"} + + +def test_bring_back_ignores_what_isnt_an_andtunes_journal(tmp_path): + data = tmp_path / "data" + _device_journal(tmp_path, {"1": {"plays": 1}}, name="someone-else.json") + bad = _device_journal(tmp_path, {}, name="andtunes-half.json") + bad.write_text('{"1": {"pla', encoding="utf-8") # mid-write + _device_journal(tmp_path, {"tracks": {"1": 2}, "9": {"plays": 1}}, + name="andtunes-odd.json") + assert plays.bring_back(_root(tmp_path), data) == 1 + assert sorted(p.name for p in (data / "plays").iterdir()) == ["andtunes-odd.json"] + assert json.loads((data / "plays" / "andtunes-odd.json").read_text()) == { + "9": {"plays": 1}} + + +def test_the_rabbit_is_one_more_machine_to_play_journal(tmp_path): + data = tmp_path / "data" + _device_journal(tmp_path, {"1": {"plays": 3, "last_played": "2026-09-11T10:00:00"}}) + plays.bring_back(_root(tmp_path), data) + library = Library() + library.tracks[1] = Track(track_id=1, name="Song", play_count=10) + PlayJournal(machine="thisbox").load(data, library) + assert library.tracks[1].play_count == 13 + assert library.tracks[1].play_date_utc == "2026-09-11T10:00:00" + + +def test_sync_brings_the_counts_back(tmp_path): + data = tmp_path / "data" + _device_journal(tmp_path, {"1": {"plays": 4, "last_played": "2026-09-11T10:00:00"}}) + tracks = {1: _track(1, _file(tmp_path, "a.mp3"), "MPEG audio file")} + root = _root(tmp_path) + plan = plan_andtunes_sync([_playlist(1)], tracks, root, copy_art=False, + data_dir=data) + summary = _run(plan) + assert summary["plays"] == 1 + # The device keeps its own journal: plays/ is the app's, never pruned. + assert (root / "plays" / "andtunes-1a2b3c4d.json").exists() + assert not any(rel.startswith("plays") for rel in plan.stale) + # Nothing new on the device → nothing to write the second time. + assert _run(plan_andtunes_sync([_playlist(1)], tracks, root, copy_art=False, + data_dir=data))["plays"] == 0 + + +def test_without_a_data_dir_nothing_comes_back(tmp_path): + _device_journal(tmp_path, {"1": {"plays": 4}}) + tracks = {1: _track(1, _file(tmp_path, "a.mp3"), "MPEG audio file")} + summary = _run(plan_andtunes_sync([_playlist(1)], tracks, _root(tmp_path), + copy_art=False)) + assert summary["plays"] == 0 + + +# ---- the old sync ---- + +def test_the_auxio_sync_is_off_the_menu(): + from lintunes.gui import main_window + assert not hasattr(main_window.MainWindow, "_sync_playlist_to_device") + source = shutil.os.path.join(shutil.os.path.dirname(main_window.__file__), + "main_window.py") + with open(source, encoding="utf-8") as f: + assert "Sync Playlist to Rabbit (Auxio)\", \"\"" not in f.read()