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
|
||||
between import and requests-rewrite harmless; a missing `library.json`
|
||||
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
|
||||
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""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_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]:
|
||||
@@ -191,6 +194,16 @@ def plan_receive(friend, root: Path, my_index: matching.LibraryIndex,
|
||||
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
|
||||
@@ -247,6 +260,11 @@ def run_plans(send_plans, receive_plans, music_dir: Path | None,
|
||||
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
|
||||
|
||||
@@ -239,11 +239,11 @@ class CassetteService(QObject):
|
||||
|
||||
# ---- requests ----
|
||||
|
||||
def save_requests(self, token: str, cassetted, followed, requested):
|
||||
"""Friend mode's Save and Close: remember what I cassetted and which
|
||||
playlists I follow, and tell the friend (``requests.json`` in my
|
||||
folder to them) which of their track ids to send."""
|
||||
from lintunes.cassette import requests as req
|
||||
def save_requests(self, token: str, cassetted, followed):
|
||||
"""Friend mode's Save and Close (and a cassette pressed in a followed
|
||||
playlist): remember what I cassetted and which playlists I follow,
|
||||
and tell the friend (``requests.json`` in my folder to them) which of
|
||||
their songs to send."""
|
||||
with self._lock:
|
||||
friend = self._state.friends.get(token)
|
||||
if friend is None:
|
||||
@@ -251,9 +251,22 @@ class CassetteService(QObject):
|
||||
friend.cassetted = sorted(int(t) for t in cassetted)
|
||||
friend.followed = sorted(followed)
|
||||
self._save()
|
||||
req.write_requests(out_dir(self.root, token), requested)
|
||||
self._write_requests(friend)
|
||||
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):
|
||||
"""After a batch arrived: what's still wanted, and the requests file
|
||||
that says so (rewritten only if it changed)."""
|
||||
|
||||
@@ -24,6 +24,7 @@ class DeliveryCoordinator(QObject):
|
||||
notice = pyqtSignal(str)
|
||||
step_failed = pyqtSignal(str, str)
|
||||
library_grew = pyqtSignal(list) # new track ids
|
||||
passed = pyqtSignal() # a pass finished (the cache may have moved)
|
||||
|
||||
def __init__(self, service, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -96,9 +97,10 @@ class DeliveryCoordinator(QObject):
|
||||
friend, root, index,
|
||||
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 \
|
||||
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.
|
||||
self._finish_receives(receives, set())
|
||||
self.passed.emit()
|
||||
return
|
||||
self._worker.start(sends, receives, self._manager.organize_root())
|
||||
|
||||
@@ -125,6 +127,7 @@ class DeliveryCoordinator(QObject):
|
||||
f"{names.get(token, 'a friend')}.")
|
||||
for what, error in result.errors[:3]:
|
||||
self.step_failed.emit(what, error)
|
||||
self.passed.emit()
|
||||
if self._again:
|
||||
self._again = False
|
||||
self._debounce.start()
|
||||
|
||||
@@ -38,6 +38,9 @@ class CassetteUi(QObject):
|
||||
self.service = None
|
||||
self.friend_mode = None # FriendMode while browsing a friend
|
||||
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) ----
|
||||
|
||||
@@ -54,6 +57,7 @@ class CassetteUi(QObject):
|
||||
from lintunes.cassette.service import CassetteService
|
||||
self.service = CassetteService(parent=self)
|
||||
self.service.changed.connect(self._refresh_selector)
|
||||
self.service.changed.connect(self._refresh_followed)
|
||||
self.service.notice.connect(self._on_notice)
|
||||
self.service.step_failed.connect(self._on_step_failed)
|
||||
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.library_grew.connect(self._on_library_grew)
|
||||
self.service.friends_added.connect(self.delivery.schedule)
|
||||
self.delivery.passed.connect(self._on_delivery_pass)
|
||||
self.delivery.start()
|
||||
self._hook_window()
|
||||
self._refresh_selector()
|
||||
return self.service
|
||||
|
||||
@@ -158,6 +164,222 @@ class CassetteUi(QObject):
|
||||
return True
|
||||
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):
|
||||
refresh = getattr(self._window, "refresh_library_view", 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)
|
||||
if answer != QMessageBox.StandardButton.Save:
|
||||
return False
|
||||
self._service.save_requests(self.token, self.cassetted, self.followed,
|
||||
self.wanted_ids())
|
||||
self._service.save_requests(self.token, self.cassetted, self.followed)
|
||||
self._leave()
|
||||
return True
|
||||
|
||||
|
||||
@@ -279,12 +279,15 @@ class MainWindow(QMainWindow):
|
||||
def _update_totals(self):
|
||||
view = self._content.currentWidget()
|
||||
count, total_ms = view.table.total_stats()
|
||||
text = ""
|
||||
if count:
|
||||
noun = "track" if count == 1 else "tracks"
|
||||
self._totals_label.setText(
|
||||
f"{count:,} {noun} · {format_total_time(total_ms)}")
|
||||
else:
|
||||
self._totals_label.setText("")
|
||||
text = f"{count:,} {noun} · {format_total_time(total_ms)}"
|
||||
# A friend's followed playlist also says when their folder last synced.
|
||||
suffix = getattr(view, "status_suffix", None)
|
||||
if suffix is not None and suffix():
|
||||
text = f"{text} · {suffix()}" if text else suffix()
|
||||
self._totals_label.setText(text)
|
||||
|
||||
# ---- preferences / theming ----
|
||||
|
||||
@@ -403,6 +406,18 @@ class MainWindow(QMainWindow):
|
||||
def sidebar(self):
|
||||
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):
|
||||
"""Songs arrived from somewhere other than an import dialog (a friend's
|
||||
delivery): show them."""
|
||||
|
||||
+82
-2
@@ -74,6 +74,8 @@ class SidebarPanel(QWidget):
|
||||
|
||||
self.library_button.clicked.connect(self._on_library_clicked)
|
||||
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_list.playlist_selected.connect(self._on_friend_playlist)
|
||||
self.friend_list.follow_toggled.connect(self.follow_toggled)
|
||||
@@ -316,10 +318,17 @@ class PlaylistTree(QTreeWidget):
|
||||
|
||||
playlist_selected = 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):
|
||||
super().__init__(parent)
|
||||
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
|
||||
# KIND_ROLE value a drop during the active drag would land on
|
||||
# ("folder" for internal playlist moves, "playlist" for track drops).
|
||||
@@ -391,7 +400,29 @@ class PlaylistTree(QTreeWidget):
|
||||
add_level(item, playlist.persistent_id)
|
||||
item.setExpanded(playlist.persistent_id in expanded)
|
||||
|
||||
add_level(None, "")
|
||||
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, "")
|
||||
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)
|
||||
|
||||
if selected_pid:
|
||||
@@ -399,13 +430,52 @@ class PlaylistTree(QTreeWidget):
|
||||
if 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]:
|
||||
"""Persistent ids of folders currently expanded in the tree."""
|
||||
result: set[str] = set()
|
||||
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
||||
while stack:
|
||||
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))
|
||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||
return result
|
||||
@@ -497,6 +567,8 @@ class PlaylistTree(QTreeWidget):
|
||||
return
|
||||
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
|
||||
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,
|
||||
event=None):
|
||||
@@ -528,6 +600,14 @@ class PlaylistTree(QTreeWidget):
|
||||
item = self.itemAt(pos)
|
||||
kind = item.data(0, KIND_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)
|
||||
parent_pid = ""
|
||||
if kind == "folder":
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QImage, QPixmap
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
||||
QButtonGroup, QCheckBox, QDialog, QRadioButton, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
|
||||
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
@@ -21,7 +21,9 @@ from PyQt6.QtWidgets import (
|
||||
from lintunes.cassette import probe as probe_mod
|
||||
from lintunes.cassette import publish
|
||||
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_PREVIEW_PX = 72
|
||||
@@ -296,6 +298,28 @@ class FriendPage(QWidget):
|
||||
box_layout.addWidget(self.details)
|
||||
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.setVisible(False)
|
||||
layout.addWidget(self.using_general)
|
||||
@@ -314,6 +338,12 @@ class FriendPage(QWidget):
|
||||
layout.addLayout(row)
|
||||
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):
|
||||
if self.selector is None and library is not None:
|
||||
from lintunes.gui.share_selector import ShareSelector
|
||||
|
||||
+29
-4
@@ -418,6 +418,13 @@ class Player(QObject):
|
||||
# here rather than in a sink so both playback paths honor it.
|
||||
self._stop_at_ms = 0
|
||||
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._local = LocalSink(self)
|
||||
@@ -624,6 +631,7 @@ class Player(QObject):
|
||||
def stop(self):
|
||||
self._sink.stop()
|
||||
self._current_track = None
|
||||
self._current_key = None
|
||||
self.track_changed.emit(None)
|
||||
|
||||
def next(self):
|
||||
@@ -668,7 +676,8 @@ class Player(QObject):
|
||||
AAC). The skip is a loop, not recursion: shuffling into a run of
|
||||
unsupported tracks must not walk the stack down."""
|
||||
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:
|
||||
# The queue references a track that's no longer in the library;
|
||||
# there's nothing to locate, so just report and stop.
|
||||
@@ -700,6 +709,7 @@ class Player(QObject):
|
||||
continue
|
||||
|
||||
self._current_track = track
|
||||
self._current_key = key
|
||||
# Arm the custom stop time for this track. It's honored only when
|
||||
# it falls before the end (otherwise the natural finish handles it).
|
||||
if track.stop_time > 0 and (track.total_time == 0
|
||||
@@ -754,8 +764,23 @@ class Player(QObject):
|
||||
def _note_finished(self, track):
|
||||
"""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."""
|
||||
if track is None or track.track_id == self._counted_finish_id:
|
||||
if track is None:
|
||||
return
|
||||
self._counted_finish_id = track.track_id
|
||||
self._manager.record_play(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.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)
|
||||
|
||||
# Bob cassettes Alice's song and saves.
|
||||
bob.save_requests(token, {5}, [], {5})
|
||||
bob.save_requests(token, {5}, [])
|
||||
_sync(alice.root, bob.root, token)
|
||||
|
||||
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)
|
||||
a.apply_settings(edited)
|
||||
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: not any((out_dir(a.root, token) / "outbox").glob("*"))), \
|
||||
"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