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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user