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:
2026-09-28 21:40:59 -07:00
co-authored by Claude Opus 5.5
parent ea7f57e38b
commit 87d43f8e0c
14 changed files with 809 additions and 24 deletions
+9
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.30.0"
__version__ = "0.31.0"
+18
View File
@@ -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
+19 -6
View File
@@ -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)."""
+4 -1
View File
@@ -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()
+222
View File
@@ -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:
+119
View File
@@ -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)
+1 -2
View File
@@ -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
+19 -4
View File
@@ -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
View File
@@ -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":
+32 -2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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"
+252
View File
@@ -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")