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:
@@ -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 @@
|
||||
{"version_name": "0.1.0", "version_code": 1}
|
||||
{"version_name": "0.2.0", "version_code": 2}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ----
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user