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:
2026-09-11 15:20:44 -05:00
co-authored by Claude Opus 5
parent 9483d8951b
commit 81b7f6767c
17 changed files with 864 additions and 127 deletions
+23 -5
View File
@@ -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/<Playlist Name>/` 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/<track id>.flac` and size-diffed
from there on, so a converted song isn't re-copied every sync. And the app
keeps `plays/andtunes-<install id>.json` in the play-journal shape, counted
on a natural finish like `Player`; `plays.bring_back` folds it into
`<data_dir>/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://<device>/`.
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
right-click menu). A sibling of `device_sync`, reusing its filename helpers
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.19.0"
__version__ = "0.20.0"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version_name": "0.1.0", "version_code": 1}
{"version_name": "0.2.0", "version_code": 2}
+73
View File
@@ -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-<install id>.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
``<data_dir>/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 ``<data_dir>/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
+140 -8
View File
@@ -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)
+35 -84
View File
@@ -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 ----
+31
View File
@@ -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
+36 -12
View File
@@ -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")
+49
View File
@@ -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-<install id>.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 `<data_dir>/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/<track id>.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.
+3 -1
View File
@@ -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
+285
View File
@@ -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()