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 82b7954..5293081 100644 Binary files a/lintunes/android/andTunes.apk and b/lintunes/android/andTunes.apk differ 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()