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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -505,6 +505,15 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
already in my library is never imported twice, which is what makes a crash
|
already in my library is never imported twice, which is what makes a crash
|
||||||
between import and requests-rewrite harmless; a missing `library.json`
|
between import and requests-rewrite harmless; a missing `library.json`
|
||||||
prunes nothing.
|
prunes nothing.
|
||||||
|
**Followed playlists** (a UFO in friend mode) are never `Playlist`s of mine:
|
||||||
|
`PlaylistTree.followed_provider` injects them (mixed alphabetically or in a
|
||||||
|
friend folder, per `Friend.layout`), and `gui/followed_view.py` plays them
|
||||||
|
from `friends/<token>/cache/`. Player queue entries may be **string keys**
|
||||||
|
(`cassette:<token>:<their id>`) resolved by `Player.track_resolver`; a key
|
||||||
|
scrobbles (`track_finished`) but never calls `record_play`. Requests are
|
||||||
|
always computed by `service._write_requests` (cassetted ∪ followed songs not
|
||||||
|
yet cached), so a save never re-asks for what's already in the cache, and a
|
||||||
|
pass prunes cache files no followed playlist uses.
|
||||||
|
|
||||||
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
|
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
|
||||||
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.30.0"
|
__version__ = "0.31.0"
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ class ReceivePlan:
|
|||||||
requests_after: set = field(default_factory=set)
|
requests_after: set = field(default_factory=set)
|
||||||
requests_path: Path = None
|
requests_path: Path = None
|
||||||
already_have: list = field(default_factory=list) # ids skipped: exact match
|
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]:
|
def followed_needed(friend_library, friend, cache: Path) -> set[int]:
|
||||||
@@ -191,6 +194,16 @@ def plan_receive(friend, root: Path, my_index: matching.LibraryIndex,
|
|||||||
continue
|
continue
|
||||||
plan.arrivals.append(Arrival(tid, path, wanted_lib, wanted_cache))
|
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_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}
|
arrived_cache = {a.track_id for a in plan.arrivals if a.to_cache}
|
||||||
plan.cassetted = cassetted - arrived_lib
|
plan.cassetted = cassetted - arrived_lib
|
||||||
@@ -247,6 +260,11 @@ def run_plans(send_plans, receive_plans, music_dir: Path | None,
|
|||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
result.errors.append((f"Sending {source.name}", str(exc)))
|
result.errors.append((f"Sending {source.name}", str(exc)))
|
||||||
for plan in receive_plans:
|
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:
|
for arrival in plan.arrivals:
|
||||||
if cancel is not None and cancel.is_set():
|
if cancel is not None and cancel.is_set():
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -239,11 +239,11 @@ class CassetteService(QObject):
|
|||||||
|
|
||||||
# ---- requests ----
|
# ---- requests ----
|
||||||
|
|
||||||
def save_requests(self, token: str, cassetted, followed, requested):
|
def save_requests(self, token: str, cassetted, followed):
|
||||||
"""Friend mode's Save and Close: remember what I cassetted and which
|
"""Friend mode's Save and Close (and a cassette pressed in a followed
|
||||||
playlists I follow, and tell the friend (``requests.json`` in my
|
playlist): remember what I cassetted and which playlists I follow,
|
||||||
folder to them) which of their track ids to send."""
|
and tell the friend (``requests.json`` in my folder to them) which of
|
||||||
from lintunes.cassette import requests as req
|
their songs to send."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
friend = self._state.friends.get(token)
|
friend = self._state.friends.get(token)
|
||||||
if friend is None:
|
if friend is None:
|
||||||
@@ -251,9 +251,22 @@ class CassetteService(QObject):
|
|||||||
friend.cassetted = sorted(int(t) for t in cassetted)
|
friend.cassetted = sorted(int(t) for t in cassetted)
|
||||||
friend.followed = sorted(followed)
|
friend.followed = sorted(followed)
|
||||||
self._save()
|
self._save()
|
||||||
req.write_requests(out_dir(self.root, token), requested)
|
self._write_requests(friend)
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _write_requests(self, friend):
|
||||||
|
"""requests.json = cassetted ∪ followed songs not in the cache yet."""
|
||||||
|
from lintunes.cassette import requests as req
|
||||||
|
from lintunes.cassette.delivery import followed_needed
|
||||||
|
from lintunes.cassette.friend_library import load_friend_library
|
||||||
|
from lintunes.cassette.state import cache_dir
|
||||||
|
wanted = set(friend.cassetted)
|
||||||
|
if friend.followed:
|
||||||
|
their = load_friend_library(in_dir(self.root, friend.token))
|
||||||
|
wanted |= followed_needed(their, friend,
|
||||||
|
cache_dir(self.root, friend.token))
|
||||||
|
req.write_requests(out_dir(self.root, friend.token), wanted)
|
||||||
|
|
||||||
def apply_delivery(self, token: str, cassetted, requested):
|
def apply_delivery(self, token: str, cassetted, requested):
|
||||||
"""After a batch arrived: what's still wanted, and the requests file
|
"""After a batch arrived: what's still wanted, and the requests file
|
||||||
that says so (rewritten only if it changed)."""
|
that says so (rewritten only if it changed)."""
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class DeliveryCoordinator(QObject):
|
|||||||
notice = pyqtSignal(str)
|
notice = pyqtSignal(str)
|
||||||
step_failed = pyqtSignal(str, str)
|
step_failed = pyqtSignal(str, str)
|
||||||
library_grew = pyqtSignal(list) # new track ids
|
library_grew = pyqtSignal(list) # new track ids
|
||||||
|
passed = pyqtSignal() # a pass finished (the cache may have moved)
|
||||||
|
|
||||||
def __init__(self, service, manager, parent=None):
|
def __init__(self, service, manager, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -96,9 +97,10 @@ class DeliveryCoordinator(QObject):
|
|||||||
friend, root, index,
|
friend, root, index,
|
||||||
is_complete=lambda path, t=token: self._service.is_complete(t, path)))
|
is_complete=lambda path, t=token: self._service.is_complete(t, path)))
|
||||||
if not any(p.copies or p.deletes for p in sends) and \
|
if not any(p.copies or p.deletes for p in sends) and \
|
||||||
not any(p.arrivals for p in receives):
|
not any(p.arrivals or p.cache_prune for p in receives):
|
||||||
# Nothing to move, but prunes and exact-match skips still count.
|
# Nothing to move, but prunes and exact-match skips still count.
|
||||||
self._finish_receives(receives, set())
|
self._finish_receives(receives, set())
|
||||||
|
self.passed.emit()
|
||||||
return
|
return
|
||||||
self._worker.start(sends, receives, self._manager.organize_root())
|
self._worker.start(sends, receives, self._manager.organize_root())
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ class DeliveryCoordinator(QObject):
|
|||||||
f"{names.get(token, 'a friend')}.")
|
f"{names.get(token, 'a friend')}.")
|
||||||
for what, error in result.errors[:3]:
|
for what, error in result.errors[:3]:
|
||||||
self.step_failed.emit(what, error)
|
self.step_failed.emit(what, error)
|
||||||
|
self.passed.emit()
|
||||||
if self._again:
|
if self._again:
|
||||||
self._again = False
|
self._again = False
|
||||||
self._debounce.start()
|
self._debounce.start()
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class CassetteUi(QObject):
|
|||||||
self.service = None
|
self.service = None
|
||||||
self.friend_mode = None # FriendMode while browsing a friend
|
self.friend_mode = None # FriendMode while browsing a friend
|
||||||
self.delivery = None # DeliveryCoordinator (host machine only)
|
self.delivery = None # DeliveryCoordinator (host machine only)
|
||||||
|
self.followed_view = None # FollowedPlaylistView, made on first use
|
||||||
|
self._followed_snapshot = None
|
||||||
|
self._friend_libraries: dict = {} # token -> (mtime key, FriendLibrary)
|
||||||
|
|
||||||
# ---- the service (host machine only) ----
|
# ---- the service (host machine only) ----
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ class CassetteUi(QObject):
|
|||||||
from lintunes.cassette.service import CassetteService
|
from lintunes.cassette.service import CassetteService
|
||||||
self.service = CassetteService(parent=self)
|
self.service = CassetteService(parent=self)
|
||||||
self.service.changed.connect(self._refresh_selector)
|
self.service.changed.connect(self._refresh_selector)
|
||||||
|
self.service.changed.connect(self._refresh_followed)
|
||||||
self.service.notice.connect(self._on_notice)
|
self.service.notice.connect(self._on_notice)
|
||||||
self.service.step_failed.connect(self._on_step_failed)
|
self.service.step_failed.connect(self._on_step_failed)
|
||||||
self.service.friends_added.connect(self._republish_timer.start)
|
self.service.friends_added.connect(self._republish_timer.start)
|
||||||
@@ -78,7 +82,9 @@ class CassetteUi(QObject):
|
|||||||
self.delivery.step_failed.connect(self._on_step_failed)
|
self.delivery.step_failed.connect(self._on_step_failed)
|
||||||
self.delivery.library_grew.connect(self._on_library_grew)
|
self.delivery.library_grew.connect(self._on_library_grew)
|
||||||
self.service.friends_added.connect(self.delivery.schedule)
|
self.service.friends_added.connect(self.delivery.schedule)
|
||||||
|
self.delivery.passed.connect(self._on_delivery_pass)
|
||||||
self.delivery.start()
|
self.delivery.start()
|
||||||
|
self._hook_window()
|
||||||
self._refresh_selector()
|
self._refresh_selector()
|
||||||
return self.service
|
return self.service
|
||||||
|
|
||||||
@@ -158,6 +164,222 @@ class CassetteUi(QObject):
|
|||||||
return True
|
return True
|
||||||
return self.friend_mode.confirm_leave()
|
return self.friend_mode.confirm_leave()
|
||||||
|
|
||||||
|
# ---- followed playlists (my sidebar) ----
|
||||||
|
|
||||||
|
def _hook_window(self):
|
||||||
|
"""Connect the sidebar tree and the player to followed playlists."""
|
||||||
|
window = self._window
|
||||||
|
sidebar = getattr(window, "sidebar", None)
|
||||||
|
if sidebar is None or getattr(self, "_hooked", False):
|
||||||
|
return
|
||||||
|
self._hooked = True
|
||||||
|
tree = sidebar.tree
|
||||||
|
tree.followed_provider = self.followed_entries
|
||||||
|
tree.followed_selected.connect(self.show_followed)
|
||||||
|
tree.unfollow_requested.connect(self.unfollow)
|
||||||
|
player = getattr(window, "player", None)
|
||||||
|
if player is not None:
|
||||||
|
player.track_resolver = self.resolve_key
|
||||||
|
player.track_changed.connect(lambda _t: self._update_followed_icon())
|
||||||
|
player.playing_changed.connect(lambda _p: self._update_followed_icon())
|
||||||
|
self._refresh_followed()
|
||||||
|
|
||||||
|
def friend_library(self, token: str):
|
||||||
|
"""Their shared library, re-read only when their files changed."""
|
||||||
|
from lintunes.cassette.friend_library import load_friend_library
|
||||||
|
from lintunes.cassette.state import in_dir
|
||||||
|
folder = in_dir(self.service.root, token)
|
||||||
|
stamp = []
|
||||||
|
for name in ("library.json", "playlists.json", "cassette.json"):
|
||||||
|
try:
|
||||||
|
stat = (folder / name).stat()
|
||||||
|
stamp.append((stat.st_mtime_ns, stat.st_size))
|
||||||
|
except OSError:
|
||||||
|
stamp.append(None)
|
||||||
|
cached = self._friend_libraries.get(token)
|
||||||
|
if cached is not None and cached[0] == stamp:
|
||||||
|
return cached[1]
|
||||||
|
friend = self.service.state().friends.get(token)
|
||||||
|
library = load_friend_library(folder, friend.name if friend else "")
|
||||||
|
self._friend_libraries[token] = (stamp, library)
|
||||||
|
return library
|
||||||
|
|
||||||
|
def followed_entries(self) -> list[dict]:
|
||||||
|
if self.service is None:
|
||||||
|
return []
|
||||||
|
entries = []
|
||||||
|
for token, friend in self.service.state().friends.items():
|
||||||
|
if not friend.followed:
|
||||||
|
continue
|
||||||
|
library = self.friend_library(token)
|
||||||
|
for pid in friend.followed:
|
||||||
|
playlist = library.playlist(pid)
|
||||||
|
entries.append({
|
||||||
|
"key": f"cassette:{token}:{pid}",
|
||||||
|
"name": playlist.name if playlist else "(no longer shared)",
|
||||||
|
"token": token, "friend": friend.name,
|
||||||
|
"layout": friend.layout})
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _refresh_followed(self):
|
||||||
|
"""Rebuild the sidebar tree only when what it shows moved."""
|
||||||
|
if not getattr(self, "_hooked", False):
|
||||||
|
return
|
||||||
|
snapshot = self.followed_entries()
|
||||||
|
if snapshot != self._followed_snapshot:
|
||||||
|
self._followed_snapshot = snapshot
|
||||||
|
self._window.sidebar.tree.rebuild()
|
||||||
|
|
||||||
|
def cached_ids(self, token: str) -> set[int]:
|
||||||
|
from lintunes.cassette.delivery import _cache_files
|
||||||
|
from lintunes.cassette.state import cache_dir
|
||||||
|
return {int(p.stem) for p in _cache_files(cache_dir(self.service.root, token))}
|
||||||
|
|
||||||
|
def _cache_path(self, token: str, track_id: int):
|
||||||
|
from lintunes.cassette.delivery import _cache_files
|
||||||
|
from lintunes.cassette.state import cache_dir
|
||||||
|
return next((p for p in _cache_files(cache_dir(self.service.root, token))
|
||||||
|
if p.stem == str(track_id)), None)
|
||||||
|
|
||||||
|
def followed_contents(self, token: str, pid: str):
|
||||||
|
"""(their playlist or None, its Tracks — cached ones pointing at the
|
||||||
|
cache file — and the friend's name)."""
|
||||||
|
import copy
|
||||||
|
library = self.friend_library(token)
|
||||||
|
friend = self.service.state().friends.get(token)
|
||||||
|
playlist = library.playlist(pid)
|
||||||
|
tracks = []
|
||||||
|
for tid in (playlist.track_ids if playlist else []):
|
||||||
|
track = library.tracks.get(tid)
|
||||||
|
if track is None:
|
||||||
|
continue
|
||||||
|
track = copy.copy(track)
|
||||||
|
cached = self._cache_path(token, tid)
|
||||||
|
track.location = str(cached) if cached else ""
|
||||||
|
tracks.append(track)
|
||||||
|
return playlist, tracks, friend.name if friend else "your friend"
|
||||||
|
|
||||||
|
def resolve_key(self, key: str):
|
||||||
|
"""Player's lookup for a friend track key."""
|
||||||
|
from lintunes.gui.followed_view import parse_key
|
||||||
|
import copy
|
||||||
|
parsed = parse_key(key)
|
||||||
|
if parsed is None or self.service is None:
|
||||||
|
return None
|
||||||
|
token, tid = parsed
|
||||||
|
track = self.friend_library(token).tracks.get(tid)
|
||||||
|
cached = self._cache_path(token, tid)
|
||||||
|
if track is None or cached is None:
|
||||||
|
return None
|
||||||
|
track = copy.copy(track)
|
||||||
|
track.location = str(cached)
|
||||||
|
return track
|
||||||
|
|
||||||
|
def show_followed(self, key: str):
|
||||||
|
from lintunes.gui.followed_view import FollowedPlaylistView
|
||||||
|
window = self._window
|
||||||
|
if self.followed_view is None:
|
||||||
|
self.followed_view = FollowedPlaylistView(self)
|
||||||
|
window.content_stack.addWidget(self.followed_view)
|
||||||
|
self.followed_view.play_requested.connect(window.play_keys)
|
||||||
|
self.followed_view.table.tracks_changed.connect(window.update_totals)
|
||||||
|
self.followed_view.show_followed(key)
|
||||||
|
window.content_stack.setCurrentWidget(self.followed_view)
|
||||||
|
self._update_followed_icon()
|
||||||
|
window.update_totals()
|
||||||
|
|
||||||
|
def _update_followed_icon(self):
|
||||||
|
view = self.followed_view
|
||||||
|
if view is None:
|
||||||
|
return
|
||||||
|
player = self._window.player
|
||||||
|
context = getattr(self._window, "now_playing_context", "")
|
||||||
|
key = player.current_key if context == view.context_id() else None
|
||||||
|
view.set_now_playing(key, player.is_playing())
|
||||||
|
|
||||||
|
def _on_delivery_pass(self):
|
||||||
|
self._refresh_followed()
|
||||||
|
if self.followed_view is not None and self.followed_view.isVisible():
|
||||||
|
self.followed_view.refresh()
|
||||||
|
|
||||||
|
def cassette_state(self, token: str, track_id: int):
|
||||||
|
"""A followed song's cassette button: None when I already have it
|
||||||
|
(exact match), on when asked for, off otherwise."""
|
||||||
|
friend = self.service.state().friends.get(token)
|
||||||
|
if friend is None:
|
||||||
|
return None
|
||||||
|
if track_id in friend.cassetted:
|
||||||
|
return "on"
|
||||||
|
track = self.friend_library(token).tracks.get(track_id)
|
||||||
|
if track is not None and self._manager is not None:
|
||||||
|
from lintunes.cassette import matching
|
||||||
|
index = matching.LibraryIndex(self._manager.library.tracks.values())
|
||||||
|
if index.classify(track) == matching.EXACT:
|
||||||
|
return None
|
||||||
|
return "off"
|
||||||
|
|
||||||
|
def toggle_cassette(self, token: str, track_id: int):
|
||||||
|
"""Pressed in a followed playlist: take it into my library. Already
|
||||||
|
in the cache → imported right now; otherwise asked for."""
|
||||||
|
friend = self.service.state().friends.get(token)
|
||||||
|
if friend is None:
|
||||||
|
return
|
||||||
|
cassetted = set(friend.cassetted)
|
||||||
|
if track_id in cassetted:
|
||||||
|
cassetted.discard(track_id)
|
||||||
|
else:
|
||||||
|
cached = self._cache_path(token, track_id)
|
||||||
|
if cached is not None and self._import_cached(cached):
|
||||||
|
self.say("Added to your library.")
|
||||||
|
if self.followed_view is not None:
|
||||||
|
self.followed_view.table.viewport().update()
|
||||||
|
return
|
||||||
|
cassetted.add(track_id)
|
||||||
|
self.service.save_requests(token, cassetted, friend.followed)
|
||||||
|
if self.delivery is not None:
|
||||||
|
self.delivery.schedule()
|
||||||
|
|
||||||
|
def _import_cached(self, path) -> bool:
|
||||||
|
from lintunes.importers.file_importer import stage_file, track_from_file
|
||||||
|
music_dir = self._manager.organize_root() if self._manager else None
|
||||||
|
if music_dir is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
dest, fields = stage_file(path, music_dir)
|
||||||
|
except OSError as exc:
|
||||||
|
self._on_step_failed("Adding the song to your library", str(exc))
|
||||||
|
return False
|
||||||
|
track = track_from_file(dest, fields, path.stem)
|
||||||
|
self._manager.add_track(track)
|
||||||
|
self._on_library_grew([track.track_id])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unfollow(self, key: str):
|
||||||
|
parts = key.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return
|
||||||
|
token, pid = parts[1], parts[2]
|
||||||
|
friend = self.service.state().friends.get(token)
|
||||||
|
if friend is None:
|
||||||
|
return
|
||||||
|
followed = [p for p in friend.followed if p != pid]
|
||||||
|
self.service.save_requests(token, friend.cassetted, followed)
|
||||||
|
if self.followed_view is not None and self.followed_view.key == key:
|
||||||
|
self._window.show_my_library()
|
||||||
|
self._refresh_followed()
|
||||||
|
if self.delivery is not None:
|
||||||
|
self.delivery.schedule() # prunes the cache
|
||||||
|
|
||||||
|
def last_synced_text(self, token: str) -> str:
|
||||||
|
from lintunes.gui.sync_settings_dialog import format_when
|
||||||
|
friend = self.service.state().friends.get(token) if self.service else None
|
||||||
|
if friend is None:
|
||||||
|
return ""
|
||||||
|
return f"{friend.name}'s folder last synced {format_when(friend.last_synced)}"
|
||||||
|
|
||||||
|
def say(self, message: str):
|
||||||
|
self._window.statusBar().showMessage(message, NOTICE_MS)
|
||||||
|
|
||||||
def _on_library_grew(self, track_ids):
|
def _on_library_grew(self, track_ids):
|
||||||
refresh = getattr(self._window, "refresh_library_view", None)
|
refresh = getattr(self._window, "refresh_library_view", None)
|
||||||
if refresh is not None:
|
if refresh is not None:
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""A friend's playlist I follow, in my own sidebar, playing from the cache.
|
||||||
|
|
||||||
|
Their songs, not mine: the track ids are theirs, so nothing here drags into
|
||||||
|
my playlists, edits, or copies. What it does do is **play** — a song whose
|
||||||
|
file has arrived in ``cache/`` plays like any other, queued by an opaque key
|
||||||
|
(``cassette:<token>:<their id>``) that Player resolves through Cassette; a
|
||||||
|
song still on its way is greyed and skipped.
|
||||||
|
|
||||||
|
The cassette column is hidden by default (right-click the header to show
|
||||||
|
it); pressing one asks for that song for my library, the same as in friend
|
||||||
|
mode, and says so at once — there's no Save step here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
from PyQt6.QtGui import QColor
|
||||||
|
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from lintunes.gui.track_table import TrackTableView
|
||||||
|
from lintunes.models.playlist import PlaylistSettings
|
||||||
|
|
||||||
|
NOT_HERE_GREY = QColor("#a0a0a0")
|
||||||
|
FOLLOWED_COLUMNS = ["name", "total_time", "artist", "album", "genre", "year"]
|
||||||
|
|
||||||
|
|
||||||
|
def track_key(token: str, track_id: int) -> str:
|
||||||
|
return f"cassette:{token}:{track_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key(key: str) -> tuple[str, int] | None:
|
||||||
|
parts = key.split(":") if isinstance(key, str) else []
|
||||||
|
if len(parts) == 3 and parts[0] == "cassette" and parts[2].isdigit():
|
||||||
|
return parts[1], int(parts[2])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FollowedPlaylistView(QWidget):
|
||||||
|
play_requested = pyqtSignal(list, int, str) # keys, start, context
|
||||||
|
|
||||||
|
def __init__(self, cassette, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._cassette = cassette # CassetteUi: cache paths, requests
|
||||||
|
self.key = "" # "cassette:<token>:<their pid>"
|
||||||
|
self.token = ""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
self.title = QLabel()
|
||||||
|
font = self.title.font()
|
||||||
|
font.setBold(True)
|
||||||
|
self.title.setFont(font)
|
||||||
|
self.title.setContentsMargins(8, 2, 8, 2)
|
||||||
|
layout.addWidget(self.title)
|
||||||
|
self.table = TrackTableView(playlist_mode=True)
|
||||||
|
self.table.set_read_only(True)
|
||||||
|
self.table.set_content_editable(False)
|
||||||
|
self._settings = PlaylistSettings(visible_columns=list(FOLLOWED_COLUMNS),
|
||||||
|
sort_column="#")
|
||||||
|
self.table.apply_settings(self._settings)
|
||||||
|
self.table.columns_changed.connect(self._on_columns)
|
||||||
|
self.table.model_.foreground_for = self._foreground
|
||||||
|
self.table.cassette_hooks = (self._cassette_state, self._cassette_toggle)
|
||||||
|
self.table.doubleClicked.connect(self._on_double_click)
|
||||||
|
layout.addWidget(self.table)
|
||||||
|
self._cached: set[int] = set()
|
||||||
|
self._friend_name = ""
|
||||||
|
|
||||||
|
def context_id(self) -> str:
|
||||||
|
return f"followed:{self.key}"
|
||||||
|
|
||||||
|
def show_followed(self, key: str):
|
||||||
|
"""Load (or reload) the followed playlist ``key``."""
|
||||||
|
self.key = key
|
||||||
|
parts = key.split(":")
|
||||||
|
self.token, pid = parts[1], parts[2]
|
||||||
|
playlist, tracks, friend_name = self._cassette.followed_contents(self.token, pid)
|
||||||
|
self._friend_name = friend_name
|
||||||
|
self._cached = self._cassette.cached_ids(self.token)
|
||||||
|
name = playlist.name if playlist is not None else "(no longer shared)"
|
||||||
|
self.title.setText(f"{name} · {friend_name}'s playlist")
|
||||||
|
self.table.set_tracks(tracks)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
if self.key:
|
||||||
|
self.show_followed(self.key)
|
||||||
|
|
||||||
|
def status_suffix(self) -> str:
|
||||||
|
"""For the bottom bar: when the friend's folder last synced."""
|
||||||
|
return self._cassette.last_synced_text(self.token)
|
||||||
|
|
||||||
|
def _on_columns(self, columns):
|
||||||
|
self._settings.visible_columns = columns
|
||||||
|
self.table.apply_settings(self._settings)
|
||||||
|
|
||||||
|
def _foreground(self, track):
|
||||||
|
return None if track.track_id in self._cached else NOT_HERE_GREY
|
||||||
|
|
||||||
|
def _cassette_state(self, track_id: int):
|
||||||
|
return self._cassette.cassette_state(self.token, track_id)
|
||||||
|
|
||||||
|
def _cassette_toggle(self, track_id: int):
|
||||||
|
self._cassette.toggle_cassette(self.token, track_id)
|
||||||
|
|
||||||
|
def _on_double_click(self, index):
|
||||||
|
tracks = [self.table.model_.track_at(self.table.model_.source_row(row))
|
||||||
|
for row in range(self.table.model_.rowCount())]
|
||||||
|
clicked = tracks[index.row()]
|
||||||
|
playable = [t for t in tracks if t.track_id in self._cached]
|
||||||
|
if clicked.track_id not in self._cached:
|
||||||
|
self._cassette.say(f"“{clicked.name}” hasn't arrived from "
|
||||||
|
f"{self._friend_name} yet.")
|
||||||
|
return
|
||||||
|
keys = [track_key(self.token, t.track_id) for t in playable]
|
||||||
|
self.play_requested.emit(keys, playable.index(clicked), self.context_id())
|
||||||
|
|
||||||
|
def set_now_playing(self, key, playing: bool):
|
||||||
|
parsed = parse_key(key) if key else None
|
||||||
|
track_id = parsed[1] if parsed and parsed[0] == self.token else None
|
||||||
|
self.table.model_.set_now_playing(track_id, playing)
|
||||||
@@ -333,8 +333,7 @@ class FriendMode(QObject):
|
|||||||
QMessageBox.StandardButton.Cancel)
|
QMessageBox.StandardButton.Cancel)
|
||||||
if answer != QMessageBox.StandardButton.Save:
|
if answer != QMessageBox.StandardButton.Save:
|
||||||
return False
|
return False
|
||||||
self._service.save_requests(self.token, self.cassetted, self.followed,
|
self._service.save_requests(self.token, self.cassetted, self.followed)
|
||||||
self.wanted_ids())
|
|
||||||
self._leave()
|
self._leave()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -279,12 +279,15 @@ class MainWindow(QMainWindow):
|
|||||||
def _update_totals(self):
|
def _update_totals(self):
|
||||||
view = self._content.currentWidget()
|
view = self._content.currentWidget()
|
||||||
count, total_ms = view.table.total_stats()
|
count, total_ms = view.table.total_stats()
|
||||||
|
text = ""
|
||||||
if count:
|
if count:
|
||||||
noun = "track" if count == 1 else "tracks"
|
noun = "track" if count == 1 else "tracks"
|
||||||
self._totals_label.setText(
|
text = f"{count:,} {noun} · {format_total_time(total_ms)}"
|
||||||
f"{count:,} {noun} · {format_total_time(total_ms)}")
|
# A friend's followed playlist also says when their folder last synced.
|
||||||
else:
|
suffix = getattr(view, "status_suffix", None)
|
||||||
self._totals_label.setText("")
|
if suffix is not None and suffix():
|
||||||
|
text = f"{text} · {suffix()}" if text else suffix()
|
||||||
|
self._totals_label.setText(text)
|
||||||
|
|
||||||
# ---- preferences / theming ----
|
# ---- preferences / theming ----
|
||||||
|
|
||||||
@@ -403,6 +406,18 @@ class MainWindow(QMainWindow):
|
|||||||
def sidebar(self):
|
def sidebar(self):
|
||||||
return self._sidebar
|
return self._sidebar
|
||||||
|
|
||||||
|
def play_keys(self, keys, start, context):
|
||||||
|
"""Play a followed friend playlist (queue of friend-track keys)."""
|
||||||
|
self._now_playing_context = context
|
||||||
|
self.player.play_queue(keys, start)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def now_playing_context(self) -> str:
|
||||||
|
return self._now_playing_context
|
||||||
|
|
||||||
|
def update_totals(self):
|
||||||
|
self._update_totals()
|
||||||
|
|
||||||
def refresh_library_view(self, _track_ids=None):
|
def refresh_library_view(self, _track_ids=None):
|
||||||
"""Songs arrived from somewhere other than an import dialog (a friend's
|
"""Songs arrived from somewhere other than an import dialog (a friend's
|
||||||
delivery): show them."""
|
delivery): show them."""
|
||||||
|
|||||||
+81
-1
@@ -74,6 +74,8 @@ class SidebarPanel(QWidget):
|
|||||||
|
|
||||||
self.library_button.clicked.connect(self._on_library_clicked)
|
self.library_button.clicked.connect(self._on_library_clicked)
|
||||||
self.tree.playlist_selected.connect(self._on_playlist_selected)
|
self.tree.playlist_selected.connect(self._on_playlist_selected)
|
||||||
|
self.tree.followed_selected.connect(
|
||||||
|
lambda _key: self.library_button.setChecked(False))
|
||||||
self.friend_button.clicked.connect(self.friend_selector_clicked)
|
self.friend_button.clicked.connect(self.friend_selector_clicked)
|
||||||
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
|
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
|
||||||
self.friend_list.follow_toggled.connect(self.follow_toggled)
|
self.friend_list.follow_toggled.connect(self.follow_toggled)
|
||||||
@@ -316,10 +318,17 @@ class PlaylistTree(QTreeWidget):
|
|||||||
|
|
||||||
playlist_selected = pyqtSignal(str) # persistent_id
|
playlist_selected = pyqtSignal(str) # persistent_id
|
||||||
export_requested = pyqtSignal(str) # persistent_id
|
export_requested = pyqtSignal(str) # persistent_id
|
||||||
|
# A friend's playlist I follow (key "cassette:<token>:<their pid>").
|
||||||
|
followed_selected = pyqtSignal(str)
|
||||||
|
unfollow_requested = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, manager, parent=None):
|
def __init__(self, manager, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
|
# Friend sharing: Callable[[], list[dict]] of followed playlists, each
|
||||||
|
# {key, name, token, friend, layout}. Never Playlists — they aren't
|
||||||
|
# mine, so they stay out of the library and its conflict merge.
|
||||||
|
self.followed_provider = None
|
||||||
self._drop_target: QTreeWidgetItem | None = None
|
self._drop_target: QTreeWidgetItem | None = None
|
||||||
# KIND_ROLE value a drop during the active drag would land on
|
# KIND_ROLE value a drop during the active drag would land on
|
||||||
# ("folder" for internal playlist moves, "playlist" for track drops).
|
# ("folder" for internal playlist moves, "playlist" for track drops).
|
||||||
@@ -391,7 +400,29 @@ class PlaylistTree(QTreeWidget):
|
|||||||
add_level(item, playlist.persistent_id)
|
add_level(item, playlist.persistent_id)
|
||||||
item.setExpanded(playlist.persistent_id in expanded)
|
item.setExpanded(playlist.persistent_id in expanded)
|
||||||
|
|
||||||
|
followed = self.followed_provider() if self.followed_provider else []
|
||||||
|
mixed = [f for f in followed if f["layout"] != "folder"]
|
||||||
|
foldered: dict[str, list] = {}
|
||||||
|
for entry in followed:
|
||||||
|
if entry["layout"] == "folder":
|
||||||
|
foldered.setdefault(entry["token"], []).append(entry)
|
||||||
|
if not mixed and not foldered:
|
||||||
add_level(None, "")
|
add_level(None, "")
|
||||||
|
else:
|
||||||
|
# One alphabetical top level: my playlists and folders, followed
|
||||||
|
# playlists shown mixed in, and a folder per grouped friend.
|
||||||
|
mine = {p.persistent_id: p for p in children.get("", [])}
|
||||||
|
rows = [(p.name.lower(), "mine", pid) for pid, p in mine.items()]
|
||||||
|
rows += [(f["name"].lower(), "followed", f) for f in mixed]
|
||||||
|
rows += [(entries[0]["friend"].lower(), "friend", entries)
|
||||||
|
for entries in foldered.values()]
|
||||||
|
for _key, kind, value in sorted(rows, key=lambda r: (r[0], r[1])):
|
||||||
|
if kind == "mine":
|
||||||
|
self._add_mine(value, children, expanded, add_level)
|
||||||
|
elif kind == "followed":
|
||||||
|
self.addTopLevelItem(self._followed_item(value))
|
||||||
|
else:
|
||||||
|
self.addTopLevelItem(self._friend_folder(value, expanded))
|
||||||
self.blockSignals(False)
|
self.blockSignals(False)
|
||||||
|
|
||||||
if selected_pid:
|
if selected_pid:
|
||||||
@@ -399,13 +430,52 @@ class PlaylistTree(QTreeWidget):
|
|||||||
if item:
|
if item:
|
||||||
self.setCurrentItem(item)
|
self.setCurrentItem(item)
|
||||||
|
|
||||||
|
def _add_mine(self, pid, children, expanded, add_level):
|
||||||
|
"""One of my top-level playlists/folders (and a folder's contents),
|
||||||
|
when the top level is being interleaved with followed ones."""
|
||||||
|
playlist = self._manager.library.playlists[pid]
|
||||||
|
is_folder = playlist.playlist_type == PlaylistType.FOLDER
|
||||||
|
item = QTreeWidgetItem([playlist.name])
|
||||||
|
item.setData(0, PID_ROLE, pid)
|
||||||
|
item.setData(0, KIND_ROLE, "folder" if is_folder else
|
||||||
|
"smart" if playlist.is_smart else "playlist")
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
|
||||||
|
self.addTopLevelItem(item)
|
||||||
|
if is_folder:
|
||||||
|
add_level(item, pid)
|
||||||
|
item.setExpanded(pid in expanded)
|
||||||
|
|
||||||
|
def _followed_item(self, entry) -> QTreeWidgetItem:
|
||||||
|
from lintunes.gui.cassette_art import ufo_icon
|
||||||
|
item = QTreeWidgetItem([entry["name"]])
|
||||||
|
item.setData(0, PID_ROLE, entry["key"])
|
||||||
|
item.setData(0, KIND_ROLE, "followed")
|
||||||
|
item.setIcon(0, ufo_icon())
|
||||||
|
item.setToolTip(0, f"{entry['friend']}'s playlist")
|
||||||
|
item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _friend_folder(self, entries, expanded) -> QTreeWidgetItem:
|
||||||
|
from lintunes.gui.cassette_art import ufo_icon
|
||||||
|
token = entries[0]["token"]
|
||||||
|
folder = QTreeWidgetItem([entries[0]["friend"]])
|
||||||
|
folder.setData(0, PID_ROLE, f"cassette-folder:{token}")
|
||||||
|
folder.setData(0, KIND_ROLE, "friend-folder")
|
||||||
|
folder.setIcon(0, ufo_icon())
|
||||||
|
folder.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
|
||||||
|
for entry in sorted(entries, key=lambda e: e["name"].lower()):
|
||||||
|
folder.addChild(self._followed_item(entry))
|
||||||
|
folder.setExpanded(f"cassette-folder:{token}" in expanded)
|
||||||
|
return folder
|
||||||
|
|
||||||
def _expanded_pids(self) -> set[str]:
|
def _expanded_pids(self) -> set[str]:
|
||||||
"""Persistent ids of folders currently expanded in the tree."""
|
"""Persistent ids of folders currently expanded in the tree."""
|
||||||
result: set[str] = set()
|
result: set[str] = set()
|
||||||
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
||||||
while stack:
|
while stack:
|
||||||
item = stack.pop()
|
item = stack.pop()
|
||||||
if item.data(0, KIND_ROLE) == "folder" and item.isExpanded():
|
if item.data(0, KIND_ROLE) in ("folder", "friend-folder") \
|
||||||
|
and item.isExpanded():
|
||||||
result.add(item.data(0, PID_ROLE))
|
result.add(item.data(0, PID_ROLE))
|
||||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||||
return result
|
return result
|
||||||
@@ -497,6 +567,8 @@ class PlaylistTree(QTreeWidget):
|
|||||||
return
|
return
|
||||||
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
|
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
|
||||||
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
||||||
|
elif current.data(0, KIND_ROLE) == "followed":
|
||||||
|
self.followed_selected.emit(current.data(0, PID_ROLE))
|
||||||
|
|
||||||
def edit(self, index, trigger=QAbstractItemView.EditTrigger.AllEditTriggers,
|
def edit(self, index, trigger=QAbstractItemView.EditTrigger.AllEditTriggers,
|
||||||
event=None):
|
event=None):
|
||||||
@@ -528,6 +600,14 @@ class PlaylistTree(QTreeWidget):
|
|||||||
item = self.itemAt(pos)
|
item = self.itemAt(pos)
|
||||||
kind = item.data(0, KIND_ROLE) if item else ""
|
kind = item.data(0, KIND_ROLE) if item else ""
|
||||||
pid = item.data(0, PID_ROLE) if item else ""
|
pid = item.data(0, PID_ROLE) if item else ""
|
||||||
|
if kind == "followed":
|
||||||
|
menu = QMenu(self)
|
||||||
|
unfollow = menu.addAction("Unfollow")
|
||||||
|
if menu.exec(self.viewport().mapToGlobal(pos)) is unfollow:
|
||||||
|
self.unfollow_requested.emit(pid)
|
||||||
|
return
|
||||||
|
if kind == "friend-folder":
|
||||||
|
return
|
||||||
# New items land inside the folder under the cursor (or at top level)
|
# New items land inside the folder under the cursor (or at top level)
|
||||||
parent_pid = ""
|
parent_pid = ""
|
||||||
if kind == "folder":
|
if kind == "folder":
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
from PyQt6.QtGui import QImage, QPixmap
|
from PyQt6.QtGui import QImage, QPixmap
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
QButtonGroup, QCheckBox, QDialog, QRadioButton, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
||||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
|
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
|
||||||
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
|
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
@@ -21,7 +21,9 @@ from PyQt6.QtWidgets import (
|
|||||||
from lintunes.cassette import probe as probe_mod
|
from lintunes.cassette import probe as probe_mod
|
||||||
from lintunes.cassette import publish
|
from lintunes.cassette import publish
|
||||||
from lintunes.cassette import syncthing_api as st
|
from lintunes.cassette import syncthing_api as st
|
||||||
from lintunes.cassette.state import OPEN, in_dir
|
from lintunes.cassette.state import (
|
||||||
|
FOLLOW_LIBRARY, FOLLOW_SEPARATE, LAYOUT_FOLDER, LAYOUT_MIXED, OPEN, in_dir,
|
||||||
|
)
|
||||||
|
|
||||||
AVATAR_PX = 256
|
AVATAR_PX = 256
|
||||||
AVATAR_PREVIEW_PX = 72
|
AVATAR_PREVIEW_PX = 72
|
||||||
@@ -296,6 +298,28 @@ class FriendPage(QWidget):
|
|||||||
box_layout.addWidget(self.details)
|
box_layout.addWidget(self.details)
|
||||||
layout.addWidget(box)
|
layout.addWidget(box)
|
||||||
|
|
||||||
|
follow = QGroupBox(f"Playlists you follow from {friend.name}")
|
||||||
|
follow_layout = QVBoxLayout(follow)
|
||||||
|
self.layout_mixed = QRadioButton("Mix them in with my playlists")
|
||||||
|
self.layout_folder = QRadioButton(f"Put them in a folder called “{friend.name}”")
|
||||||
|
self.mode_separate = QRadioButton(
|
||||||
|
"Keep their songs separate from my library (they still play)")
|
||||||
|
self.mode_library = QRadioButton("Add their songs to my library")
|
||||||
|
for first, second in ((self.layout_mixed, self.layout_folder),
|
||||||
|
(self.mode_separate, self.mode_library)):
|
||||||
|
group = QButtonGroup(self)
|
||||||
|
group.addButton(first)
|
||||||
|
group.addButton(second)
|
||||||
|
self.layout_folder.setChecked(friend.layout == LAYOUT_FOLDER)
|
||||||
|
self.layout_mixed.setChecked(friend.layout != LAYOUT_FOLDER)
|
||||||
|
self.mode_library.setChecked(friend.followed_mode == FOLLOW_LIBRARY)
|
||||||
|
self.mode_separate.setChecked(friend.followed_mode != FOLLOW_LIBRARY)
|
||||||
|
for button in (self.layout_mixed, self.layout_folder,
|
||||||
|
self.mode_separate, self.mode_library):
|
||||||
|
follow_layout.addWidget(button)
|
||||||
|
button.toggled.connect(self._on_follow_settings)
|
||||||
|
layout.addWidget(follow)
|
||||||
|
|
||||||
self.using_general = QLabel("Using general settings — see General.")
|
self.using_general = QLabel("Using general settings — see General.")
|
||||||
self.using_general.setVisible(False)
|
self.using_general.setVisible(False)
|
||||||
layout.addWidget(self.using_general)
|
layout.addWidget(self.using_general)
|
||||||
@@ -314,6 +338,12 @@ class FriendPage(QWidget):
|
|||||||
layout.addLayout(row)
|
layout.addLayout(row)
|
||||||
self.show_status(None)
|
self.show_status(None)
|
||||||
|
|
||||||
|
def _on_follow_settings(self, _checked=False):
|
||||||
|
self.friend.layout = (LAYOUT_FOLDER if self.layout_folder.isChecked()
|
||||||
|
else LAYOUT_MIXED)
|
||||||
|
self.friend.followed_mode = (FOLLOW_LIBRARY if self.mode_library.isChecked()
|
||||||
|
else FOLLOW_SEPARATE)
|
||||||
|
|
||||||
def ensure_selector(self, library, same_for_all: bool):
|
def ensure_selector(self, library, same_for_all: bool):
|
||||||
if self.selector is None and library is not None:
|
if self.selector is None and library is not None:
|
||||||
from lintunes.gui.share_selector import ShareSelector
|
from lintunes.gui.share_selector import ShareSelector
|
||||||
|
|||||||
+28
-3
@@ -418,6 +418,13 @@ class Player(QObject):
|
|||||||
# here rather than in a sink so both playback paths honor it.
|
# here rather than in a sink so both playback paths honor it.
|
||||||
self._stop_at_ms = 0
|
self._stop_at_ms = 0
|
||||||
self._counted_finish_id: int | None = None
|
self._counted_finish_id: int | None = None
|
||||||
|
# Queue entries are my track ids (int) or, for a friend's followed
|
||||||
|
# playlist, opaque string keys ("cassette:<token>:<their id>")
|
||||||
|
# resolved through this callback — Player stays manager-free about
|
||||||
|
# friends. Plays of friend tracks scrobble but don't count in my
|
||||||
|
# journal.
|
||||||
|
self.track_resolver = None # Callable[[str], Track | None]
|
||||||
|
self._current_key = None
|
||||||
self._volume = 1.0 # logical 0..1; what the volume slider shows
|
self._volume = 1.0 # logical 0..1; what the volume slider shows
|
||||||
|
|
||||||
self._local = LocalSink(self)
|
self._local = LocalSink(self)
|
||||||
@@ -624,6 +631,7 @@ class Player(QObject):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
self._sink.stop()
|
self._sink.stop()
|
||||||
self._current_track = None
|
self._current_track = None
|
||||||
|
self._current_key = None
|
||||||
self.track_changed.emit(None)
|
self.track_changed.emit(None)
|
||||||
|
|
||||||
def next(self):
|
def next(self):
|
||||||
@@ -668,7 +676,8 @@ class Player(QObject):
|
|||||||
AAC). The skip is a loop, not recursion: shuffling into a run of
|
AAC). The skip is a loop, not recursion: shuffling into a run of
|
||||||
unsupported tracks must not walk the stack down."""
|
unsupported tracks must not walk the stack down."""
|
||||||
while 0 <= self._index < len(self._queue):
|
while 0 <= self._index < len(self._queue):
|
||||||
track = self._manager.library.tracks.get(self._queue[self._index])
|
key = self._queue[self._index]
|
||||||
|
track = self._resolve(key)
|
||||||
if track is None:
|
if track is None:
|
||||||
# The queue references a track that's no longer in the library;
|
# The queue references a track that's no longer in the library;
|
||||||
# there's nothing to locate, so just report and stop.
|
# there's nothing to locate, so just report and stop.
|
||||||
@@ -700,6 +709,7 @@ class Player(QObject):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self._current_track = track
|
self._current_track = track
|
||||||
|
self._current_key = key
|
||||||
# Arm the custom stop time for this track. It's honored only when
|
# Arm the custom stop time for this track. It's honored only when
|
||||||
# it falls before the end (otherwise the natural finish handles it).
|
# it falls before the end (otherwise the natural finish handles it).
|
||||||
if track.stop_time > 0 and (track.total_time == 0
|
if track.stop_time > 0 and (track.total_time == 0
|
||||||
@@ -754,8 +764,23 @@ class Player(QObject):
|
|||||||
def _note_finished(self, track):
|
def _note_finished(self, track):
|
||||||
"""Record a completed play once, guarding against a double count if
|
"""Record a completed play once, guarding against a double count if
|
||||||
both the stop-time and end-of-media paths fire for the same track."""
|
both the stop-time and end-of-media paths fire for the same track."""
|
||||||
if track is None or track.track_id == self._counted_finish_id:
|
if track is None:
|
||||||
return
|
return
|
||||||
self._counted_finish_id = track.track_id
|
key = (self._current_key if track is self._current_track
|
||||||
|
and self._current_key is not None else track.track_id)
|
||||||
|
if key == self._counted_finish_id:
|
||||||
|
return
|
||||||
|
self._counted_finish_id = key
|
||||||
|
if not isinstance(key, str):
|
||||||
self._manager.record_play(track.track_id)
|
self._manager.record_play(track.track_id)
|
||||||
self.track_finished.emit(track)
|
self.track_finished.emit(track)
|
||||||
|
|
||||||
|
def _resolve(self, key):
|
||||||
|
if isinstance(key, str):
|
||||||
|
return self.track_resolver(key) if self.track_resolver else None
|
||||||
|
return self._manager.library.tracks.get(key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self):
|
||||||
|
"""The queue entry playing: a track id, or a friend track's key."""
|
||||||
|
return self._current_key
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ def test_loop_between_two_libraries(qapp, tmp_path, mp3_file):
|
|||||||
_sync(alice.root, bob.root, token)
|
_sync(alice.root, bob.root, token)
|
||||||
|
|
||||||
# Bob cassettes Alice's song and saves.
|
# Bob cassettes Alice's song and saves.
|
||||||
bob.save_requests(token, {5}, [], {5})
|
bob.save_requests(token, {5}, [])
|
||||||
_sync(alice.root, bob.root, token)
|
_sync(alice.root, bob.root, token)
|
||||||
|
|
||||||
def run(coordinator):
|
def run(coordinator):
|
||||||
@@ -326,7 +326,7 @@ def test_real_delivery(qapp, tmp_path, syncthing_pair, mp3_file):
|
|||||||
edited.friends[token].selection = Selection(all_library=True)
|
edited.friends[token].selection = Selection(all_library=True)
|
||||||
a.apply_settings(edited)
|
a.apply_settings(edited)
|
||||||
assert settle(lambda: 5 in load_their_ids(b, token))
|
assert settle(lambda: 5 in load_their_ids(b, token))
|
||||||
b.save_requests(token, {5}, [], {5})
|
b.save_requests(token, {5}, [])
|
||||||
assert settle(lambda: len(bob_mgr.library.tracks) == 1), "never arrived"
|
assert settle(lambda: len(bob_mgr.library.tracks) == 1), "never arrived"
|
||||||
assert settle(lambda: not any((out_dir(a.root, token) / "outbox").glob("*"))), \
|
assert settle(lambda: not any((out_dir(a.root, token) / "outbox").glob("*"))), \
|
||||||
"sender's outbox never cleaned up"
|
"sender's outbox never cleaned up"
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Round 69: followed playlists in my sidebar.
|
||||||
|
|
||||||
|
* A followed friend playlist shows in my sidebar with a purple UFO — mixed in
|
||||||
|
alphabetically, or inside a folder named after the friend (a per-friend
|
||||||
|
setting) — and is never a Playlist of mine, so it stays out of the
|
||||||
|
library and its conflict merge.
|
||||||
|
* It plays from the cache: Player queues opaque keys
|
||||||
|
("cassette:<token>:<their id>") resolved through Cassette. Plays scrobble
|
||||||
|
but don't count in my play journal. Songs not here yet are grey and skipped.
|
||||||
|
* Its cassette column is hidden by default; pressing one takes the song into
|
||||||
|
my library — straight from the cache if it's there, otherwise by asking.
|
||||||
|
* Unfollowing frees the cache; requests never re-ask for cached songs.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lintunes.cassette import delivery, matching, publish, share
|
||||||
|
from lintunes.cassette import requests as req
|
||||||
|
from lintunes.cassette.service import CassetteService
|
||||||
|
from lintunes.cassette.state import (
|
||||||
|
CassetteState, Friend, Selection, cache_dir, in_dir, out_dir,
|
||||||
|
)
|
||||||
|
from lintunes.gui.followed_view import parse_key, track_key
|
||||||
|
from lintunes.models import Playlist, Track
|
||||||
|
from lintunes.models.library import Library
|
||||||
|
|
||||||
|
TOKEN = "0123456789abcdef"
|
||||||
|
|
||||||
|
|
||||||
|
def _friend_share(root, mp3_file, cached=(10,)):
|
||||||
|
theirs = Library()
|
||||||
|
for tid, name in ((10, "One"), (11, "Two"), (12, "Three")):
|
||||||
|
theirs.tracks[tid] = Track(track_id=tid, name=name, artist="Sam's Band",
|
||||||
|
album="Demo", total_time=1000, size=100)
|
||||||
|
theirs.playlists["P"] = Playlist(name="Road Trip", persistent_id="P",
|
||||||
|
track_ids=[11, 10])
|
||||||
|
share.publish_share(in_dir(root, TOKEN), theirs,
|
||||||
|
Selection(all_library=True, all_playlists=True))
|
||||||
|
publish.publish_intro(in_dir(root, TOKEN), "Sam", None)
|
||||||
|
cache = cache_dir(root, TOKEN)
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
for tid in cached:
|
||||||
|
shutil.copyfile(mp3_file, cache / f"{tid}.mp3")
|
||||||
|
return theirs
|
||||||
|
|
||||||
|
|
||||||
|
# ---- keys and the player ----
|
||||||
|
|
||||||
|
class TestPlayer:
|
||||||
|
def test_keys(self):
|
||||||
|
assert parse_key(track_key(TOKEN, 12)) == (TOKEN, 12)
|
||||||
|
assert parse_key("12") is None and parse_key(12) is None
|
||||||
|
|
||||||
|
def test_friend_track_scrobbles_but_isnt_counted(self, qapp, tmp_path, mp3_file):
|
||||||
|
from lintunes.player import Player
|
||||||
|
manager = MagicMock()
|
||||||
|
player = Player(manager)
|
||||||
|
friend_track = Track(track_id=5, name="Theirs", location=str(mp3_file))
|
||||||
|
player.track_resolver = lambda key: friend_track if key == "cassette:t:5" else None
|
||||||
|
finished = []
|
||||||
|
player.track_finished.connect(finished.append)
|
||||||
|
player._current_track = friend_track
|
||||||
|
player._current_key = "cassette:t:5"
|
||||||
|
player._note_finished(friend_track)
|
||||||
|
player._note_finished(friend_track)
|
||||||
|
manager.record_play.assert_not_called()
|
||||||
|
assert finished == [friend_track] # Last.fm still hears it
|
||||||
|
assert player._resolve("cassette:t:5") is friend_track
|
||||||
|
assert player._resolve("cassette:t:6") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the service never re-asks for cached songs ----
|
||||||
|
|
||||||
|
class TestRequests:
|
||||||
|
def test_followed_songs_already_cached_arent_requested(self, qapp, tmp_path, mp3_file):
|
||||||
|
root = tmp_path / "root"
|
||||||
|
_friend_share(root, mp3_file, cached=(10,))
|
||||||
|
service = CassetteService(root=root, config_loader=lambda: {})
|
||||||
|
service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam")
|
||||||
|
service.save_requests(TOKEN, {12}, ["P"])
|
||||||
|
assert req.read_requests(out_dir(root, TOKEN)) == {11, 12}
|
||||||
|
|
||||||
|
def test_unfollowed_cache_is_pruned(self, tmp_path, mp3_file):
|
||||||
|
root = tmp_path / "root"
|
||||||
|
_friend_share(root, mp3_file, cached=(10, 12))
|
||||||
|
friend = Friend(token=TOKEN, device_id="D", name="Sam", followed=["P"])
|
||||||
|
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||||
|
assert [p.name for p in plan.cache_prune] == ["12.mp3"]
|
||||||
|
friend.followed = []
|
||||||
|
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||||
|
assert sorted(p.name for p in plan.cache_prune) == ["10.mp3", "12.mp3"]
|
||||||
|
delivery.run_plans([], [plan], None)
|
||||||
|
assert list(cache_dir(root, TOKEN).iterdir()) == []
|
||||||
|
|
||||||
|
def test_no_playlists_file_prunes_nothing(self, tmp_path, mp3_file):
|
||||||
|
root = tmp_path / "root"
|
||||||
|
_friend_share(root, mp3_file, cached=(10,))
|
||||||
|
(in_dir(root, TOKEN) / share.PLAYLISTS_FILE).unlink()
|
||||||
|
friend = Friend(token=TOKEN, device_id="D", name="Sam")
|
||||||
|
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||||
|
assert plan.cache_prune == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the sidebar and the view ----
|
||||||
|
|
||||||
|
class _Window:
|
||||||
|
def __init__(self, qapp):
|
||||||
|
from PyQt6.QtWidgets import QMainWindow, QStackedWidget
|
||||||
|
from lintunes.gui.sidebar import SidebarPanel
|
||||||
|
self.main = QMainWindow()
|
||||||
|
self.content_stack = QStackedWidget()
|
||||||
|
self.main.setCentralWidget(self.content_stack)
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.library.playlists = {
|
||||||
|
"M1": Playlist(name="Morning", persistent_id="M1"),
|
||||||
|
"M2": Playlist(name="Zebra", persistent_id="M2")}
|
||||||
|
self.sidebar = SidebarPanel(manager, MagicMock())
|
||||||
|
self.player = MagicMock()
|
||||||
|
self.player.current_key = None
|
||||||
|
self.player.is_playing.return_value = False
|
||||||
|
self.now_playing_context = ""
|
||||||
|
self.played = []
|
||||||
|
self.totals = 0
|
||||||
|
|
||||||
|
def statusBar(self):
|
||||||
|
return self.main.statusBar()
|
||||||
|
|
||||||
|
def play_keys(self, keys, start, context):
|
||||||
|
self.played.append((keys, start, context))
|
||||||
|
|
||||||
|
def update_totals(self):
|
||||||
|
self.totals += 1
|
||||||
|
|
||||||
|
def show_my_library(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def followed(qapp, tmp_path, mp3_file):
|
||||||
|
from lintunes.gui.cassette_ui import CassetteUi
|
||||||
|
from lintunes.library_manager import LibraryManager
|
||||||
|
root = tmp_path / "root"
|
||||||
|
_friend_share(root, mp3_file, cached=(10,))
|
||||||
|
service = CassetteService(root=root, config_loader=lambda: {})
|
||||||
|
service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam",
|
||||||
|
followed=["P"], layout="mixed")
|
||||||
|
manager = LibraryManager(Library(), tmp_path / "data")
|
||||||
|
manager.organize_root = lambda: tmp_path / "Music"
|
||||||
|
window = _Window(qapp)
|
||||||
|
ui = CassetteUi(window.main, MagicMock(), manager)
|
||||||
|
ui._window = window
|
||||||
|
ui.service = service
|
||||||
|
ui._hook_window()
|
||||||
|
return ui, window, service, manager
|
||||||
|
|
||||||
|
|
||||||
|
class TestSidebar:
|
||||||
|
def test_mixed_in_alphabetically(self, followed):
|
||||||
|
ui, window, _, _ = followed
|
||||||
|
tree = window.sidebar.tree
|
||||||
|
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
|
||||||
|
assert names == ["Morning", "Road Trip", "Zebra"]
|
||||||
|
|
||||||
|
def test_in_a_friend_folder(self, followed):
|
||||||
|
ui, window, service, _ = followed
|
||||||
|
service._state.friends[TOKEN].layout = "folder"
|
||||||
|
ui._refresh_followed()
|
||||||
|
tree = window.sidebar.tree
|
||||||
|
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
|
||||||
|
assert names == ["Morning", "Sam", "Zebra"]
|
||||||
|
folder = tree.topLevelItem(1)
|
||||||
|
assert folder.child(0).text(0) == "Road Trip"
|
||||||
|
|
||||||
|
def test_followed_items_arent_editable_or_draggable(self, followed):
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
_, window, _, _ = followed
|
||||||
|
tree = window.sidebar.tree
|
||||||
|
item = tree._find_item(f"cassette:{TOKEN}:P")
|
||||||
|
assert not item.flags() & Qt.ItemFlag.ItemIsEditable
|
||||||
|
assert not item.flags() & Qt.ItemFlag.ItemIsDragEnabled
|
||||||
|
|
||||||
|
|
||||||
|
class TestFollowedView:
|
||||||
|
def test_cached_plays_uncached_is_grey(self, followed):
|
||||||
|
ui, window, _, _ = followed
|
||||||
|
ui.show_followed(f"cassette:{TOKEN}:P")
|
||||||
|
view = ui.followed_view
|
||||||
|
assert [t.track_id for t in view.table.model_.tracks] == [11, 10]
|
||||||
|
assert view._foreground(view.table.model_.tracks[0]) is not None # 11 not here
|
||||||
|
assert view._foreground(view.table.model_.tracks[1]) is None # 10 cached
|
||||||
|
view._on_double_click(view.table.model_.index(1, 0))
|
||||||
|
assert window.played == [([track_key(TOKEN, 10)], 0,
|
||||||
|
f"followed:cassette:{TOKEN}:P")]
|
||||||
|
view._on_double_click(view.table.model_.index(0, 0)) # not here yet
|
||||||
|
assert len(window.played) == 1
|
||||||
|
assert "last synced" in view.status_suffix()
|
||||||
|
|
||||||
|
def test_cassette_column_hidden_by_default(self, followed):
|
||||||
|
from lintunes.gui.track_table import CASSETTE_FIELD
|
||||||
|
ui, _, _, _ = followed
|
||||||
|
ui.show_followed(f"cassette:{TOKEN}:P")
|
||||||
|
assert CASSETTE_FIELD not in ui.followed_view.table.model_.fields
|
||||||
|
assert ui.followed_view.table.cassette_hooks is not None
|
||||||
|
|
||||||
|
def test_resolve_points_at_the_cache(self, followed):
|
||||||
|
ui, _, service, _ = followed
|
||||||
|
track = ui.resolve_key(track_key(TOKEN, 10))
|
||||||
|
assert Path(track.location).parent == cache_dir(service.root, TOKEN)
|
||||||
|
assert ui.resolve_key(track_key(TOKEN, 11)) is None
|
||||||
|
|
||||||
|
def test_cassette_a_cached_song_imports_it_now(self, followed, tmp_path):
|
||||||
|
ui, _, service, manager = followed
|
||||||
|
ui.toggle_cassette(TOKEN, 10)
|
||||||
|
[track] = manager.library.tracks.values()
|
||||||
|
assert tmp_path / "Music" in Path(track.location).parents
|
||||||
|
assert track.play_count == 0
|
||||||
|
assert service.state().friends[TOKEN].cassetted == []
|
||||||
|
|
||||||
|
def test_cassette_an_uncached_song_asks_for_it(self, followed):
|
||||||
|
ui, _, service, manager = followed
|
||||||
|
ui.toggle_cassette(TOKEN, 12)
|
||||||
|
assert service.state().friends[TOKEN].cassetted == [12]
|
||||||
|
assert 12 in req.read_requests(out_dir(service.root, TOKEN))
|
||||||
|
assert ui.cassette_state(TOKEN, 12) == "on"
|
||||||
|
ui.toggle_cassette(TOKEN, 12)
|
||||||
|
assert ui.cassette_state(TOKEN, 12) == "off"
|
||||||
|
|
||||||
|
def test_unfollow(self, followed):
|
||||||
|
ui, window, service, _ = followed
|
||||||
|
ui.unfollow(f"cassette:{TOKEN}:P")
|
||||||
|
assert service.state().friends[TOKEN].followed == []
|
||||||
|
tree = window.sidebar.tree
|
||||||
|
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
|
||||||
|
assert names == ["Morning", "Zebra"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings:
|
||||||
|
def test_follow_settings_on_the_friend_page(self, qapp, tmp_path):
|
||||||
|
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
|
||||||
|
from tests.test_round64 import _NoProbe
|
||||||
|
state = CassetteState()
|
||||||
|
state.friends["t"] = Friend(token="t", device_id="D", name="Sam")
|
||||||
|
dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe())
|
||||||
|
page = dialog.friend_pages["t"]
|
||||||
|
assert page.layout_folder.isChecked() and page.mode_separate.isChecked()
|
||||||
|
page.layout_mixed.setChecked(True)
|
||||||
|
page.mode_library.setChecked(True)
|
||||||
|
friend = dialog.state.friends["t"]
|
||||||
|
assert (friend.layout, friend.followed_mode) == ("mixed", "library")
|
||||||
Reference in New Issue
Block a user