Files
lintunes/lintunes/cassette/delivery.py
T
trav 35281b9864 v0.31.0: Cassette followed playlists — a friend's playlist in your sidebar
- A followed playlist (the UFO in friend mode) appears in my sidebar with
  a purple UFO: mixed in with my playlists alphabetically, or inside a
  folder named after the friend (per-friend, in Sync Settings). It's never
  a Playlist of mine, so it stays out of the library and its merges.
- It plays from the cache. Player's queue accepts friend-track keys,
  resolved through Cassette: they scrobble to Last.fm but don't count in
  my play journal. Songs still on their way are grey and skipped.
- The cassette column there is hidden by default (right-click the
  header); pressing one takes the song into my library — straight from
  the cache if it's there, otherwise by asking for it.
- Sync Settings per friend: keep followed songs separate (default) or add
  them to my library. Unfollowing (right-click) frees their cache.
- The bottom bar says when the friend's folder last synced.
2026-09-28 21:40:59 -07:00

324 lines
12 KiB
Python

"""The request / delivery loop — both directions, rebuilt from the folders.
**Sending** (my LinTunes serving a friend): their ``requests.json`` (in their
folder to me) ∩ what I *currently* offer them = what belongs in my
``outbox/``. Missing files are copied in (to a ``.part`` name the
``.stignore`` keeps out of sync, then renamed); files no longer requested are
deleted — a request disappears when the song has arrived, so the outbox
cleans itself up with no bookkeeping of its own.
**Receiving** (my LinTunes asking a friend): each complete file in their
outbox that I want is brought in — a cassetted song is imported exactly like
Add to Library (copied into the organized tree, tags read, date added now,
none of their listening history), a song wanted only for a followed playlist
is copied into ``cache/``. Then my ``requests.json`` is rewritten once per
batch without what arrived and without anything they no longer offer.
Each machine trusts only its own view, and every step is safe to repeat: a
song already in my library (exact match) is not imported twice, a file
Syncthing is still writing (``.syncthing.*.tmp``) is never touched, and
whatever a crash interrupted is simply planned again next time.
Planning is pure and runs on the GUI thread (it reads the library); the file
work runs on ``DeliveryWorker``'s thread; adding tracks to the library happens
back on the GUI thread.
"""
from __future__ import annotations
import logging
import re
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.cassette import matching, publish, share
from lintunes.cassette import requests as req
from lintunes.cassette.friend_library import load_friend_library
from lintunes.cassette.state import FOLLOW_LIBRARY, cache_dir, in_dir, out_dir
from lintunes.importers.file_importer import sanitize_component
log = logging.getLogger(__name__)
OUTBOX = "outbox"
PART_SUFFIX = ".part"
_NAME_RE = re.compile(r"^(\d+) ~ (.+)$")
def outbox_name(track_id: int, location: str) -> str:
return f"{track_id} ~ {sanitize_component(Path(location).name)}"
def parse_outbox_name(name: str) -> tuple[int, str] | None:
"""(track id, original filename) for one of ours, else None."""
match = _NAME_RE.match(name)
return (int(match.group(1)), match.group(2)) if match else None
def is_syncthing_temp(name: str) -> bool:
return (name.startswith(".syncthing.") or name.startswith("~syncthing~")
or name.endswith(".tmp") or name.endswith(PART_SUFFIX))
def list_outbox(folder: Path) -> dict[int, Path]:
"""{track id: path} for the finished files in an outbox."""
found = {}
try:
entries = list(folder.iterdir())
except OSError:
return found
for path in entries:
if is_syncthing_temp(path.name) or not path.is_file():
continue
parsed = parse_outbox_name(path.name)
if parsed is not None:
found[parsed[0]] = path
return found
# ---------------------------------------------------------------- sending --
@dataclass
class SendPlan:
token: str
outbox: Path
copies: list = field(default_factory=list) # (source Path, dest name)
deletes: list = field(default_factory=list) # Paths
missing: list = field(default_factory=list) # track ids with no file
def plan_send(token: str, root: Path, library, selection) -> SendPlan:
outbox = out_dir(root, token) / OUTBOX
their_requests = req.read_requests(in_dir(root, token))
offered = share.offered_ids(library, selection)
want = their_requests & offered
present = list_outbox(outbox)
plan = SendPlan(token=token, outbox=outbox)
for tid in sorted(want):
if tid in present:
continue
track = library.tracks[tid]
source = Path(track.location) if track.location else None
if source is None or not source.is_file():
plan.missing.append(tid)
continue
plan.copies.append((source, outbox_name(tid, track.location)))
for tid, path in present.items():
if tid not in want:
plan.deletes.append(path)
return plan
# -------------------------------------------------------------- receiving --
@dataclass
class Arrival:
track_id: int
path: Path
to_library: bool
to_cache: bool
@dataclass
class ReceivePlan:
token: str
arrivals: list = field(default_factory=list) # Arrival
cache: Path = None
# The requests file as it should read once the arrivals are in.
cassetted: set = field(default_factory=set)
requests_after: set = field(default_factory=set)
requests_path: Path = None
already_have: list = field(default_factory=list) # ids skipped: exact match
# Cached songs no followed playlist uses any more (unfollowed, or the
# friend took them out): deleted to give the space back.
cache_prune: list = field(default_factory=list) # Paths
def followed_needed(friend_library, friend, cache: Path) -> set[int]:
"""Songs of followed playlists that aren't in the cache yet."""
cached = {int(p.stem) for p in _cache_files(cache)}
needed = set()
for pid in friend.followed:
playlist = friend_library.playlist(pid)
if playlist is not None:
needed.update(t for t in playlist.track_ids if t not in cached)
return needed
def _cache_files(cache: Path):
try:
return [p for p in cache.iterdir()
if p.is_file() and p.stem.isdigit() and not is_syncthing_temp(p.name)]
except OSError:
return []
def plan_receive(friend, root: Path, my_index: matching.LibraryIndex,
is_complete=None) -> ReceivePlan:
token = friend.token
source = in_dir(root, token)
library_file = source / share.LIBRARY_FILE
their = load_friend_library(source, friend.name)
cache = cache_dir(root, token)
plan = ReceivePlan(token=token, cache=cache,
requests_path=out_dir(root, token))
cassetted = set(friend.cassetted)
# Prune what they stopped offering — but only against a library file
# that's actually here; a not-yet-synced one isn't "offers nothing".
if library_file.exists() and publish.read_json(library_file) is not None:
cassetted &= set(their.tracks)
to_library = set(cassetted)
needed = followed_needed(their, friend, cache)
if friend.followed_mode == FOLLOW_LIBRARY:
to_library |= needed
needed_cache = set(needed)
else:
needed_cache = set(needed)
for tid, path in sorted(list_outbox(source / OUTBOX).items()):
wanted_lib = tid in to_library
wanted_cache = tid in needed_cache
if not (wanted_lib or wanted_cache):
continue
if is_complete is not None and not is_complete(path):
continue
if wanted_lib and tid in their.tracks and \
my_index.classify(their.tracks[tid]) == matching.EXACT:
plan.already_have.append(tid)
wanted_lib = False
if not wanted_cache:
cassetted.discard(tid)
continue
plan.arrivals.append(Arrival(tid, path, wanted_lib, wanted_cache))
playlists_file = source / share.PLAYLISTS_FILE
if playlists_file.exists() and publish.read_json(playlists_file) is not None:
keep = set()
for pid in friend.followed:
playlist = their.playlist(pid)
if playlist is not None:
keep.update(playlist.track_ids)
plan.cache_prune = [p for p in _cache_files(cache)
if int(p.stem) not in keep]
arrived_lib = {a.track_id for a in plan.arrivals if a.to_library}
arrived_cache = {a.track_id for a in plan.arrivals if a.to_cache}
plan.cassetted = cassetted - arrived_lib
still_needed = needed - arrived_cache
if friend.followed_mode == FOLLOW_LIBRARY:
still_needed -= arrived_lib
plan.requests_after = plan.cassetted | still_needed
return plan
# ------------------------------------------------------------------ work --
@dataclass
class Staged:
token: str
track_id: int
dest: Path
fields: dict
fallback_name: str
@dataclass
class DeliveryResult:
staged: list = field(default_factory=list) # Staged, to add to library
cached: list = field(default_factory=list) # (token, track id)
sent: int = 0
removed: int = 0
errors: list = field(default_factory=list) # (what, error text)
def run_plans(send_plans, receive_plans, music_dir: Path | None,
cancel: threading.Event | None = None) -> DeliveryResult:
"""All the file work. Safe off the GUI thread: touches no library."""
from lintunes.importers.file_importer import stage_file
result = DeliveryResult()
for plan in send_plans:
for path in plan.deletes:
try:
path.unlink(missing_ok=True)
result.removed += 1
except OSError as exc:
result.errors.append((f"Removing {path.name}", str(exc)))
if plan.copies:
plan.outbox.mkdir(parents=True, exist_ok=True)
for source, name in plan.copies:
if cancel is not None and cancel.is_set():
return result
part = plan.outbox / ("." + name + PART_SUFFIX)
try:
shutil.copyfile(source, part)
part.replace(plan.outbox / name)
result.sent += 1
except OSError as exc:
part.unlink(missing_ok=True)
result.errors.append((f"Sending {source.name}", str(exc)))
for plan in receive_plans:
for path in plan.cache_prune:
try:
path.unlink(missing_ok=True)
except OSError as exc:
result.errors.append((f"Tidying the cache ({path.name})", str(exc)))
for arrival in plan.arrivals:
if cancel is not None and cancel.is_set():
return result
parsed = parse_outbox_name(arrival.path.name)
original = parsed[1] if parsed else arrival.path.name
try:
if arrival.to_cache:
plan.cache.mkdir(parents=True, exist_ok=True)
dest = plan.cache / f"{arrival.track_id}{arrival.path.suffix.lower()}"
tmp = dest.with_name("." + dest.name + PART_SUFFIX)
shutil.copyfile(arrival.path, tmp)
tmp.replace(dest)
result.cached.append((plan.token, arrival.track_id))
if arrival.to_library and music_dir is not None:
dest, fields = stage_file(arrival.path, music_dir, original)
result.staged.append(Staged(plan.token, arrival.track_id,
dest, fields,
Path(original).stem))
except OSError as exc:
result.errors.append((f"Bringing in {original}", str(exc)))
return result
class DeliveryWorker(QObject):
"""``run_plans`` on a daemon thread (the ExportWorker shape)."""
finished = pyqtSignal(object, object) # DeliveryResult, receive plans
def __init__(self, parent=None):
super().__init__(parent)
self._busy = False
self._cancel = threading.Event()
def busy(self) -> bool:
return self._busy
def cancel(self):
self._cancel.set()
def start(self, send_plans, receive_plans, music_dir):
if self._busy:
return False
self._busy = True
self._cancel.clear()
def run():
try:
result = run_plans(send_plans, receive_plans, music_dir,
self._cancel)
except Exception as exc: # never die silently
log.exception("cassette delivery")
result = DeliveryResult(errors=[("Delivering songs", str(exc))])
self._busy = False
self.finished.emit(result, receive_plans)
threading.Thread(target=run, daemon=True, name="cassette-delivery").start()
return True