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.

Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
This commit is contained in:
2026-09-11 15:20:44 -05:00
parent 077479967a
commit 1280c89015
17 changed files with 864 additions and 127 deletions
+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