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.
This commit is contained in:
2026-09-28 21:40:59 -07:00
parent cfeeeb2bf2
commit 35281b9864
14 changed files with 809 additions and 24 deletions
+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