v0.17.0: the desktop already knows what the songs are
The first half of andTunes, a music player for the Rabbit R1. Auxio re-reads Android's MediaStore on every launch, which is the "your songs will show up here" hang — so the answer isn't a faster scanner, it's not scanning at all. LinTunes already knows the artist, album and year of every file it just copied, so it writes them into Music/andTunes/library.json and the app reads one file instead of indexing a filesystem. New lintunes/andtunes/: layout.py (a de-duplicated Media/<Artist>/<Album>/ tree, so a song in three playlists is stored once), manifest.py (pure, Qt-free), art.py (one 480 px cover per album via QImage, cached and invalidated by the audio file's mtime — which is exactly what embedding new art moves), and sync.py, a third sibling of plan_export/ExportWorker. Three rules the code depends on: planning never renders art (a mutagen open per album, and planning runs on the GUI thread); the index is written last and after a cancel lists only tracks whose files actually landed; and every delete goes through layout.assert_inside, which is why the old Music/<Playlist>/ folders are structurally unreachable rather than merely un-referenced. Connections gains "Sync to Rabbit" and "Rabbit Sync Settings…" — the ticked set is the device's contents, so unticking is how a playlist comes off, which nothing could do before. The selection rides preferences.json so both machines agree. The old per-playlist sync stays, renamed "(Auxio)": removing it now would leave the device full of files and nothing able to open them until the app exists. The app itself is rounds 48-50; its board is andtunes/TASKS.md, including the measured screen facts (480x640 px at density override 160 — one dp is half its usual physical size on a 2.88" panel). Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.16.0"
|
||||
__version__ = "0.17.0"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""The andTunes side of the house: what the Rabbit R1 app expects to find.
|
||||
|
||||
`device_sync.py` is the transport — how bytes cross the MTP mount. This
|
||||
package is the *format*: where files live under ``Music/andTunes/`` on the
|
||||
device, what the library index looks like, and how album art is rendered for a
|
||||
2.88" screen. Keeping them apart means the app's on-device contract can change
|
||||
without touching the copy loop, and vice versa.
|
||||
"""
|
||||
|
||||
from .layout import (
|
||||
ART_DIR, BUTTONS_DIR, FORMAT_VERSION, MANIFEST_NAME, MEDIA_DIR,
|
||||
PLAYLISTS_DIR, ROOT_NAME, DeviceLayoutError, album_key, art_relpath,
|
||||
assert_inside, media_relpath, playlist_relpath, root_for,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ART_DIR", "BUTTONS_DIR", "FORMAT_VERSION", "MANIFEST_NAME", "MEDIA_DIR",
|
||||
"PLAYLISTS_DIR", "ROOT_NAME", "DeviceLayoutError", "album_key",
|
||||
"art_relpath", "assert_inside", "media_relpath", "playlist_relpath",
|
||||
"root_for",
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Album covers, rendered once per album for a 2.88" screen.
|
||||
|
||||
Art lives in ID3/MP4/FLAC tags, not as files, so somebody has to decode it.
|
||||
Doing that on the Rabbit means a MediaMetadataRetriever open per album on a
|
||||
Helio P35; doing it here means the device reads a plain JPEG that is already
|
||||
the right size. LinTunes has mutagen and Qt's image stack, so it does the work.
|
||||
|
||||
Keyed by album rather than track — one cover for a twelve-song release is one
|
||||
mutagen open instead of twelve, and one file on the device instead of twelve
|
||||
copies of the same picture.
|
||||
|
||||
Rendering uses QImage (not QPixmap), which is safe off the GUI thread — this
|
||||
runs on the sync worker's daemon thread.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice, Qt
|
||||
from PyQt6.QtGui import QImage
|
||||
|
||||
from .. import tagging
|
||||
from .layout import album_key
|
||||
|
||||
# The panel is 480 px wide, so a cover never needs to be bigger than that;
|
||||
# the list thumbnails downsample from the same file on the device.
|
||||
ART_SIZE = 480
|
||||
JPEG_QUALITY = 85
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
base = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
|
||||
return Path(base) / "lintunes" / "andtunes-art"
|
||||
|
||||
|
||||
def render_jpeg(data: bytes) -> bytes | None:
|
||||
"""Scale raw cover bytes down to ART_SIZE and re-encode as JPEG.
|
||||
|
||||
Never upscales: a small embedded cover stays small rather than being
|
||||
blown up into a bigger file that looks no better.
|
||||
"""
|
||||
image = QImage()
|
||||
if not image.loadFromData(QByteArray(data)):
|
||||
return None
|
||||
if image.width() > ART_SIZE or image.height() > ART_SIZE:
|
||||
image = image.scaled(ART_SIZE, ART_SIZE,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
if not image.save(buffer, "JPEG", JPEG_QUALITY):
|
||||
return None
|
||||
return bytes(buffer.data())
|
||||
|
||||
|
||||
def album_art_jpeg(track) -> bytes | None:
|
||||
"""The cover for this track's album, ready to write to the device.
|
||||
|
||||
Cached under $XDG_CACHE_HOME/lintunes/andtunes-art so a re-sync doesn't
|
||||
re-decode every album. The cache entry is stale once the audio file is
|
||||
newer than it — which is exactly right, because embedding new artwork
|
||||
rewrites the file and moves its mtime. Nothing prunes this cache; it is
|
||||
one small JPEG per album and `clear_cache()` empties it.
|
||||
"""
|
||||
if track is None or not track.location:
|
||||
return None
|
||||
src = Path(track.location)
|
||||
try:
|
||||
src_mtime = src.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
cache = _cache_dir() / f"{album_key(track)}.jpg"
|
||||
try:
|
||||
if cache.stat().st_mtime >= src_mtime:
|
||||
return cache.read_bytes()
|
||||
except OSError:
|
||||
pass # not cached yet, or unreadable — fall through and re-render
|
||||
|
||||
raw = tagging.read_embedded_artwork(src)
|
||||
if not raw:
|
||||
return None
|
||||
jpeg = render_jpeg(raw)
|
||||
if not jpeg:
|
||||
return None
|
||||
try:
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cache.with_suffix(".tmp")
|
||||
tmp.write_bytes(jpeg)
|
||||
tmp.replace(cache)
|
||||
except OSError:
|
||||
pass # a cache we can't write is a slow sync, not a failed one
|
||||
return jpeg
|
||||
|
||||
|
||||
def clear_cache():
|
||||
for path in _cache_dir().glob("*.jpg"):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Where everything lives under ``Music/andTunes/`` on the device.
|
||||
|
||||
The old per-playlist sync stores a song once *per playlist*, which is fine for
|
||||
handing Auxio a folder and wrong for an app with Artists/Albums/Songs screens:
|
||||
"all songs it knows about" would have to de-duplicate at read time, and a song
|
||||
in three playlists would cost three copies of the file. So andTunes gets an
|
||||
iTunes-shaped media tree — each track stored exactly once — with playlists
|
||||
reduced to m3u files pointing into it.
|
||||
|
||||
Music/andTunes/
|
||||
library.json the index the app reads at startup
|
||||
Media/<Artist>/<Album>/04 Song.mp3
|
||||
Art/<album key>.jpg one cover per album, not per track
|
||||
Playlists/<Name>.m3u
|
||||
Buttons/*.png the user's own menu artwork
|
||||
plays/ counts the device writes back
|
||||
|
||||
Two of those directories are the *device's*, not ours: `Buttons` holds artwork
|
||||
the user dropped there by hand and `plays` is written by the app. Sync never
|
||||
deletes inside either — see PRESERVED_DIRS.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ..device_sync import sanitize_name
|
||||
|
||||
# Bumped when the on-device layout or manifest changes shape. The app refuses
|
||||
# a format it doesn't know rather than half-reading it.
|
||||
FORMAT_VERSION = 1
|
||||
|
||||
ROOT_NAME = "andTunes" # under <storage>/Music/
|
||||
MEDIA_DIR = "Media"
|
||||
ART_DIR = "Art"
|
||||
PLAYLISTS_DIR = "Playlists"
|
||||
BUTTONS_DIR = "Buttons"
|
||||
PLAYS_DIR = "plays"
|
||||
MANIFEST_NAME = "library.json"
|
||||
|
||||
# Subtrees of the andTunes root that sync reads but never writes or prunes.
|
||||
PRESERVED_DIRS = frozenset({BUTTONS_DIR, PLAYS_DIR})
|
||||
|
||||
UNKNOWN_ARTIST = "Unknown Artist"
|
||||
UNKNOWN_ALBUM = "Unknown Album"
|
||||
|
||||
|
||||
class DeviceLayoutError(Exception):
|
||||
"""A path that would land outside the andTunes root."""
|
||||
|
||||
|
||||
def root_for(device) -> Path:
|
||||
"""The andTunes root on a connected device."""
|
||||
return device.music_dir / ROOT_NAME
|
||||
|
||||
|
||||
def assert_inside(root: Path, path: Path) -> Path:
|
||||
"""Return `path`, or raise if it isn't strictly inside `root`.
|
||||
|
||||
Every delete on the device goes through here. andTunes is the first
|
||||
feature that removes a *directory* from the Rabbit, and the blast radius
|
||||
of getting that wrong is somebody's music — so the containment check is a
|
||||
hard gate rather than a careful caller. It also means the old
|
||||
``Music/<Playlist>/`` folders are structurally untouchable: they aren't
|
||||
under the root, so no code path in this package can reach them.
|
||||
"""
|
||||
root_r = Path(os.path.abspath(root))
|
||||
path_r = Path(os.path.abspath(path))
|
||||
if path_r == root_r or not path_r.is_relative_to(root_r):
|
||||
raise DeviceLayoutError(f"{path} is not inside {root}")
|
||||
return path
|
||||
|
||||
|
||||
def _artist_folder(track) -> str:
|
||||
return sanitize_name(track.album_artist or track.artist or UNKNOWN_ARTIST)
|
||||
|
||||
|
||||
def _album_folder(track) -> str:
|
||||
return sanitize_name(track.album or UNKNOWN_ALBUM)
|
||||
|
||||
|
||||
def media_relpath(track) -> str:
|
||||
"""``Media/<Artist>/<Album>/04 Song.mp3``, relative to the andTunes root.
|
||||
|
||||
Numbering follows the iTunes tree `filename_tags` already knows how to
|
||||
read back: a bare ``NN`` track number, or ``D-NN`` once a release has more
|
||||
than one disc. A track with no number keeps just its title.
|
||||
"""
|
||||
stem = sanitize_name(track.name or Path(track.location).stem
|
||||
or f"Track {track.track_id}")
|
||||
if track.track_number:
|
||||
if track.disc_number and (track.disc_count > 1 or track.disc_number > 1):
|
||||
stem = f"{track.disc_number}-{track.track_number:02d} {stem}"
|
||||
else:
|
||||
stem = f"{track.track_number:02d} {stem}"
|
||||
ext = Path(track.location).suffix
|
||||
return f"{MEDIA_DIR}/{_artist_folder(track)}/{_album_folder(track)}/{stem}{ext}"
|
||||
|
||||
|
||||
def album_key(track) -> str:
|
||||
"""A stable 8-hex id for the album a track belongs to.
|
||||
|
||||
Keyed on album artist + album so every track of a release shares one cover
|
||||
file — 300 albums instead of 3000 covers, and one mutagen open per album
|
||||
at sync time instead of one per song.
|
||||
"""
|
||||
ident = f"{(track.album_artist or track.artist).lower()}\x00{track.album.lower()}"
|
||||
return hashlib.sha1(ident.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def art_relpath(key: str) -> str:
|
||||
return f"{ART_DIR}/{key}.jpg"
|
||||
|
||||
|
||||
def playlist_relpath(name: str) -> str:
|
||||
return f"{PLAYLISTS_DIR}/{sanitize_name(name)}.m3u"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Build ``library.json`` — the whole point of andTunes.
|
||||
|
||||
Every other Android player rebuilds its library by scanning the device:
|
||||
Auxio reads Android's MediaStore on each launch, which is what leaves the R1
|
||||
sitting on "your songs will show up here". But LinTunes *already knows* the
|
||||
answer — it has the artist, album, year and track number for every file it
|
||||
just copied. So it writes them down, and the app opens by parsing one file
|
||||
instead of indexing a filesystem.
|
||||
|
||||
Pure functions only: no Qt, no device, no I/O. The shape is deliberately flat
|
||||
— one track list, playlists as id references. Artists and albums are grouped
|
||||
on the device in a single pass at load, which is cheaper than shipping three
|
||||
redundant lists and parsing them.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .layout import FORMAT_VERSION
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def track_entry(track, media_rel: str, art_rel: str | None = None) -> dict:
|
||||
"""One track's row. Empty and zero fields are omitted.
|
||||
|
||||
Sparse rows the way `Track.to_dict` is sparse: a library of a few thousand
|
||||
tracks is the file the app parses before it can draw anything, so every
|
||||
key that says nothing is latency on the R1's cold start.
|
||||
"""
|
||||
entry = {"id": track.track_id, "title": track.name, "path": media_rel}
|
||||
optional = {
|
||||
"artist": track.artist,
|
||||
"album_artist": track.album_artist,
|
||||
"album": track.album,
|
||||
"year": track.year,
|
||||
"track": track.track_number,
|
||||
"disc": track.disc_number,
|
||||
"secs": round(track.total_time / 1000) if track.total_time else 0,
|
||||
}
|
||||
for key, value in optional.items():
|
||||
if value:
|
||||
entry[key] = value
|
||||
if art_rel:
|
||||
entry["art"] = art_rel
|
||||
return entry
|
||||
|
||||
|
||||
def build_manifest(entries, playlists, *, generator: str,
|
||||
generated: str | None = None) -> dict:
|
||||
"""The complete index.
|
||||
|
||||
`entries` is a list of ``(track, media_rel, art_rel_or_None)`` in no
|
||||
particular order; `playlists` is ``(name, persistent_id, [track_id, …])``
|
||||
with the ids in playing order. A playlist's ids are filtered to tracks
|
||||
that actually made it onto the device, so the app never resolves a
|
||||
dangling reference — the m3u is filtered the same way by the worker.
|
||||
"""
|
||||
rows = [track_entry(track, rel, art) for track, rel, art in entries]
|
||||
known = {row["id"] for row in rows}
|
||||
rows.sort(key=lambda r: r["id"])
|
||||
return {
|
||||
"format": FORMAT_VERSION,
|
||||
"generated": generated or _utc_now(),
|
||||
"generator": generator,
|
||||
"tracks": rows,
|
||||
"playlists": [
|
||||
{"id": pid, "name": name,
|
||||
"tracks": [tid for tid in track_ids if tid in known]}
|
||||
for name, pid, track_ids in playlists
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Sync the whole opted-in library into ``Music/andTunes/`` on the device.
|
||||
|
||||
A sibling of `device_sync.plan_sync` / `DeviceSyncWorker` and of
|
||||
`export/exporter.py`: same shape (a pure planning function, then a worker on a
|
||||
daemon thread reporting through Qt signals), same MTP caveats (never copystat,
|
||||
never trust mtimes — diff by name + size), same "write the index last" rule
|
||||
that keeps an interrupted run from leaving a manifest naming files that aren't
|
||||
there.
|
||||
|
||||
What differs, and why it's a separate worker rather than a flag on the old one:
|
||||
|
||||
* One tree for the whole library instead of one folder per playlist, so paths
|
||||
are nested and directories have to be created and pruned.
|
||||
* An album-art pass, which reads local files the planner never touches.
|
||||
* Several trailing files (every m3u, then `library.json`) instead of one m3u.
|
||||
|
||||
The old per-playlist sync is untouched and still on the menu — it is what
|
||||
feeds Auxio, and it stays until andTunes can actually play a song.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from .. import __version__
|
||||
from ..device_sync import CHUNK, build_m3u, track_display
|
||||
from . import art as art_mod
|
||||
from . import layout
|
||||
from .layout import (
|
||||
MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, assert_inside,
|
||||
media_relpath, playlist_relpath,
|
||||
)
|
||||
from .manifest import build_manifest
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaItem:
|
||||
track_id: int
|
||||
src: Path
|
||||
rel: str # "Media/Artist/Album/04 Song.mp3", posix, under the root
|
||||
size: int
|
||||
track: object = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistEntry:
|
||||
name: str
|
||||
persistent_id: str
|
||||
rel: str # "Playlists/Name.m3u"
|
||||
entries: list = field(default_factory=list) # (media_rel, secs, display)
|
||||
track_ids: list = field(default_factory=list) # playing order, with repeats
|
||||
|
||||
|
||||
@dataclass
|
||||
class AndTunesPlan:
|
||||
device: object
|
||||
root: Path
|
||||
items: list = field(default_factory=list) # every track that should be there
|
||||
copies: list = field(default_factory=list) # the subset needing transfer
|
||||
kept: int = 0
|
||||
stale: list = field(default_factory=list) # rel paths to delete
|
||||
skipped: int = 0
|
||||
playlists: list = field(default_factory=list) # PlaylistEntry
|
||||
albums: dict = field(default_factory=dict) # album key -> representative MediaItem
|
||||
copy_art: bool = True
|
||||
bytes_to_copy: int = 0
|
||||
bytes_freed: int = 0
|
||||
|
||||
|
||||
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:
|
||||
"""Diff the selected playlists against the andTunes tree already there.
|
||||
|
||||
Pure planning — nothing is created, copied, or deleted. Album art is
|
||||
deliberately *not* rendered here: it means a mutagen open per album, and
|
||||
planning runs on the GUI thread. The worker does it, which costs the
|
||||
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)
|
||||
|
||||
# Resolve every unique track across every selected playlist to a real file.
|
||||
items: dict[int, MediaItem] = {}
|
||||
skipped: set[int] = set()
|
||||
for playlist in playlists:
|
||||
for tid in playlist.track_ids:
|
||||
if tid in items or tid in skipped:
|
||||
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)
|
||||
plan.skipped = len(skipped)
|
||||
|
||||
# Same collision rule as the per-playlist sync: every member of a
|
||||
# colliding group gets the [track_id] suffix, so a name never depends on
|
||||
# which playlist happened to be walked first.
|
||||
by_rel: dict[str, list[MediaItem]] = {}
|
||||
for item in items.values():
|
||||
by_rel.setdefault(item.rel, []).append(item)
|
||||
for group in by_rel.values():
|
||||
if len(group) > 1:
|
||||
for item in group:
|
||||
stem, ext = os.path.splitext(item.rel)
|
||||
item.rel = f"{stem} [{item.track_id}]{ext}"
|
||||
|
||||
plan.items = sorted(items.values(), key=lambda i: i.track_id)
|
||||
|
||||
# One representative track per album for the art pass. Lowest track id
|
||||
# wins so the choice doesn't depend on playlist order either.
|
||||
for item in plan.items:
|
||||
plan.albums.setdefault(album_key(item.track), item)
|
||||
|
||||
for playlist in playlists:
|
||||
entry = PlaylistEntry(name=playlist.name,
|
||||
persistent_id=playlist.persistent_id,
|
||||
rel=playlist_relpath(playlist.name))
|
||||
for tid in playlist.track_ids:
|
||||
item = items.get(tid)
|
||||
if item is None:
|
||||
continue # no local file; left out of both m3u and manifest
|
||||
entry.entries.append((
|
||||
_relative_to_playlists(item.rel),
|
||||
round(item.track.total_time / 1000),
|
||||
track_display(item.track)))
|
||||
entry.track_ids.append(tid)
|
||||
plan.playlists.append(entry)
|
||||
|
||||
# Diff against the device by name + size (mtimes lie over MTP).
|
||||
on_device = _scan_device(plan.root)
|
||||
expected = {item.rel for item in plan.items}
|
||||
expected |= {entry.rel for entry in plan.playlists}
|
||||
if plan.copy_art:
|
||||
expected |= {art_relpath(key) for key in plan.albums}
|
||||
expected.add(MANIFEST_NAME)
|
||||
|
||||
for item in plan.items:
|
||||
if on_device.get(item.rel) == item.size:
|
||||
plan.kept += 1
|
||||
else:
|
||||
plan.copies.append(item)
|
||||
plan.bytes_to_copy += item.size
|
||||
for rel, size in sorted(on_device.items()):
|
||||
if rel not in expected:
|
||||
plan.stale.append(rel)
|
||||
plan.bytes_freed += size
|
||||
return plan
|
||||
|
||||
|
||||
def _scan_device(root: Path) -> dict[str, int]:
|
||||
"""Every file under the andTunes root, by root-relative posix path.
|
||||
|
||||
`Buttons/` (the user's own menu artwork) and `plays/` (written by the app)
|
||||
are skipped entirely — sync reads those, it doesn't own them.
|
||||
"""
|
||||
found: dict[str, int] = {}
|
||||
root = Path(root)
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
rel_dir = Path(dirpath).relative_to(root)
|
||||
if rel_dir == Path("."):
|
||||
dirnames[:] = [d for d in dirnames if d not in PRESERVED_DIRS]
|
||||
for name in filenames:
|
||||
rel = (rel_dir / name).as_posix()
|
||||
try:
|
||||
found[rel] = (Path(dirpath) / name).stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
class _Cancelled(Exception):
|
||||
"""Internal: unwinds _run when cancel() was requested."""
|
||||
|
||||
|
||||
class AndTunesSyncWorker(QObject):
|
||||
"""Runs an AndTunesPlan on a daemon thread, reporting through signals.
|
||||
|
||||
Progress is emitted in KiB (a pyqtSignal(int) is a C int — byte counts
|
||||
overflow past 2 GiB) and the label names the phase, because the art and
|
||||
index phases move no audio bytes.
|
||||
|
||||
Cancelling stops at the next chunk boundary and still writes the index:
|
||||
the manifest and m3us are rewritten to list only what is actually on the
|
||||
device, so a cancelled sync leaves andTunes a smaller working library
|
||||
rather than a broken one. Re-syncing picks up the rest.
|
||||
"""
|
||||
|
||||
progress = pyqtSignal(int, int, str) # done_kib, total_kib, phase label
|
||||
finished = pyqtSignal(dict)
|
||||
cancelled = pyqtSignal(dict)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, plan: AndTunesPlan, parent=None):
|
||||
super().__init__(parent)
|
||||
self._plan = plan
|
||||
self._busy = False
|
||||
self._cancel = threading.Event()
|
||||
|
||||
def busy(self) -> bool:
|
||||
return self._busy
|
||||
|
||||
def cancel(self):
|
||||
self._cancel.set()
|
||||
|
||||
def _check_cancel(self):
|
||||
if self._cancel.is_set():
|
||||
raise _Cancelled
|
||||
|
||||
def start(self):
|
||||
if self._busy:
|
||||
return
|
||||
self._busy = True
|
||||
threading.Thread(target=self._run_guarded, daemon=True).start()
|
||||
|
||||
def _run_guarded(self):
|
||||
try:
|
||||
self._run()
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
# ---- the run ----
|
||||
|
||||
def _run(self):
|
||||
plan = self._plan
|
||||
device_name = plan.device.name if plan.device else "device"
|
||||
self._dest = None # in-flight partial, removed on abort
|
||||
self._removed = 0
|
||||
self._present: set[int] = set() # track ids really on the device
|
||||
self._art: dict[str, str] = {} # album key -> art rel path
|
||||
self._total_kib = max(plan.bytes_to_copy // 1024, 1)
|
||||
self._done = 0
|
||||
|
||||
try:
|
||||
self._check_cancel()
|
||||
plan.root.mkdir(parents=True, exist_ok=True)
|
||||
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._delete_stale()
|
||||
self._copy_media()
|
||||
self._sync_art()
|
||||
self._write_index()
|
||||
self._prune_empty_dirs()
|
||||
self.finished.emit({
|
||||
"device": device_name,
|
||||
"playlists": len(plan.playlists),
|
||||
"copied": len(self._present) - plan.kept,
|
||||
"kept": plan.kept,
|
||||
"removed": self._removed,
|
||||
"skipped": plan.skipped,
|
||||
"albums": len(self._art),
|
||||
"tracks": len(self._present),
|
||||
})
|
||||
except _Cancelled:
|
||||
self._drop_partial()
|
||||
# Leave a library that matches the device: the index lists only
|
||||
# tracks whose files are really there. Art isn't re-rendered here
|
||||
# — _write_index reads what is already on the device.
|
||||
try:
|
||||
self._write_index()
|
||||
except OSError:
|
||||
pass # device going away; the size diff self-heals next time
|
||||
self.cancelled.emit({
|
||||
"device": device_name,
|
||||
"copied": len(self._present) - plan.kept,
|
||||
"total": len(plan.copies),
|
||||
"removed": self._removed,
|
||||
})
|
||||
except OSError as e:
|
||||
self._drop_partial()
|
||||
self.failed.emit(
|
||||
f"Sync failed: {e}. Is the {device_name} still connected?")
|
||||
|
||||
def _drop_partial(self):
|
||||
if self._dest is None:
|
||||
return
|
||||
try:
|
||||
self._dest.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._dest = None
|
||||
|
||||
def _emit(self, label: str):
|
||||
self.progress.emit(self._done // 1024, self._total_kib, label)
|
||||
|
||||
def _delete_stale(self):
|
||||
plan = self._plan
|
||||
for rel in plan.stale:
|
||||
self._check_cancel()
|
||||
try:
|
||||
target = assert_inside(plan.root, plan.root / rel)
|
||||
target.unlink()
|
||||
self._removed += 1
|
||||
except layout.DeviceLayoutError:
|
||||
continue # never reachable by construction; refuse anyway
|
||||
except OSError:
|
||||
pass # one stubborn file shouldn't kill the sync
|
||||
|
||||
def _copy_media(self):
|
||||
plan = self._plan
|
||||
for i, item in enumerate(plan.copies, start=1):
|
||||
self._check_cancel()
|
||||
label = f"{i}/{len(plan.copies)} {Path(item.rel).stem}"
|
||||
# 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")
|
||||
except FileNotFoundError:
|
||||
self._done += item.size
|
||||
self._emit(label)
|
||||
continue
|
||||
dest = plan.root / item.rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._dest = dest
|
||||
# Manual chunked copy: byte-accurate progress, and no copystat
|
||||
# (gvfs-MTP rejects it).
|
||||
with fsrc, open(dest, "wb") as fdst:
|
||||
while True:
|
||||
chunk = fsrc.read(CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
fdst.write(chunk)
|
||||
self._done += len(chunk)
|
||||
self._emit(label)
|
||||
self._check_cancel()
|
||||
self._dest = None
|
||||
self._present.add(item.track_id)
|
||||
|
||||
def _sync_art(self):
|
||||
"""One cover per album, written only when it differs by size."""
|
||||
plan = self._plan
|
||||
if not plan.copy_art:
|
||||
return
|
||||
total = len(plan.albums)
|
||||
for i, (key, item) in enumerate(sorted(plan.albums.items()), start=1):
|
||||
self._check_cancel()
|
||||
if item.track_id not in self._present:
|
||||
continue
|
||||
self._emit(f"Album art {i}/{total}")
|
||||
rel = art_relpath(key)
|
||||
dest = plan.root / rel
|
||||
jpeg = art_mod.album_art_jpeg(item.track)
|
||||
if not jpeg:
|
||||
# The album lost its art (or never had any): don't leave a
|
||||
# cover behind that the manifest no longer points at.
|
||||
try:
|
||||
assert_inside(plan.root, dest).unlink()
|
||||
except (layout.DeviceLayoutError, OSError):
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
if dest.stat().st_size != len(jpeg):
|
||||
raise FileNotFoundError
|
||||
except OSError:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(jpeg)
|
||||
self._art[key] = rel
|
||||
|
||||
def _write_index(self):
|
||||
"""The m3us and then library.json — always last, always consistent.
|
||||
|
||||
Everything here describes what is on the device *now*, so an
|
||||
interrupted sync leaves the app a shorter library rather than one
|
||||
with holes in it.
|
||||
"""
|
||||
plan = self._plan
|
||||
for entry in plan.playlists:
|
||||
rows = [row for row, tid in zip(entry.entries, entry.track_ids)
|
||||
if tid in self._present]
|
||||
dest = plan.root / entry.rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(build_m3u(rows), encoding="utf-8")
|
||||
|
||||
# Which covers are on the device right now, rather than which ones
|
||||
# this run wrote — so a cancelled sync still points at art an earlier
|
||||
# sync left behind.
|
||||
have_art = {key for key in plan.albums
|
||||
if (plan.root / art_relpath(key)).exists()}
|
||||
entries = [(item.track, item.rel,
|
||||
art_relpath(k) if (k := album_key(item.track)) in have_art
|
||||
else None)
|
||||
for item in plan.items if item.track_id in self._present]
|
||||
playlists = [(e.name, e.persistent_id, e.track_ids)
|
||||
for e in plan.playlists]
|
||||
payload = build_manifest(entries, playlists,
|
||||
generator=f"lintunes {__version__}")
|
||||
(plan.root / MANIFEST_NAME).write_text(
|
||||
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||
encoding="utf-8")
|
||||
|
||||
def _prune_empty_dirs(self):
|
||||
"""Drop folders an artist or album left behind when it went away.
|
||||
|
||||
Bottom-up so a whole emptied branch goes in one pass, guarded like
|
||||
every other delete, and never inside Buttons/ or plays/ — those are
|
||||
the device's, and an empty one still belongs to it.
|
||||
"""
|
||||
plan = self._plan
|
||||
for dirpath, _dirnames, _filenames in os.walk(plan.root, topdown=False):
|
||||
current = Path(dirpath)
|
||||
if current == plan.root:
|
||||
continue
|
||||
rel = current.relative_to(plan.root)
|
||||
if rel.parts[0] in PRESERVED_DIRS:
|
||||
continue
|
||||
# Ask the filesystem, not os.walk: walking bottom-up means a
|
||||
# parent's listing was taken before its children were removed, so
|
||||
# an emptied branch would only lose its deepest folder per sync.
|
||||
try:
|
||||
if any(current.iterdir()):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
assert_inside(plan.root, current).rmdir()
|
||||
except (layout.DeviceLayoutError, OSError):
|
||||
pass
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Pick which playlists live on the Rabbit.
|
||||
|
||||
The old menu synced whichever playlist happened to be open, which made
|
||||
"remove this from the device" an operation with no button: nothing ever
|
||||
deleted a playlist folder. Here the ticked set *is* the device's contents —
|
||||
unticking is how a playlist comes off, and the next sync does it.
|
||||
|
||||
The selection is stored in preferences.json, so it rides the same Syncthing
|
||||
share as the library and both machines agree on what belongs on the device.
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..models.playlist import PlaylistType
|
||||
|
||||
|
||||
def syncable_playlists(library) -> list:
|
||||
"""Playlists a user can put on a device, in name order.
|
||||
|
||||
Folders hold no tracks and system playlists aren't the user's to sync;
|
||||
a smart playlist is fine — by the time we see it, its membership has
|
||||
already been computed.
|
||||
"""
|
||||
return sorted(
|
||||
(p for p in library.playlists.values()
|
||||
if p.playlist_type != PlaylistType.FOLDER and not p.is_system),
|
||||
key=lambda p: p.name.casefold())
|
||||
|
||||
|
||||
class DeviceSyncSettingsDialog(QDialog):
|
||||
def __init__(self, prefs, library, parent=None, device_name="Rabbit"):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"{device_name} Sync Settings")
|
||||
self._prefs = prefs
|
||||
self._playlists = syncable_playlists(library)
|
||||
|
||||
selected = set(prefs.device_sync.get("playlist_ids", []))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(
|
||||
f"Playlists to keep on the {device_name}. Unticking one removes "
|
||||
"it from the device on the next sync."))
|
||||
|
||||
# 500 playlists is a normal library here, so the list is unusable
|
||||
# without a filter. Hiding rows doesn't renumber them, so the
|
||||
# row-to-playlist mapping in selected_ids() stays valid.
|
||||
self._filter = QLineEdit(self)
|
||||
self._filter.setPlaceholderText("Filter playlists…")
|
||||
self._filter.setClearButtonEnabled(True)
|
||||
self._filter.textChanged.connect(self._apply_filter)
|
||||
layout.addWidget(self._filter)
|
||||
|
||||
self._list = QListWidget(self)
|
||||
for playlist in self._playlists:
|
||||
item = QListWidgetItem(
|
||||
f"{playlist.name} ({len(playlist.track_ids)})", self._list)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(
|
||||
Qt.CheckState.Checked
|
||||
if playlist.persistent_id in selected
|
||||
else Qt.CheckState.Unchecked)
|
||||
layout.addWidget(self._list)
|
||||
|
||||
self._count = QLabel(self)
|
||||
layout.addWidget(self._count)
|
||||
self._list.itemChanged.connect(lambda _item: self._refresh_count())
|
||||
self._refresh_count()
|
||||
|
||||
self._art = QCheckBox("Copy album art to the device", self)
|
||||
self._art.setChecked(bool(prefs.device_sync.get("copy_art", True)))
|
||||
self._art.setToolTip(
|
||||
"Covers are read from the tags and scaled to 480 px — one file "
|
||||
"per album, not per song.")
|
||||
layout.addWidget(self._art)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel, parent=self)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _apply_filter(self, text: str):
|
||||
needle = text.strip().casefold()
|
||||
for row, playlist in enumerate(self._playlists):
|
||||
self._list.item(row).setHidden(
|
||||
bool(needle) and needle not in playlist.name.casefold())
|
||||
|
||||
def _refresh_count(self):
|
||||
n = len(self.selected_ids())
|
||||
self._count.setText(
|
||||
f"{n} playlist{'' if n == 1 else 's'} selected"
|
||||
if n else "No playlists selected — nothing will be synced.")
|
||||
|
||||
def selected_ids(self) -> list:
|
||||
return [playlist.persistent_id
|
||||
for row, playlist in enumerate(self._playlists)
|
||||
if self._list.item(row).checkState() == Qt.CheckState.Checked]
|
||||
|
||||
def accept(self):
|
||||
self._prefs.update_device_sync(playlist_ids=self.selected_ids(),
|
||||
copy_art=self._art.isChecked())
|
||||
super().accept()
|
||||
+119
-9
@@ -10,6 +10,8 @@ from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
|
||||
from lintunes import device_sync, mpris, music_folder, tagging, theme
|
||||
from lintunes.andtunes import layout as andtunes_layout
|
||||
from lintunes.andtunes import sync as andtunes_sync
|
||||
from lintunes.art_search import AlbumArtFetcher
|
||||
from lintunes.cast.controller import CastController
|
||||
from lintunes.eventlog import log_control
|
||||
@@ -20,6 +22,7 @@ from lintunes.player import Player
|
||||
from lintunes.importers import file_importer
|
||||
from lintunes.gui.album_art_dialog import AlbumArtDialog
|
||||
from lintunes.gui.cast_dialog import ChromecastDialog
|
||||
from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog
|
||||
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
|
||||
from lintunes.gui.identify_dialog import IdentifyDialog
|
||||
from lintunes.gui.sidebar import SidebarPanel
|
||||
@@ -157,6 +160,7 @@ class MainWindow(QMainWindow):
|
||||
self._sync_progress.hide()
|
||||
self.statusBar().addPermanentWidget(self._sync_progress)
|
||||
self._sync_worker = None
|
||||
self._andtunes_worker = None
|
||||
self._sync_device_name = ""
|
||||
# Export shares the status-bar progress widgets with device sync —
|
||||
# only one of the two may run at a time (each guards on the other).
|
||||
@@ -333,8 +337,18 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Mnemonic on the "n": Alt+C already belongs to &Controls.
|
||||
connections_menu = bar.addMenu("Co&nnections")
|
||||
self._andtunes_action = self._add_action(
|
||||
connections_menu, "Sync to Rabbit", "", self._sync_to_andtunes)
|
||||
self._andtunes_action.setEnabled(False)
|
||||
self._add_action(connections_menu, "Rabbit Sync Settings…", "",
|
||||
self._show_device_sync_settings)
|
||||
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", "",
|
||||
connections_menu, "Sync Playlist to Rabbit (Auxio)", "",
|
||||
self._sync_playlist_to_device)
|
||||
self._sync_action.setEnabled(False)
|
||||
connections_menu.addSeparator()
|
||||
@@ -371,9 +385,12 @@ class MainWindow(QMainWindow):
|
||||
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_sync.find_device() is not None)
|
||||
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)
|
||||
|
||||
if self._cast.is_connected():
|
||||
self._cast_action.setText(
|
||||
@@ -470,7 +487,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _busy_worker(self):
|
||||
"""The transfer currently owning the status-bar progress widgets."""
|
||||
for worker in (self._sync_worker, self._export_worker):
|
||||
for worker in (self._sync_worker, self._andtunes_worker,
|
||||
self._export_worker):
|
||||
if worker is not None and worker.busy():
|
||||
return worker
|
||||
return None
|
||||
@@ -479,7 +497,10 @@ class MainWindow(QMainWindow):
|
||||
if self._export_worker is not None and self._export_worker.busy():
|
||||
self._confirm_cancel_export()
|
||||
return
|
||||
if self._sync_worker is None or not self._sync_worker.busy():
|
||||
worker = self._sync_worker
|
||||
if self._andtunes_worker is not None and self._andtunes_worker.busy():
|
||||
worker = self._andtunes_worker
|
||||
if worker is None or not worker.busy():
|
||||
return
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle("Cancel transfer")
|
||||
@@ -493,9 +514,8 @@ class MainWindow(QMainWindow):
|
||||
box.exec()
|
||||
# The transfer keeps running while the dialog is up — it may well
|
||||
# have finished by now, in which case there is nothing to cancel.
|
||||
if (box.clickedButton() is cancel_button
|
||||
and self._sync_worker is not None):
|
||||
self._sync_worker.cancel()
|
||||
if box.clickedButton() is cancel_button and worker is not None:
|
||||
worker.cancel()
|
||||
|
||||
def _on_sync_cancelled(self, summary):
|
||||
self._sync_inhibitor.release()
|
||||
@@ -530,6 +550,95 @@ class MainWindow(QMainWindow):
|
||||
self._hide_sync_widgets()
|
||||
QMessageBox.warning(self, "Sync failed", message)
|
||||
|
||||
# ---- connections: andTunes (whole-library sync) ----
|
||||
|
||||
def _show_device_sync_settings(self):
|
||||
device = device_sync.find_device()
|
||||
dialog = DeviceSyncSettingsDialog(
|
||||
self._prefs, self._manager.library, self,
|
||||
device_name=device.name if device else "Rabbit")
|
||||
dialog.exec()
|
||||
|
||||
def _selected_sync_playlists(self) -> list:
|
||||
"""The ticked playlists, in the order they were ticked in settings.
|
||||
|
||||
Ids that no longer resolve are skipped rather than cleaned up: a
|
||||
playlist can be missing because the other machine hasn't synced yet,
|
||||
and silently forgetting the choice would be worse than ignoring it.
|
||||
"""
|
||||
library = self._manager.library
|
||||
return [library.playlists[pid]
|
||||
for pid in self._prefs.device_sync.get("playlist_ids", [])
|
||||
if pid in library.playlists]
|
||||
|
||||
def _sync_to_andtunes(self):
|
||||
# Everything may have changed since the menu opened — re-verify.
|
||||
if self._busy_worker() is not None:
|
||||
return
|
||||
device = device_sync.find_device()
|
||||
if device is None:
|
||||
return
|
||||
playlists = self._selected_sync_playlists()
|
||||
if not playlists:
|
||||
QMessageBox.information(
|
||||
self, "Nothing to sync",
|
||||
"No playlists are set to sync yet.\n\n"
|
||||
"Pick them in Connections → Rabbit Sync Settings…")
|
||||
return
|
||||
|
||||
root = andtunes_layout.root_for(device)
|
||||
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)))
|
||||
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 {len(playlists)} playlist(s) needs "
|
||||
f"{device_sync.format_bytes(needed - plan.bytes_freed)}, but "
|
||||
f"the {device.name} only has "
|
||||
f"{device_sync.format_bytes(free)} free.")
|
||||
return
|
||||
|
||||
self._andtunes_worker = andtunes_sync.AndTunesSyncWorker(plan, self)
|
||||
self._andtunes_worker.progress.connect(self._on_andtunes_progress)
|
||||
self._andtunes_worker.finished.connect(self._on_andtunes_finished)
|
||||
self._andtunes_worker.cancelled.connect(self._on_sync_cancelled)
|
||||
self._andtunes_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._andtunes_worker.start()
|
||||
|
||||
def _on_andtunes_progress(self, done_kib, total_kib, label):
|
||||
# Unlike the per-playlist sync this run has phases that move no audio
|
||||
# bytes (album art, then the index), so the label carries the story.
|
||||
self._sync_progress.setRange(0, total_kib)
|
||||
self._sync_progress.setValue(done_kib)
|
||||
self._sync_label.setText(f"{self._sync_device_name} · {label}")
|
||||
|
||||
def _on_andtunes_finished(self, summary):
|
||||
self._sync_inhibitor.release()
|
||||
self._hide_sync_widgets()
|
||||
msg = (f"Synced {summary['playlists']} playlist(s) to the "
|
||||
f"{summary['device']}: {summary['tracks']} tracks "
|
||||
f"({summary['copied']} copied, {summary['kept']} up to date), "
|
||||
f"{summary['albums']} covers, {summary['removed']} removed")
|
||||
if summary["skipped"]:
|
||||
msg += f", {summary['skipped']} skipped (no local file)"
|
||||
self.statusBar().showMessage(msg, 10000)
|
||||
|
||||
# ---- export ----
|
||||
|
||||
def _refresh_export_action(self):
|
||||
@@ -1309,7 +1418,8 @@ class MainWindow(QMainWindow):
|
||||
def closeEvent(self, event):
|
||||
# Quitting mid-transfer abandons a partial file on the device (it
|
||||
# self-heals next sync, but silently) — make it a deliberate choice.
|
||||
if self._sync_worker is not None and self._sync_worker.busy():
|
||||
if self._busy_worker() is not None and self._busy_worker() is not (
|
||||
self._export_worker):
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle("Sync in progress")
|
||||
box.setText(
|
||||
|
||||
@@ -33,6 +33,14 @@ DEFAULTS = {
|
||||
"acoustid": {
|
||||
"api_key": "",
|
||||
},
|
||||
# Which playlists belong on the andTunes device, by persistent_id. Kept
|
||||
# here rather than on the Playlist so it stays out of the conflict-merge
|
||||
# machinery — and because riding the syncthing share is the point: both
|
||||
# machines should agree on what the Rabbit is carrying.
|
||||
"device_sync": {
|
||||
"playlist_ids": [],
|
||||
"copy_art": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +97,15 @@ class Preferences(QObject):
|
||||
def acoustid(self) -> dict:
|
||||
return self._data["acoustid"]
|
||||
|
||||
def update_device_sync(self, **fields):
|
||||
self._data["device_sync"].update(fields)
|
||||
self.save()
|
||||
self.changed.emit()
|
||||
|
||||
@property
|
||||
def device_sync(self) -> dict:
|
||||
return self._data["device_sync"]
|
||||
|
||||
def save(self):
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(".json.tmp")
|
||||
|
||||
Reference in New Issue
Block a user