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). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
This commit is contained in:
@@ -265,7 +265,42 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
copystat and never trust mtimes (diff by name+size). Sync owns exactly
|
||||
`Music/<Playlist Name>/` on the device (creates/overwrites/deletes there,
|
||||
plus an Auxio-importable `.m3u`); it never deletes outside that folder and
|
||||
only ever *reads* local library files.
|
||||
only ever *reads* local library files. Since Round 47 this is the *older*
|
||||
of two syncs — kept on the menu as "Sync Playlist to Rabbit (Auxio)" until
|
||||
the andTunes app can play a song — and its `sanitize_name` / `track_display`
|
||||
/ `track_filename` / `build_m3u` / `CHUNK` are imported by both
|
||||
`export/exporter.py` and `andtunes/`, so their signatures are load-bearing.
|
||||
|
||||
- **`lintunes/andtunes/`** — the desktop half of andTunes, a music player for
|
||||
the Rabbit R1 (Round 47; the app itself lives in `andtunes/` at the repo
|
||||
root, board in `andtunes/TASKS.md`). The premise: **the app never scans
|
||||
anything.** Auxio re-reads Android's MediaStore on every launch, which is
|
||||
the "your songs will show up here" hang — but 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 parses one file instead of
|
||||
indexing a filesystem. `layout.py` is the on-device shape (`Media/<Artist>/
|
||||
<Album>/04 Song.mp3` — one copy per song however many playlists hold it —
|
||||
plus `Art/`, `Playlists/`, `library.json`); `manifest.py` is pure and
|
||||
Qt-free; `art.py` renders one 480 px JPEG **per album** (not per track) via
|
||||
QImage — safe off the GUI thread, unlike QPixmap — cached under
|
||||
`$XDG_CACHE_HOME/lintunes/andtunes-art` and invalidated by the audio file
|
||||
being newer, which is exactly what embedding new art does. `sync.py` is a
|
||||
third sibling of `plan_export`/`ExportWorker`: pure planner, then a daemon
|
||||
thread with KiB progress. Three rules that bite: **planning must not render
|
||||
art** (a mutagen open per album, and planning runs on the GUI thread — the
|
||||
worker does it, and those bytes are absent from the progress total); the
|
||||
index (m3us then `library.json`) is written **last** and after a cancel is
|
||||
rewritten to list only tracks whose files actually landed, so the app never
|
||||
opens a manifest with holes in it; and every delete goes through
|
||||
`layout.assert_inside`, which refuses anything not strictly inside the
|
||||
andTunes root. That guard is why the old `Music/<Playlist>/` folders are
|
||||
structurally unreachable rather than merely un-referenced, and `Buttons/`
|
||||
(the user's own menu artwork) and `plays/` (written by the app) are skipped
|
||||
by the device scan entirely — sync reads those, it doesn't own them.
|
||||
Which playlists go on the device lives in `preferences.json` under
|
||||
`device_sync` (so it rides the Syncthing share and both machines agree);
|
||||
keeping it off the `Playlist` keeps it out of the conflict-merge machinery,
|
||||
and **unticking is the only way a playlist comes off the device**.
|
||||
|
||||
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
|
||||
right-click menu). A sibling of `device_sync`, reusing its filename helpers
|
||||
@@ -323,6 +358,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
|
||||
## Conventions & gotchas
|
||||
|
||||
- **The Rabbit R1's screen is not what the internet says.** Measured:
|
||||
**480 × 640 px**, physical density 320, **override density 160** — so an app
|
||||
sees a 480 × 640 *dp* canvas on a 2.88" panel (~278 real ppi). One dp is
|
||||
about half its usual physical size, so andTunes doubles every stock value
|
||||
(rows ≥ 88 dp, text 28–32 sp). Don't "fix" it by changing the device
|
||||
density; trav has it where he wants it. Android 13 / API 33, arm64-v8a.
|
||||
The scroll wheel emits KEY_UP/KEY_DOWN (`KEYCODE_DPAD_UP`/`DOWN`).
|
||||
|
||||
- **Tests are organized as `tests/test_roundN.py`** — each development round adds
|
||||
a new `test_roundN.py` alongside the topical files (`test_models.py`,
|
||||
`test_itunes_importer.py`, etc.). New feature work follows the same pattern.
|
||||
|
||||
@@ -9,6 +9,34 @@ When a round closes, move its finished items to `tasks-done.md`.
|
||||
|
||||
- [ ] archive the done tasks in here to another file, this is crufty....
|
||||
|
||||
## Round 47 (2026-09-06) — LinTunes writes the device's library: done, see tasks-done.md
|
||||
|
||||
The desktop half of andTunes: a de-duplicated `Music/andTunes/` tree, a
|
||||
`library.json` the R1 app reads instead of scanning, per-album cover export,
|
||||
and a ticked-playlist selection that makes unticking the way to remove one.
|
||||
The app itself is rounds 48–50 — its board is `andtunes/TASKS.md`. Left on
|
||||
the table:
|
||||
|
||||
- [ ] **Ship default button images.** The spec wants the six menu tiles to be
|
||||
replaceable artwork "stored in an easy spot". The device side is
|
||||
designed (`Buttons/<name>.png`, app falls back to a bundled drawable)
|
||||
but LinTunes doesn't write the defaults yet — and when it does it must
|
||||
only fill in files that are *missing*, never overwrite trav's own.
|
||||
- [ ] **A format gate for what Android can't decode.** Sync copies every
|
||||
suffix verbatim today. AIFF and protected AAC will simply fail to play
|
||||
on the device. Wants the `export/web_support.py` shape: deny-by-default
|
||||
on the suffix with the iTunes `kind` breaking the `.m4a` tie, and
|
||||
FLAC-only conversion so it can never cost a bit.
|
||||
- [ ] **Clean up the old `Music/<Playlist>/` folders.** They're outside the
|
||||
andTunes root and the containment guard makes them unreachable on
|
||||
purpose, so this has to be a separate, explicitly confirmed action —
|
||||
and only once andTunes has replaced Auxio.
|
||||
- [ ] **Art export is the slow part of a sync.** One mutagen open + JPEG
|
||||
encode per album, on the worker thread, uncounted in the progress
|
||||
bar's byte total (the bar sits full while the label says "Album art").
|
||||
Fine for a few hundred albums; if trav syncs a thousand it wants a
|
||||
second progress phase with its own range.
|
||||
|
||||
## Round 46 (2026-09-02) — The file already told you: done, see tasks-done.md
|
||||
|
||||
Filename-derived proposals when no database knows the song, plus using the
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
ok, so right now we have the 'sync playlist to rabbit' menu. I have been using an android app called 'auxio' but everytime I reopen it it seems to reindex all the audio, takes a long time and doesn't always work? It sits on "your songs will show up here" and then they don't. First of all, can you do some solid research to see if there is an existing app that would work well to deliver this spec? We don't need to reinvent the wheel. If we ARE going to reinvent the wheel let's call this app 'andtunes' (get it?).
|
||||
|
||||
My needs are basic. I want to be able to sync playlists like we have now. I want the android app to open really, really quickly.
|
||||
|
||||
It opens to a main menu:
|
||||
- playlists
|
||||
- artists
|
||||
- albums
|
||||
- songs
|
||||
- search
|
||||
- now playing (if nothing is playing it should instead say "shuffle all" which shuffles all songs it knows about)
|
||||
|
||||
playlists button goes to a list of playlists, artists button goes to a list of all artists. andTunes only needs to sync with lintunes so maybe we can rebuild the andTunes music database when we sync to the phone. The 'sync playlist to rabbit' button can instead be 'sync playlist to andtunes'. Are we able to install the andTunes app right from linTunes? If so that could be another menu item from connections menu, 'install andtunes'. To think of it, we will probably want a way to choose which ones get synced. So instead of "sync playlist to rabbit" there need to be 2 menus, 'sync rabbit' which syncs all the playlists we've decided we want to sync. And another menu like 'rabbit sync settings' where we can check/uncheck playlists that we want synced. This is the method for removing synced playlists.
|
||||
|
||||
We only need compatibility with the rabbit r1! This is important. We should optimize UI for this device's small screen. The screen is really dim so we should maximize contrast and use black and white. The main screen buttons (playlists, artists, etc) should be buttons. Each one should be a fraction of the size of the screen. I'll need you to make standard images stored in an easy spot that I could then replace with my own button images if I want.
|
||||
|
||||
At the top of the songs page it should show "shuffle all" and then all songs sorted alphabetically.
|
||||
|
||||
tapping on an artist in the artist view brings us to albums from that artist but also an option for "all songs". "all songs" shows an alphabetical list of all songs from that artist with a 'shuffle all' (for that artist) button at the top. On the albums list for an artist, next to each album should be a little icon of the album art.
|
||||
|
||||
If something is playing, or playing but paused, there should be a button at the bottom of the screen that says "now playing" that the user can tap to go to the now-playing screen.
|
||||
|
||||
The now-playing screen should have a white background, show the album art square at top, below that a slim scrubber for current place in song, and transport buttons (back, play/pause, next). Below that we can have a shuffle button as well as a 'back' button that brings us to the last screen we were at (gotta keep track of that somehow?).
|
||||
|
||||
are there any basic features of a phone music app you can think of that I'm missing? This app can be the BARE minimum to start, I'll add features later as I find that I need them. But if you think of some blaring obvious stuff that I forgot feel free to suggest, thanks.
|
||||
|
||||
sorry this spec is a HUGE jumble. Probably the first task for you, after researching if this exists already, is to make a separate andTunes task list to build all this. Totally ok if we can't build the whole thing in one session! Feel free to break up the work however makes sense.
|
||||
@@ -0,0 +1,86 @@
|
||||
# andTunes — task board
|
||||
|
||||
A music player for the Rabbit R1 that never scans anything: LinTunes writes
|
||||
`Music/andTunes/library.json` during sync and the app just reads it. See
|
||||
`../andTunes spec.txt` for the original brief.
|
||||
|
||||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
|
||||
## Device facts (measured 2026-09-06, `adb shell wm size` etc.)
|
||||
|
||||
- Panel **480 × 640 px**, physical density 320, **override density 160** — so
|
||||
the app sees a 480 × 640 **dp** canvas on a 2.88" screen (~278 real ppi).
|
||||
- **One dp is about half its usual physical size here.** Double every stock
|
||||
value: body text 28–32 sp, list rows ≥ 88 dp, menu tiles ~213 dp. A 48 dp
|
||||
row would be 4.4 mm tall. Do not "fix" this by changing the device density —
|
||||
trav likes it where it is.
|
||||
- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free.
|
||||
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**
|
||||
(`KEYCODE_DPAD_UP` / `DPAD_DOWN`). Side/PTT button sits on `mtk-kpd` with
|
||||
KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession
|
||||
gets media keys for free.
|
||||
|
||||
## Round 48 — plays a song (v0.1.0)
|
||||
|
||||
- [ ] Toolchain: `sudo dnf install java-21-openjdk-devel`, Android
|
||||
commandline-tools + platform 33 + build-tools into `~/Android/Sdk`.
|
||||
(Java 25 is installed but AGP won't run on it.) Gradle via the wrapper.
|
||||
- [ ] Gradle project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33.
|
||||
**No Compose, no androidx, no Media3** — framework `Activity`,
|
||||
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`,
|
||||
R8 minify. Cold start is the whole feature; there is nothing to
|
||||
initialize if nothing is linked in.
|
||||
- [ ] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to
|
||||
the toggle. `READ_MEDIA_AUDIO` grants the audio files but **not**
|
||||
`library.json`, which isn't media — that's the whole reason.
|
||||
- [ ] Library loader: parse `library.json` off the main thread into a
|
||||
singleton that survives between activities. Refuse an unknown `format`
|
||||
with a readable message rather than half-reading it.
|
||||
- [ ] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads
|
||||
"Now Playing" or "Shuffle All" depending on the service.
|
||||
- [ ] `ListActivity` in songs mode + "Shuffle all" header row.
|
||||
- [ ] `PlaybackService` (foreground) + `NowPlayingActivity`: white
|
||||
background, square art, slim scrubber, ◀ ⏯ ▶, shuffle. **No back
|
||||
button** — Android's own is always on screen.
|
||||
- [ ] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification.
|
||||
- [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one.
|
||||
- [ ] Verify with `adb shell am start -W` — target: menu visible < 400 ms.
|
||||
|
||||
## Round 49 — the rest of the screens
|
||||
|
||||
- [ ] Playlists, artists (→ albums + "All songs" + shuffle for that artist),
|
||||
albums, search.
|
||||
- [ ] Album art thumbs in album rows, full art on now playing.
|
||||
- [ ] The "Now playing" bar at the bottom of every list screen.
|
||||
- [ ] Scroll wheel → list scrolling, and volume on the now-playing screen.
|
||||
- [ ] Replaceable button images: load `Buttons/<name>.png` from the device
|
||||
folder when present, else the bundled drawable. LinTunes ships defaults
|
||||
into `Buttons/` only when the file is missing, so the user's own
|
||||
artwork is never overwritten.
|
||||
- [ ] Missing file (synced then deleted) skips with a toast, doesn't crash
|
||||
the queue.
|
||||
- [ ] Resume last track + position on launch.
|
||||
|
||||
## Round 50 — install from LinTunes, counts coming back
|
||||
|
||||
- [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")`
|
||||
(the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then
|
||||
`adb install -r`; fall back to copying the APK over MTP for a manual
|
||||
tap. APK committed at `lintunes/android/andTunes.apk` with
|
||||
`package_data`, read via `importlib.resources` like `export/templates`.
|
||||
- [ ] Format gate shaped like `export/web_support.py`: deny-by-default on
|
||||
suffix, AIFF and protected AAC refused, anything else unplayable
|
||||
converted to **FLAC** (never a lossy re-encode).
|
||||
- [ ] Play counts back: the app writes `plays/andtunes-<installid>.json` in
|
||||
the per-machine-totals shape of `storage/play_journal.py`; sync copies
|
||||
it back and folds it in. The R1 becomes one more machine in the Round 38
|
||||
model — no new merge rules.
|
||||
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` once andTunes covers it, and
|
||||
offer a one-off cleanup of the old `Music/<Playlist>/` folders.
|
||||
|
||||
## Parked
|
||||
|
||||
- Gapless, crossfade, EQ, ratings, folder browsing, sleep timer, Android Auto.
|
||||
- Deleting the old per-playlist folders automatically. They're outside the
|
||||
andTunes root and the containment guard makes them unreachable on purpose;
|
||||
removing them should be an explicit, separately-confirmed action.
|
||||
@@ -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")
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
## Done
|
||||
|
||||
### Round 47 (2026-09-06) — LinTunes writes the device's library (v0.17.0)
|
||||
|
||||
The first half of andTunes, a music player for the Rabbit R1 that never scans
|
||||
anything. Auxio re-reads Android's MediaStore on every launch, which is what
|
||||
leaves the Rabbit sitting on "your songs will show up here" — so the answer
|
||||
isn't a faster scanner, it's not scanning: LinTunes already knows the artist,
|
||||
album and year of every file it just copied, so it writes them down and the
|
||||
app reads one JSON file. This round builds the desktop side; the app itself is
|
||||
rounds 48–50 (`andtunes/TASKS.md`).
|
||||
|
||||
- [x] **A de-duplicated tree instead of a folder per playlist.**
|
||||
`lintunes/andtunes/layout.py` lays out `Music/andTunes/` — `Media/
|
||||
<Artist>/<Album>/04 Song.mp3` (the iTunes shape `filename_tags` already
|
||||
reads back, with a `2-04` prefix once a release has more than one disc),
|
||||
`Art/<key>.jpg`, `Playlists/<Name>.m3u`, `library.json`. A song in three
|
||||
playlists is stored once, which the Artists/Albums/Songs screens need
|
||||
anyway and which saves the space three copies cost.
|
||||
- [x] **`library.json`, the reason the app opens fast.**
|
||||
`andtunes/manifest.py` is pure — no Qt, no device, no I/O. Rows are
|
||||
sparse the way `Track.to_dict` is sparse, because that file is what the
|
||||
R1 parses before it can draw anything. Artists and albums are *not*
|
||||
shipped: grouping a flat list on device beats parsing three redundant
|
||||
ones. A playlist's ids are filtered to tracks that actually landed, so
|
||||
the app never resolves a dangling reference.
|
||||
- [x] **One cover per album, not per song.** `andtunes/art.py` reads the
|
||||
embedded artwork with mutagen, scales to 480 px (the panel's width) and
|
||||
re-encodes as JPEG through `QImage`/`QBuffer` — no new dependency, and
|
||||
QImage is safe off the GUI thread. Keyed on album artist + album, so a
|
||||
twelve-track record costs one mutagen open and one file. Cached under
|
||||
`$XDG_CACHE_HOME/lintunes/andtunes-art`, invalidated by the audio file
|
||||
being newer — which is exactly right, since embedding new art rewrites
|
||||
the file and moves its mtime.
|
||||
- [x] **A sibling worker, not a flag on the old one.**
|
||||
`andtunes/sync.py` follows the `plan_export`/`ExportWorker` shape
|
||||
exactly: pure planner, then a daemon thread emitting KiB progress, with
|
||||
the same MTP caveats (no copystat, diff by name+size). It is separate
|
||||
because it nests directories, has an album-art pass, and writes several
|
||||
trailing files instead of one m3u. Planning deliberately does *not*
|
||||
render art — that's a mutagen open per album and planning runs on the
|
||||
GUI thread.
|
||||
- [x] **The index is written last, and always describes what's really there.**
|
||||
Cancel mid-sync and the manifest and m3us are rewritten listing only
|
||||
tracks whose files landed, so andTunes opens a smaller working library
|
||||
rather than a broken one. Re-syncing completes it.
|
||||
- [x] **A containment guard, because this is the first code that deletes a
|
||||
directory on the Rabbit.** Every delete goes through
|
||||
`layout.assert_inside`, which refuses anything that isn't strictly
|
||||
inside the andTunes root — so the old `Music/<Playlist>/` folders are
|
||||
structurally unreachable, not merely un-referenced. `Buttons/` (the
|
||||
user's own menu artwork) and `plays/` (written by the app) are skipped
|
||||
by the scan entirely: sync reads those, it doesn't own them. Emptied
|
||||
folders are pruned bottom-up, asking the filesystem rather than
|
||||
`os.walk`'s stale listing.
|
||||
- [x] **Unticking a playlist is how it comes off the device.** New
|
||||
`Connections → Sync to Rabbit` (everything ticked, no longer caring
|
||||
which view is open) and `Rabbit Sync Settings…`
|
||||
(`gui/device_sync_dialog.py` — a checkbox list with a filter box,
|
||||
because 487 playlists is a normal library here). The selection lives in
|
||||
`preferences.json` under `device_sync`, so it rides the Syncthing share
|
||||
and both machines agree on what the Rabbit is carrying; keeping it off
|
||||
the `Playlist` keeps it out of the conflict-merge machinery.
|
||||
- [x] **The old per-playlist sync stays, renamed "(Auxio)".** Removing it in
|
||||
the same round that adds the new tree would leave the device full of
|
||||
files and nothing able to open them until round 49. It goes when
|
||||
andTunes can actually play a song.
|
||||
|
||||
Not done, deliberately: the Android app (rounds 48–50), shipping default
|
||||
button images, the format gate for files Android can't decode, and reading
|
||||
play counts back off the device. Verified against the real 21,531-track
|
||||
library into a scratch device root — a re-sync copies nothing, and unticking
|
||||
a playlist removes its media, its m3u, its now-unused cover and every emptied
|
||||
folder.
|
||||
|
||||
### Round 46 (2026-09-02) — The file already told you (v0.16.0)
|
||||
|
||||
Round 45 shipped and trav broke it in two places within the hour, both real.
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
"""Round 47: LinTunes writes the andTunes library onto the device.
|
||||
|
||||
The Rabbit's music now lives in one de-duplicated tree, ``Music/andTunes/``,
|
||||
with a ``library.json`` the Android app reads instead of scanning anything.
|
||||
Everything here is testable against tmp_path stand-ins the same way the
|
||||
per-playlist sync is: a fake device root for planning and copying, with the
|
||||
worker driven synchronously via ``_run()`` so progress handlers fire inline.
|
||||
|
||||
What the tests are really pinning:
|
||||
|
||||
* a track is stored **once**, however many playlists hold it;
|
||||
* names collide deterministically (order-independent [track_id] suffixes);
|
||||
* the containment guard, because this is the first code that deletes a
|
||||
*directory* on the Rabbit — the old ``Music/<Playlist>/`` folders must be
|
||||
structurally unreachable from here;
|
||||
* the index is written **last** and always describes what is really there,
|
||||
including after a cancel.
|
||||
|
||||
Not covered: the menu actions and the settings dialog are plain Qt in
|
||||
MainWindow, which has no tests anywhere in this suite; verified by hand.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes import tagging
|
||||
from lintunes.andtunes import art, layout, manifest
|
||||
from lintunes.andtunes.layout import (
|
||||
DeviceLayoutError, album_key, art_relpath, assert_inside, media_relpath,
|
||||
playlist_relpath,
|
||||
)
|
||||
from lintunes.andtunes.manifest import build_manifest
|
||||
from lintunes.andtunes.sync import AndTunesSyncWorker, plan_andtunes_sync
|
||||
from lintunes.models import Track
|
||||
from lintunes.models.playlist import Playlist, PlaylistType
|
||||
|
||||
|
||||
def _track(tid, name="Song", artist="Band", album="Record", path="",
|
||||
total_time=180_000, **kw):
|
||||
return Track(track_id=tid, name=name, artist=artist, album=album,
|
||||
location=str(path), total_time=total_time, **kw)
|
||||
|
||||
|
||||
def _audio(tmp_path, filename, data=b"x" * 100):
|
||||
path = tmp_path / "local" / filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def _playlist(name, pid, track_ids):
|
||||
return Playlist(name=name, persistent_id=pid,
|
||||
playlist_type=PlaylistType.REGULAR,
|
||||
track_ids=list(track_ids))
|
||||
|
||||
|
||||
def _library(*tracks):
|
||||
return {t.track_id: t for t in tracks}
|
||||
|
||||
|
||||
class TestMediaLayout:
|
||||
def test_artist_album_track_number(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "Alhambra", "Ahmad Jamal", "Alhambra", src,
|
||||
track_number=4)
|
||||
assert media_relpath(t) == "Media/Ahmad Jamal/Alhambra/04 Alhambra.mp3"
|
||||
|
||||
def test_album_artist_wins_over_track_artist(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "Song", "Guest", "Comp", src, album_artist="Host")
|
||||
assert media_relpath(t).startswith("Media/Host/")
|
||||
|
||||
def test_multi_disc_gets_a_disc_prefix(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "Song", "Band", "Record", src, track_number=4,
|
||||
disc_number=2, disc_count=2)
|
||||
assert media_relpath(t).endswith("/2-04 Song.mp3")
|
||||
|
||||
def test_single_disc_has_no_prefix(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "Song", "Band", "Record", src, track_number=4,
|
||||
disc_number=1, disc_count=1)
|
||||
assert media_relpath(t).endswith("/04 Song.mp3")
|
||||
|
||||
def test_no_track_number_keeps_just_the_title(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
assert media_relpath(_track(1, "Song", "Band", "Record", src)).endswith(
|
||||
"/Song.mp3")
|
||||
|
||||
def test_missing_artist_and_album_fall_back(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "Song", "", "", src)
|
||||
assert media_relpath(t) == "Media/Unknown Artist/Unknown Album/Song.mp3"
|
||||
|
||||
def test_forbidden_characters_are_sanitized(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "A/B", "AC/DC", "Back: In", src)
|
||||
assert media_relpath(t) == "Media/AC DC/Back In/A B.mp3"
|
||||
|
||||
def test_title_falls_back_to_the_filename_stem(self, tmp_path):
|
||||
src = _audio(tmp_path, "raw_rip.mp3")
|
||||
assert media_relpath(_track(1, "", "", "", src)).endswith("/raw_rip.mp3")
|
||||
|
||||
|
||||
class TestAlbumKey:
|
||||
def test_same_album_same_key(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
a = _track(1, "One", "Band", "Record", src)
|
||||
b = _track(2, "Two", "Band", "Record", src)
|
||||
assert album_key(a) == album_key(b)
|
||||
|
||||
def test_case_insensitive(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
a = _track(1, "One", "Band", "Record", src)
|
||||
b = _track(2, "One", "BAND", "record", src)
|
||||
assert album_key(a) == album_key(b)
|
||||
|
||||
def test_different_albums_differ(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
a = _track(1, "One", "Band", "Record", src)
|
||||
b = _track(2, "One", "Band", "Other", src)
|
||||
assert album_key(a) != album_key(b)
|
||||
|
||||
def test_key_is_eight_hex(self, tmp_path):
|
||||
key = album_key(_track(1, path=_audio(tmp_path, "x.mp3")))
|
||||
assert len(key) == 8 and int(key, 16) >= 0
|
||||
|
||||
|
||||
class TestContainmentGuard:
|
||||
"""The blast radius of a bad delete here is somebody's music."""
|
||||
|
||||
def test_inside_is_allowed(self, tmp_path):
|
||||
root = tmp_path / "andTunes"
|
||||
assert assert_inside(root, root / "Media" / "a.mp3")
|
||||
|
||||
def test_the_root_itself_is_refused(self, tmp_path):
|
||||
root = tmp_path / "andTunes"
|
||||
with pytest.raises(DeviceLayoutError):
|
||||
assert_inside(root, root)
|
||||
|
||||
def test_a_sibling_playlist_folder_is_refused(self, tmp_path):
|
||||
"""The old Auxio folders live next door and must be unreachable."""
|
||||
root = tmp_path / "Music" / "andTunes"
|
||||
with pytest.raises(DeviceLayoutError):
|
||||
assert_inside(root, tmp_path / "Music" / "a fresh master")
|
||||
|
||||
def test_dot_dot_escape_is_refused(self, tmp_path):
|
||||
root = tmp_path / "andTunes"
|
||||
with pytest.raises(DeviceLayoutError):
|
||||
assert_inside(root, root / ".." / "elsewhere" / "x.mp3")
|
||||
|
||||
|
||||
class TestManifest:
|
||||
def test_sparse_rows_omit_empty_fields(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, "Song", "", "", src, total_time=0)
|
||||
row = manifest.track_entry(t, "Media/x.mp3")
|
||||
assert row == {"id": 1, "title": "Song", "path": "Media/x.mp3"}
|
||||
|
||||
def test_seconds_round_from_milliseconds(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, path=src, total_time=214_400)
|
||||
assert manifest.track_entry(t, "p")["secs"] == 214
|
||||
|
||||
def test_art_included_only_when_given(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
t = _track(1, path=src)
|
||||
assert "art" not in manifest.track_entry(t, "p")
|
||||
assert manifest.track_entry(t, "p", "Art/ab.jpg")["art"] == "Art/ab.jpg"
|
||||
|
||||
def test_playlist_ids_are_filtered_to_known_tracks(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
entries = [(_track(1, path=src), "Media/a.mp3", None)]
|
||||
payload = build_manifest(entries, [("Mix", "P1", [1, 99])],
|
||||
generator="lintunes test")
|
||||
assert payload["playlists"][0]["tracks"] == [1]
|
||||
|
||||
def test_format_version_is_stamped(self, tmp_path):
|
||||
payload = build_manifest([], [], generator="lintunes test")
|
||||
assert payload["format"] == layout.FORMAT_VERSION
|
||||
assert payload["generator"] == "lintunes test"
|
||||
|
||||
|
||||
class TestPlan:
|
||||
def test_a_track_in_two_playlists_is_stored_once(self, tmp_path):
|
||||
src = _audio(tmp_path, "a.mp3")
|
||||
track = _track(1, "Song", "Band", "Record", src)
|
||||
plan = plan_andtunes_sync(
|
||||
[_playlist("A", "P1", [1]), _playlist("B", "P2", [1])],
|
||||
_library(track), tmp_path / "dev")
|
||||
assert len(plan.items) == 1
|
||||
assert len(plan.copies) == 1
|
||||
assert [p.name for p in plan.playlists] == ["A", "B"]
|
||||
|
||||
def test_collision_suffixes_are_order_independent(self, tmp_path):
|
||||
# Same artist, album, title and number: only the track id separates them.
|
||||
a = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
b = _track(2, "Song", "Band", "Record", _audio(tmp_path, "b.mp3"))
|
||||
forward = plan_andtunes_sync([_playlist("A", "P1", [1, 2])],
|
||||
_library(a, b), tmp_path / "d1")
|
||||
backward = plan_andtunes_sync([_playlist("A", "P1", [2, 1])],
|
||||
_library(a, b), tmp_path / "d2")
|
||||
assert ({i.rel for i in forward.items}
|
||||
== {i.rel for i in backward.items}
|
||||
== {"Media/Band/Record/Song [1].mp3",
|
||||
"Media/Band/Record/Song [2].mp3"})
|
||||
|
||||
def test_missing_local_files_are_skipped(self, tmp_path):
|
||||
here = _track(1, "Here", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
gone = _track(2, "Gone", "Band", "Record", tmp_path / "nope.mp3")
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1, 2])],
|
||||
_library(here, gone), tmp_path / "dev")
|
||||
assert plan.skipped == 1
|
||||
assert [i.track_id for i in plan.items] == [1]
|
||||
assert plan.playlists[0].track_ids == [1]
|
||||
|
||||
def test_m3u_entries_point_up_into_media(self, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), tmp_path / "dev")
|
||||
assert plan.playlists[0].rel == "Playlists/A.m3u"
|
||||
assert plan.playlists[0].entries[0][0] == "../Media/Band/Record/Song.mp3"
|
||||
|
||||
def test_matching_files_are_kept_and_extras_go_stale(self, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record",
|
||||
_audio(tmp_path, "a.mp3", b"y" * 40))
|
||||
root = tmp_path / "dev"
|
||||
dest = root / "Media" / "Band" / "Record"
|
||||
dest.mkdir(parents=True)
|
||||
(dest / "Song.mp3").write_bytes(b"y" * 40)
|
||||
(root / "Media" / "Old" ).mkdir(parents=True)
|
||||
(root / "Media" / "Old" / "gone.mp3").write_bytes(b"z" * 10)
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root)
|
||||
assert plan.kept == 1 and plan.copies == []
|
||||
assert plan.stale == ["Media/Old/gone.mp3"]
|
||||
assert plan.bytes_freed == 10
|
||||
|
||||
def test_a_size_mismatch_is_recopied(self, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record",
|
||||
_audio(tmp_path, "a.mp3", b"y" * 40))
|
||||
root = tmp_path / "dev"
|
||||
dest = root / "Media" / "Band" / "Record"
|
||||
dest.mkdir(parents=True)
|
||||
(dest / "Song.mp3").write_bytes(b"y" * 39) # a partial from last time
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root)
|
||||
assert plan.kept == 0 and len(plan.copies) == 1
|
||||
|
||||
def test_the_devices_own_folders_are_never_scanned_or_pruned(self, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
root = tmp_path / "dev"
|
||||
for owned in (layout.BUTTONS_DIR, layout.PLAYS_DIR):
|
||||
(root / owned).mkdir(parents=True)
|
||||
(root / owned / "mine.dat").write_bytes(b"!")
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root)
|
||||
assert plan.stale == []
|
||||
|
||||
def test_the_index_and_art_are_never_stale(self, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
root = tmp_path / "dev"
|
||||
root.mkdir()
|
||||
(root / layout.MANIFEST_NAME).write_text("{}")
|
||||
(root / layout.ART_DIR).mkdir()
|
||||
(root / art_relpath(album_key(track))).write_bytes(b"jpeg")
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root)
|
||||
assert plan.stale == []
|
||||
|
||||
|
||||
def _run(worker):
|
||||
"""Drive the worker synchronously so signals fire inline."""
|
||||
worker._run()
|
||||
|
||||
|
||||
def _cancel_on_emit(worker, n: int):
|
||||
"""Cancel once the nth progress signal has fired.
|
||||
|
||||
Each 100-byte fixture is one chunk, so it emits exactly once — n is
|
||||
effectively "cancel while copying file n", the same trick test_round28
|
||||
uses on the per-playlist worker.
|
||||
"""
|
||||
seen = []
|
||||
|
||||
def _tick(*_args):
|
||||
seen.append(1)
|
||||
if len(seen) >= n:
|
||||
worker.cancel()
|
||||
|
||||
worker.progress.connect(_tick)
|
||||
return worker
|
||||
|
||||
|
||||
class TestWorker:
|
||||
def test_end_to_end(self, qapp, tmp_path):
|
||||
a = _track(1, "One", "Band", "Record", _audio(tmp_path, "a.mp3"),
|
||||
track_number=1)
|
||||
b = _track(2, "Two", "Band", "Record", _audio(tmp_path, "b.mp3"),
|
||||
track_number=2)
|
||||
root = tmp_path / "dev"
|
||||
plan = plan_andtunes_sync([_playlist("Mix", "P1", [2, 1])],
|
||||
_library(a, b), root, copy_art=False)
|
||||
done = []
|
||||
worker = AndTunesSyncWorker(plan)
|
||||
worker.finished.connect(done.append)
|
||||
_run(worker)
|
||||
|
||||
assert (root / "Media/Band/Record/01 One.mp3").read_bytes() == b"x" * 100
|
||||
assert (root / "Media/Band/Record/02 Two.mp3").exists()
|
||||
m3u = (root / "Playlists/Mix.m3u").read_text()
|
||||
assert m3u.splitlines()[0] == "#EXTM3U"
|
||||
# Playlist order, not track-number order, and pointing up into Media/.
|
||||
assert "../Media/Band/Record/02 Two.mp3" in m3u.splitlines()[2]
|
||||
payload = json.loads((root / layout.MANIFEST_NAME).read_text())
|
||||
assert [t["id"] for t in payload["tracks"]] == [1, 2]
|
||||
assert payload["playlists"][0] == {"id": "P1", "name": "Mix",
|
||||
"tracks": [2, 1]}
|
||||
assert done[0]["tracks"] == 2 and done[0]["copied"] == 2
|
||||
|
||||
def test_progress_is_reported_in_kib(self, qapp, tmp_path):
|
||||
big = _audio(tmp_path, "big.mp3", b"x" * (2 * 1024 * 1024))
|
||||
track = _track(1, "Big", "Band", "Record", big)
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), tmp_path / "dev",
|
||||
copy_art=False)
|
||||
seen = []
|
||||
worker = AndTunesSyncWorker(plan)
|
||||
worker.progress.connect(lambda d, t, label: seen.append((d, t)))
|
||||
_run(worker)
|
||||
assert seen[-1][0] == 2048 and seen[0][1] == 2048
|
||||
|
||||
def test_stale_files_are_removed_and_empty_folders_pruned(self, qapp, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
root = tmp_path / "dev"
|
||||
(root / "Media" / "Old Band" / "Old Record").mkdir(parents=True)
|
||||
(root / "Media/Old Band/Old Record/gone.mp3").write_bytes(b"z")
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root, copy_art=False)
|
||||
_run(AndTunesSyncWorker(plan))
|
||||
assert not (root / "Media" / "Old Band").exists()
|
||||
assert (root / "Media/Band/Record/Song.mp3").exists()
|
||||
|
||||
def test_the_devices_own_folders_survive_a_sync(self, qapp, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
root = tmp_path / "dev"
|
||||
(root / layout.BUTTONS_DIR).mkdir(parents=True)
|
||||
(root / layout.BUTTONS_DIR / "playlists.png").write_bytes(b"png")
|
||||
(root / layout.PLAYS_DIR).mkdir(parents=True) # empty on purpose
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root, copy_art=False)
|
||||
_run(AndTunesSyncWorker(plan))
|
||||
assert (root / layout.BUTTONS_DIR / "playlists.png").exists()
|
||||
assert (root / layout.PLAYS_DIR).is_dir()
|
||||
|
||||
def test_a_stale_entry_pointing_outside_the_root_is_refused(
|
||||
self, qapp, tmp_path):
|
||||
"""Belt and braces: the guard holds even if planning were wrong."""
|
||||
root = tmp_path / "dev" / "andTunes"
|
||||
root.mkdir(parents=True)
|
||||
neighbour = tmp_path / "dev" / "a fresh master"
|
||||
neighbour.mkdir()
|
||||
(neighbour / "song.mp3").write_bytes(b"precious")
|
||||
plan = plan_andtunes_sync([], {}, root, copy_art=False)
|
||||
plan.stale = ["../a fresh master/song.mp3"]
|
||||
_run(AndTunesSyncWorker(plan))
|
||||
assert (neighbour / "song.mp3").read_bytes() == b"precious"
|
||||
|
||||
def test_a_source_that_vanished_is_skipped_not_fatal(self, qapp, tmp_path):
|
||||
here = _track(1, "Here", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
gone = _track(2, "Gone", "Band", "Record", _audio(tmp_path, "b.mp3"))
|
||||
root = tmp_path / "dev"
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1, 2])],
|
||||
_library(here, gone), root, copy_art=False)
|
||||
os.unlink(gone.location) # Syncthing moved it under us
|
||||
failures = []
|
||||
worker = AndTunesSyncWorker(plan)
|
||||
worker.failed.connect(failures.append)
|
||||
_run(worker)
|
||||
assert not failures
|
||||
payload = json.loads((root / layout.MANIFEST_NAME).read_text())
|
||||
assert [t["id"] for t in payload["tracks"]] == [1]
|
||||
assert "Gone" not in (root / "Playlists/A.m3u").read_text()
|
||||
|
||||
def test_device_side_error_reports_failure(self, qapp, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
blocked = tmp_path / "dev"
|
||||
blocked.write_text("not a directory") # mkdir will raise
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), blocked, copy_art=False)
|
||||
failures = []
|
||||
worker = AndTunesSyncWorker(plan)
|
||||
worker.failed.connect(failures.append)
|
||||
_run(worker)
|
||||
assert failures and "still connected" in failures[0]
|
||||
|
||||
|
||||
class TestCancel:
|
||||
def test_cancel_before_the_run_copies_nothing_and_still_indexes(
|
||||
self, qapp, tmp_path):
|
||||
track = _track(1, "Song", "Band", "Record", _audio(tmp_path, "a.mp3"))
|
||||
root = tmp_path / "dev"
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1])],
|
||||
_library(track), root, copy_art=False)
|
||||
worker = AndTunesSyncWorker(plan)
|
||||
stopped = []
|
||||
worker.cancelled.connect(stopped.append)
|
||||
worker.cancel()
|
||||
_run(worker)
|
||||
assert stopped and stopped[0]["copied"] == 0
|
||||
assert not (root / "Media").exists()
|
||||
|
||||
def test_cancel_during_the_second_file_keeps_the_first(self, qapp, tmp_path):
|
||||
a = _track(1, "One", "Band", "Record", _audio(tmp_path, "a.mp3"),
|
||||
track_number=1)
|
||||
b = _track(2, "Two", "Band", "Record", _audio(tmp_path, "b.mp3"),
|
||||
track_number=2)
|
||||
root = tmp_path / "dev"
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1, 2])],
|
||||
_library(a, b), root, copy_art=False)
|
||||
# Cancel while the second file is in flight; the first must survive.
|
||||
_run(_cancel_on_emit(AndTunesSyncWorker(plan), 2))
|
||||
assert (root / "Media/Band/Record/01 One.mp3").exists()
|
||||
assert not (root / "Media/Band/Record/02 Two.mp3").exists()
|
||||
|
||||
def test_a_cancelled_sync_leaves_a_coherent_index(self, qapp, tmp_path):
|
||||
"""The app must never open a manifest naming files that aren't there."""
|
||||
a = _track(1, "One", "Band", "Record", _audio(tmp_path, "a.mp3"),
|
||||
track_number=1)
|
||||
b = _track(2, "Two", "Band", "Record", _audio(tmp_path, "b.mp3"),
|
||||
track_number=2)
|
||||
root = tmp_path / "dev"
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1, 2])],
|
||||
_library(a, b), root, copy_art=False)
|
||||
_run(_cancel_on_emit(AndTunesSyncWorker(plan), 2))
|
||||
payload = json.loads((root / layout.MANIFEST_NAME).read_text())
|
||||
for row in payload["tracks"]:
|
||||
assert (root / row["path"]).exists()
|
||||
assert payload["playlists"][0]["tracks"] == [1]
|
||||
assert "02 Two.mp3" not in (root / "Playlists/A.m3u").read_text()
|
||||
|
||||
def test_resyncing_after_a_cancel_completes_the_library(self, qapp, tmp_path):
|
||||
a = _track(1, "One", "Band", "Record", _audio(tmp_path, "a.mp3"),
|
||||
track_number=1)
|
||||
b = _track(2, "Two", "Band", "Record", _audio(tmp_path, "b.mp3"),
|
||||
track_number=2)
|
||||
root = tmp_path / "dev"
|
||||
lib, pls = _library(a, b), [_playlist("A", "P1", [1, 2])]
|
||||
_run(_cancel_on_emit(AndTunesSyncWorker(
|
||||
plan_andtunes_sync(pls, lib, root, copy_art=False)), 2))
|
||||
_run(AndTunesSyncWorker(
|
||||
plan_andtunes_sync(pls, lib, root, copy_art=False)))
|
||||
payload = json.loads((root / layout.MANIFEST_NAME).read_text())
|
||||
assert [t["id"] for t in payload["tracks"]] == [1, 2]
|
||||
assert (root / "Media/Band/Record/02 Two.mp3").exists()
|
||||
|
||||
|
||||
class TestAlbumArt:
|
||||
def test_art_is_extracted_scaled_and_shared_across_an_album(
|
||||
self, qapp, tmp_path, mp3_file, jpeg_bytes, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||
tagging.write_artwork(mp3_file, jpeg_bytes)
|
||||
second = tmp_path / "local" / "b.mp3"
|
||||
second.parent.mkdir(parents=True, exist_ok=True)
|
||||
second.write_bytes(mp3_file.read_bytes())
|
||||
|
||||
a = _track(1, "One", "Band", "Record", mp3_file, track_number=1)
|
||||
b = _track(2, "Two", "Band", "Record", second, track_number=2)
|
||||
root = tmp_path / "dev"
|
||||
plan = plan_andtunes_sync([_playlist("A", "P1", [1, 2])],
|
||||
_library(a, b), root)
|
||||
summary = []
|
||||
worker = AndTunesSyncWorker(plan)
|
||||
worker.finished.connect(summary.append)
|
||||
_run(worker)
|
||||
|
||||
rel = art_relpath(album_key(a))
|
||||
assert (root / rel).exists()
|
||||
assert summary[0]["albums"] == 1 # one cover, not two
|
||||
payload = json.loads((root / layout.MANIFEST_NAME).read_text())
|
||||
assert {t["art"] for t in payload["tracks"]} == {rel}
|
||||
|
||||
def test_a_track_without_art_gets_no_art_key(self, qapp, tmp_path, mp3_file,
|
||||
monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||
track = _track(1, "Song", "Band", "Record", mp3_file)
|
||||
root = tmp_path / "dev"
|
||||
_run(AndTunesSyncWorker(plan_andtunes_sync(
|
||||
[_playlist("A", "P1", [1])], _library(track), root)))
|
||||
payload = json.loads((root / layout.MANIFEST_NAME).read_text())
|
||||
assert "art" not in payload["tracks"][0]
|
||||
assert not (root / art_relpath(album_key(track))).exists()
|
||||
|
||||
def test_render_never_upscales_a_small_cover(self, qapp, jpeg_bytes):
|
||||
from PyQt6.QtGui import QImage
|
||||
out = art.render_jpeg(jpeg_bytes)
|
||||
image = QImage()
|
||||
image.loadFromData(out)
|
||||
assert image.width() == 8
|
||||
|
||||
def test_the_cache_is_reused_until_the_file_changes(
|
||||
self, qapp, tmp_path, mp3_file, jpeg_bytes, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||
tagging.write_artwork(mp3_file, jpeg_bytes)
|
||||
track = _track(1, "Song", "Band", "Record", mp3_file)
|
||||
assert art.album_art_jpeg(track)
|
||||
|
||||
calls = []
|
||||
real = tagging.read_embedded_artwork
|
||||
monkeypatch.setattr(art.tagging, "read_embedded_artwork",
|
||||
lambda p: calls.append(p) or real(p))
|
||||
assert art.album_art_jpeg(track)
|
||||
assert calls == [] # served from cache
|
||||
|
||||
os.utime(mp3_file, (9e9, 9e9)) # the file changed under us
|
||||
assert art.album_art_jpeg(track)
|
||||
assert calls # so the cover was re-read
|
||||
|
||||
|
||||
class TestSyncablePlaylists:
|
||||
def test_folders_and_system_playlists_are_not_offered(self):
|
||||
from lintunes.gui.device_sync_dialog import syncable_playlists
|
||||
|
||||
class _Lib:
|
||||
playlists = {
|
||||
"P1": Playlist(name="Mix", persistent_id="P1"),
|
||||
"F1": Playlist(name="Folder", persistent_id="F1",
|
||||
playlist_type=PlaylistType.FOLDER),
|
||||
"S1": Playlist(name="Music", persistent_id="S1",
|
||||
is_system=True),
|
||||
"M1": Playlist(name="Recent", persistent_id="M1",
|
||||
playlist_type=PlaylistType.SMART),
|
||||
}
|
||||
|
||||
assert [p.persistent_id for p in syncable_playlists(_Lib())] == ["P1", "M1"]
|
||||
Reference in New Issue
Block a user