v0.28.0: Cassette share selector — choose what friends can cassette
- Sync Settings gets a share selector on each friend page (and on General when "Share the same selection with all friends" is on; each friend's own selection is kept, greyed, and comes back when it's turned off). - Library: a genre/artist/album column browser with a checkbox beside every artist, album and track, search, Select All / Select None and "Share whole library". Playlists: a checklist with "Share all playlists". Nothing is shared by default. - Checking an artist or album is a snapshot of its tracks; the whole library and shared playlists are live. A shared playlist's tracks are checked and locked, with a tooltip naming the playlist(s). - cassette/share.py writes the trimmed copy (library.json, playlists.json) into each friend's folder: tags yes, listening history and local paths no. Written on OK and after library edits (debounced), and only when the bytes would change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.27.0"
|
__version__ = "0.28.0"
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ class CassetteService(QObject):
|
|||||||
changed = pyqtSignal() # state or statuses moved
|
changed = pyqtSignal() # state or statuses moved
|
||||||
notice = pyqtSignal(str) # "<Name> is now connected."
|
notice = pyqtSignal(str) # "<Name> is now connected."
|
||||||
step_failed = pyqtSignal(str, str) # step, what Syncthing said
|
step_failed = pyqtSignal(str, str) # step, what Syncthing said
|
||||||
|
# A friendship was added (their copy of my share needs writing). Emitted
|
||||||
|
# from the background thread; the GUI side does the writing, because
|
||||||
|
# that reads the library.
|
||||||
|
friends_added = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, root: Path | None = None, config_loader=load_config,
|
def __init__(self, root: Path | None = None, config_loader=load_config,
|
||||||
session_factory=None, addresses_for=None, parent=None):
|
session_factory=None, addresses_for=None, parent=None):
|
||||||
@@ -160,6 +164,7 @@ class CassetteService(QObject):
|
|||||||
self._kick = threading.Event()
|
self._kick = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._last_failure: tuple[str, str] | None = None
|
self._last_failure: tuple[str, str] | None = None
|
||||||
|
self._library_provider = None
|
||||||
|
|
||||||
# ---- lifecycle ----
|
# ---- lifecycle ----
|
||||||
|
|
||||||
@@ -209,6 +214,29 @@ class CassetteService(QObject):
|
|||||||
publish.publish_intro(out_dir(self.root, token),
|
publish.publish_intro(out_dir(self.root, token),
|
||||||
self._state.display_name, avatar)
|
self._state.display_name, avatar)
|
||||||
|
|
||||||
|
# ---- publishing my share ----
|
||||||
|
|
||||||
|
def set_library_provider(self, provider):
|
||||||
|
"""``provider()`` returns the live Library. Only ever called on the
|
||||||
|
GUI thread (republish), which is the thread that mutates it."""
|
||||||
|
self._library_provider = provider
|
||||||
|
|
||||||
|
def republish(self) -> list[str]:
|
||||||
|
"""Write every friend's trimmed copy of my library; unchanged files
|
||||||
|
aren't touched. Returns the tokens whose copy changed."""
|
||||||
|
if self._library_provider is None:
|
||||||
|
return []
|
||||||
|
from lintunes.cassette import share
|
||||||
|
library = self._library_provider()
|
||||||
|
changed = []
|
||||||
|
with self._lock:
|
||||||
|
for token, friend in self._state.friends.items():
|
||||||
|
selection = self._state.selection_for(friend)
|
||||||
|
if share.publish_share(out_dir(self.root, token), library,
|
||||||
|
selection):
|
||||||
|
changed.append(token)
|
||||||
|
return changed
|
||||||
|
|
||||||
# ---- user actions ----
|
# ---- user actions ----
|
||||||
|
|
||||||
def create_invite(self) -> str:
|
def create_invite(self) -> str:
|
||||||
@@ -270,6 +298,7 @@ class CassetteService(QObject):
|
|||||||
added=now_iso())
|
added=now_iso())
|
||||||
self._state.friends[code.token] = friend
|
self._state.friends[code.token] = friend
|
||||||
self._save()
|
self._save()
|
||||||
|
self.republish()
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
self.kick()
|
self.kick()
|
||||||
return friend
|
return friend
|
||||||
@@ -325,6 +354,7 @@ class CassetteService(QObject):
|
|||||||
self._save()
|
self._save()
|
||||||
for token in list(state.friends) + [i.token for i in state.open_invites()]:
|
for token in list(state.friends) + [i.token for i in state.open_invites()]:
|
||||||
self._publish_intro(token)
|
self._publish_intro(token)
|
||||||
|
self.republish()
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
if errors:
|
if errors:
|
||||||
raise errors[0]
|
raise errors[0]
|
||||||
@@ -453,6 +483,7 @@ class CassetteService(QObject):
|
|||||||
invite.status = USED
|
invite.status = USED
|
||||||
self._state.probing.pop(device_id, None)
|
self._state.probing.pop(device_id, None)
|
||||||
self._save()
|
self._save()
|
||||||
|
self.friends_added.emit()
|
||||||
|
|
||||||
def _reject(self, client, device_id: str):
|
def _reject(self, client, device_id: str):
|
||||||
self._state.probing.pop(device_id, None)
|
self._state.probing.pop(device_id, None)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""What a friend sees: the trimmed library copy in my outgoing folder.
|
||||||
|
|
||||||
|
Pure and Qt-free. A ``Selection`` (state.py) says what I offer:
|
||||||
|
|
||||||
|
* ``all_library`` — everything, live: a track added next week is offered too.
|
||||||
|
* ``track_ids`` — tracks checked in the browser. Checking an artist or album
|
||||||
|
stores the ids it covered *then*, so a new album shows up unchecked.
|
||||||
|
* ``all_playlists`` / ``playlist_ids`` — shared playlists, live: a song added
|
||||||
|
to a shared playlist is offered with it. Their tracks are **locked** in the
|
||||||
|
selector (``locks``), because unchecking them there would break the
|
||||||
|
playlist on the friend's side.
|
||||||
|
|
||||||
|
Offering only makes a track *available*; nothing is sent until the friend
|
||||||
|
asks for it.
|
||||||
|
|
||||||
|
The copy keeps what describes a song (the tags) and drops what describes *my
|
||||||
|
listening* — play and skip counts, ratings, loved, last played, dates — and
|
||||||
|
anything that only means something on this machine (location, comments,
|
||||||
|
start/stop times, EQ).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lintunes.models import PlaylistType
|
||||||
|
from lintunes.cassette import publish
|
||||||
|
|
||||||
|
LIBRARY_FILE = "library.json"
|
||||||
|
PLAYLISTS_FILE = "playlists.json"
|
||||||
|
|
||||||
|
# Track fields a friend receives. Everything else stays home.
|
||||||
|
SHARED_FIELDS = (
|
||||||
|
"name", "artist", "album_artist", "album", "genre", "composer",
|
||||||
|
"grouping", "kind", "size", "total_time", "disc_number", "disc_count",
|
||||||
|
"track_number", "track_count", "year", "bpm", "bit_rate", "sample_rate",
|
||||||
|
"compilation", "sort_name", "sort_artist", "sort_album_artist",
|
||||||
|
"sort_album", "sort_composer", "work", "movement_name",
|
||||||
|
"movement_number", "movement_count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shareable_playlists(library) -> list:
|
||||||
|
"""Playlists that can be shared at all: regular and smart. Folders are
|
||||||
|
structure, and system playlists are the library itself."""
|
||||||
|
return [p for p in library.playlists.values()
|
||||||
|
if p.playlist_type in (PlaylistType.REGULAR, PlaylistType.SMART)
|
||||||
|
and not p.is_system]
|
||||||
|
|
||||||
|
|
||||||
|
def shared_playlists(library, selection) -> list:
|
||||||
|
playlists = shareable_playlists(library)
|
||||||
|
if not selection.all_playlists:
|
||||||
|
playlists = [p for p in playlists
|
||||||
|
if p.persistent_id in selection.playlist_ids]
|
||||||
|
return sorted(playlists, key=lambda p: (p.name.casefold(), p.persistent_id))
|
||||||
|
|
||||||
|
|
||||||
|
def locks(library, selection) -> dict[int, list[str]]:
|
||||||
|
"""{track id: names of the shared playlists that require it}."""
|
||||||
|
required: dict[int, list[str]] = {}
|
||||||
|
for playlist in shared_playlists(library, selection):
|
||||||
|
for tid in playlist.track_ids:
|
||||||
|
if tid in library.tracks:
|
||||||
|
names = required.setdefault(tid, [])
|
||||||
|
if playlist.name not in names:
|
||||||
|
names.append(playlist.name)
|
||||||
|
return required
|
||||||
|
|
||||||
|
|
||||||
|
def offered_ids(library, selection) -> set[int]:
|
||||||
|
if selection.all_library:
|
||||||
|
return set(library.tracks)
|
||||||
|
offered = {tid for tid in selection.track_ids if tid in library.tracks}
|
||||||
|
offered.update(locks(library, selection))
|
||||||
|
return offered
|
||||||
|
|
||||||
|
|
||||||
|
def track_entry(track) -> dict:
|
||||||
|
entry = {"id": track.track_id}
|
||||||
|
for name in SHARED_FIELDS:
|
||||||
|
value = getattr(track, name)
|
||||||
|
if value not in ("", 0, None, False):
|
||||||
|
entry[name] = value
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def build_library(library, selection) -> dict:
|
||||||
|
offered = offered_ids(library, selection)
|
||||||
|
tracks = [track_entry(library.tracks[tid]) for tid in sorted(offered)]
|
||||||
|
return {"format": publish.FORMAT_VERSION, "tracks": tracks}
|
||||||
|
|
||||||
|
|
||||||
|
def build_playlists(library, selection) -> dict:
|
||||||
|
offered = offered_ids(library, selection)
|
||||||
|
playlists = []
|
||||||
|
for playlist in shared_playlists(library, selection):
|
||||||
|
playlists.append({
|
||||||
|
"id": playlist.persistent_id,
|
||||||
|
"name": playlist.name,
|
||||||
|
"smart": playlist.is_smart,
|
||||||
|
"track_ids": [t for t in playlist.track_ids if t in offered],
|
||||||
|
})
|
||||||
|
return {"format": publish.FORMAT_VERSION, "playlists": playlists}
|
||||||
|
|
||||||
|
|
||||||
|
def publish_share(out: Path, library, selection) -> bool:
|
||||||
|
"""Write the trimmed copy into ``out``. True when either file changed."""
|
||||||
|
publish.prepare_out_dir(out)
|
||||||
|
wrote_library = publish.write_json_if_changed(
|
||||||
|
out / LIBRARY_FILE, build_library(library, selection))
|
||||||
|
wrote_playlists = publish.write_json_if_changed(
|
||||||
|
out / PLAYLISTS_FILE, build_playlists(library, selection))
|
||||||
|
return wrote_library or wrote_playlists
|
||||||
@@ -7,7 +7,7 @@ and calls ``build_menu`` / ``refresh_menu`` / ``shutdown``.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt6.QtCore import QObject
|
from PyQt6.QtCore import QObject, QTimer
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QDialog, QMessageBox
|
from PyQt6.QtWidgets import QDialog, QMessageBox
|
||||||
|
|
||||||
@@ -16,13 +16,22 @@ from lintunes.cassette import syncthing_api as st
|
|||||||
from lintunes.config import load_config, save_config
|
from lintunes.config import load_config, save_config
|
||||||
|
|
||||||
NOTICE_MS = 10000
|
NOTICE_MS = 10000
|
||||||
|
# Library edits are batched before friends' copies are rebuilt: an import of
|
||||||
|
# a hundred files is one rewrite, not a hundred.
|
||||||
|
REPUBLISH_DELAY_MS = 5000
|
||||||
|
|
||||||
|
|
||||||
class CassetteUi(QObject):
|
class CassetteUi(QObject):
|
||||||
def __init__(self, window, prefs, parent=None, service_factory=None):
|
def __init__(self, window, prefs, manager=None, parent=None,
|
||||||
|
service_factory=None):
|
||||||
super().__init__(parent or window)
|
super().__init__(parent or window)
|
||||||
self._window = window
|
self._window = window
|
||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
|
self._manager = manager
|
||||||
|
self._republish_timer = QTimer(self)
|
||||||
|
self._republish_timer.setSingleShot(True)
|
||||||
|
self._republish_timer.setInterval(REPUBLISH_DELAY_MS)
|
||||||
|
self._republish_timer.timeout.connect(self._republish)
|
||||||
self._elsewhere_action: QAction | None = None
|
self._elsewhere_action: QAction | None = None
|
||||||
self._actions: list[QAction] = []
|
self._actions: list[QAction] = []
|
||||||
self._service_factory = service_factory
|
self._service_factory = service_factory
|
||||||
@@ -44,9 +53,37 @@ class CassetteUi(QObject):
|
|||||||
self.service = CassetteService(parent=self)
|
self.service = CassetteService(parent=self)
|
||||||
self.service.notice.connect(self._on_notice)
|
self.service.notice.connect(self._on_notice)
|
||||||
self.service.step_failed.connect(self._on_step_failed)
|
self.service.step_failed.connect(self._on_step_failed)
|
||||||
|
self.service.friends_added.connect(self._republish_timer.start)
|
||||||
|
if self._manager is not None:
|
||||||
|
self.service.set_library_provider(self._library)
|
||||||
|
manager = self._manager
|
||||||
|
for signal in (manager.playlists_changed,
|
||||||
|
manager.playlist_content_changed,
|
||||||
|
manager.track_fields_edited,
|
||||||
|
manager.library_reloaded,
|
||||||
|
manager.tracks_removed,
|
||||||
|
manager.tracks_restored,
|
||||||
|
manager.tracks_added):
|
||||||
|
signal.connect(self._schedule_republish)
|
||||||
self.service.start()
|
self.service.start()
|
||||||
|
# Whatever changed while LinTunes was closed.
|
||||||
|
self._republish_timer.start()
|
||||||
return self.service
|
return self.service
|
||||||
|
|
||||||
|
def _library(self):
|
||||||
|
return self._manager.library if self._manager is not None else None
|
||||||
|
|
||||||
|
def _schedule_republish(self, *_):
|
||||||
|
self._republish_timer.start()
|
||||||
|
|
||||||
|
def _republish(self):
|
||||||
|
if self.service is not None:
|
||||||
|
try:
|
||||||
|
self.service.republish()
|
||||||
|
except OSError as exc:
|
||||||
|
self._on_step_failed("Updating what your friends can see",
|
||||||
|
str(exc))
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
if self.service is not None:
|
if self.service is not None:
|
||||||
self.service.stop()
|
self.service.stop()
|
||||||
@@ -148,7 +185,8 @@ class CassetteUi(QObject):
|
|||||||
service = self.service
|
service = self.service
|
||||||
config = load_config()
|
config = load_config()
|
||||||
dialog = SyncSettingsDialog(service.state(), service.root, config,
|
dialog = SyncSettingsDialog(service.state(), service.root, config,
|
||||||
self._window, service=service)
|
self._window, service=service,
|
||||||
|
library=self._library())
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
dialog.apply()
|
dialog.apply()
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class MainWindow(QMainWindow):
|
|||||||
# Friend sharing's menu group and dialogs. Its background service is
|
# Friend sharing's menu group and dialogs. Its background service is
|
||||||
# started by run_gui (host machine only), never by constructing the
|
# started by run_gui (host machine only), never by constructing the
|
||||||
# window — tests build windows by the dozen.
|
# window — tests build windows by the dozen.
|
||||||
self._cassette = CassetteUi(self, prefs)
|
self._cassette = CassetteUi(self, prefs, manager)
|
||||||
|
|
||||||
self.setWindowTitle("LinTunes")
|
self.setWindowTitle("LinTunes")
|
||||||
self.resize(1280, 800)
|
self.resize(1280, 800)
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""The share selector on Sync Settings' General and friend pages: what a
|
||||||
|
friend may cassette from me.
|
||||||
|
|
||||||
|
Two independent sections. **Library** is a column browser like the main
|
||||||
|
window's (genre / artist / album, then the tracks), with a checkbox beside
|
||||||
|
every artist, album and track and a **Share whole library** box above it.
|
||||||
|
**Playlists** is every playlist with a checkbox and **Share all playlists**.
|
||||||
|
Both have Select All / Select None. Nothing is shared by default.
|
||||||
|
|
||||||
|
Checking an artist or album checks the tracks shown under it (a snapshot: a
|
||||||
|
new album appears unchecked later). A shared playlist's tracks are checked
|
||||||
|
and **locked** in the Library section — not user-checkable, with a tooltip
|
||||||
|
naming the playlist(s) — because unchecking them there would break the
|
||||||
|
playlist on the friend's side.
|
||||||
|
|
||||||
|
The widget edits the ``Selection`` it's given in place; the dialog owns the
|
||||||
|
copy and only OK writes it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QCheckBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||||||
|
QListWidgetItem, QPushButton, QSplitter, QTreeWidget, QTreeWidgetItem,
|
||||||
|
QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lintunes.cassette import share
|
||||||
|
from lintunes.gui.library_view import (
|
||||||
|
_artist_sort_key, _canonical_values, _matches_search,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHECKED = Qt.CheckState.Checked
|
||||||
|
UNCHECKED = Qt.CheckState.Unchecked
|
||||||
|
PARTIAL = Qt.CheckState.PartiallyChecked
|
||||||
|
TID_ROLE = Qt.ItemDataRole.UserRole
|
||||||
|
KEY_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _artist_of(track) -> str:
|
||||||
|
return track.album_artist or track.artist
|
||||||
|
|
||||||
|
|
||||||
|
def lock_tooltip(names: list[str]) -> str:
|
||||||
|
quoted = ", ".join(f"“{n}”" for n in names)
|
||||||
|
noun = "playlist" if len(names) == 1 else "playlists"
|
||||||
|
return (f"Shared with the {noun} {quoted}. To stop sharing this song, "
|
||||||
|
f"unshare the {noun} below.")
|
||||||
|
|
||||||
|
|
||||||
|
def _tristate(ids, checked: set) -> Qt.CheckState:
|
||||||
|
if not ids:
|
||||||
|
return UNCHECKED
|
||||||
|
hits = sum(1 for tid in ids if tid in checked)
|
||||||
|
if hits == 0:
|
||||||
|
return UNCHECKED
|
||||||
|
return CHECKED if hits == len(ids) else PARTIAL
|
||||||
|
|
||||||
|
|
||||||
|
class _CheckList(QListWidget):
|
||||||
|
"""A browser column whose rows (after "All") carry a checkbox."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setMinimumWidth(120)
|
||||||
|
self.setUniformItemSizes(True)
|
||||||
|
|
||||||
|
def set_values(self, label: str, values: list[str]):
|
||||||
|
current = self.selected_value()
|
||||||
|
self.blockSignals(True)
|
||||||
|
self.clear()
|
||||||
|
self.addItem(f"All ({len(values)} {label})")
|
||||||
|
for value in values:
|
||||||
|
item = QListWidgetItem(value)
|
||||||
|
item.setData(KEY_ROLE, value)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(UNCHECKED)
|
||||||
|
self.addItem(item)
|
||||||
|
row = 0
|
||||||
|
if current:
|
||||||
|
matches = self.findItems(current, Qt.MatchFlag.MatchExactly)
|
||||||
|
if matches:
|
||||||
|
row = self.row(matches[0])
|
||||||
|
self.setCurrentRow(row)
|
||||||
|
self.blockSignals(False)
|
||||||
|
|
||||||
|
def selected_value(self) -> str:
|
||||||
|
item = self.currentItem()
|
||||||
|
if item is None or self.row(item) == 0:
|
||||||
|
return ""
|
||||||
|
return item.text()
|
||||||
|
|
||||||
|
def value_items(self):
|
||||||
|
for row in range(1, self.count()):
|
||||||
|
yield self.item(row)
|
||||||
|
|
||||||
|
|
||||||
|
class ShareSelector(QWidget):
|
||||||
|
changed = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, library, selection, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._library = library
|
||||||
|
self.selection = selection
|
||||||
|
self._tracks = list(library.tracks.values())
|
||||||
|
self._visible = self._tracks
|
||||||
|
self._search_text = ""
|
||||||
|
self._locks: dict[int, list[str]] = {}
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||||
|
splitter.addWidget(self._build_library_box())
|
||||||
|
splitter.addWidget(self._build_playlists_box())
|
||||||
|
splitter.setSizes([560, 140])
|
||||||
|
splitter.setCollapsible(0, False)
|
||||||
|
layout.addWidget(splitter)
|
||||||
|
|
||||||
|
self._refresh_locks()
|
||||||
|
self._populate_genres()
|
||||||
|
|
||||||
|
# ---- building ----
|
||||||
|
|
||||||
|
def _build_library_box(self) -> QWidget:
|
||||||
|
box = QGroupBox("Library")
|
||||||
|
layout = QVBoxLayout(box)
|
||||||
|
top = QHBoxLayout()
|
||||||
|
self.whole_library = QCheckBox("Share whole library")
|
||||||
|
self.whole_library.setToolTip(
|
||||||
|
"Everything in your library can be cassetted, including songs "
|
||||||
|
"you add later. Nothing is sent until your friend asks for it.")
|
||||||
|
self.whole_library.setChecked(self.selection.all_library)
|
||||||
|
self.whole_library.toggled.connect(self._on_whole_library)
|
||||||
|
top.addWidget(self.whole_library)
|
||||||
|
top.addStretch(1)
|
||||||
|
self.search = QLineEdit()
|
||||||
|
self.search.setPlaceholderText("search")
|
||||||
|
self.search.setClearButtonEnabled(True)
|
||||||
|
self.search.textChanged.connect(self._on_search)
|
||||||
|
top.addWidget(self.search)
|
||||||
|
self.lib_all = QPushButton("Select All")
|
||||||
|
self.lib_none = QPushButton("Select None")
|
||||||
|
self.lib_all.clicked.connect(lambda: self._select_all_tracks(True))
|
||||||
|
self.lib_none.clicked.connect(lambda: self._select_all_tracks(False))
|
||||||
|
top.addWidget(self.lib_all)
|
||||||
|
top.addWidget(self.lib_none)
|
||||||
|
layout.addLayout(top)
|
||||||
|
|
||||||
|
self._browser_host = QWidget()
|
||||||
|
host = QVBoxLayout(self._browser_host)
|
||||||
|
host.setContentsMargins(0, 0, 0, 0)
|
||||||
|
split = QSplitter(Qt.Orientation.Vertical)
|
||||||
|
columns = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
self.genres = QListWidget()
|
||||||
|
self.genres.setUniformItemSizes(True)
|
||||||
|
self.artists = _CheckList()
|
||||||
|
self.albums = _CheckList()
|
||||||
|
for widget in (self.genres, self.artists, self.albums):
|
||||||
|
widget.setMinimumHeight(110)
|
||||||
|
columns.addWidget(widget)
|
||||||
|
self.tracks = QTreeWidget()
|
||||||
|
self.tracks.setHeaderLabels(["Name", "Artist", "Album"])
|
||||||
|
self.tracks.setRootIsDecorated(False)
|
||||||
|
self.tracks.setUniformRowHeights(True)
|
||||||
|
self.tracks.setColumnWidth(0, 260)
|
||||||
|
self.tracks.setColumnWidth(1, 180)
|
||||||
|
self.tracks.setMinimumHeight(160)
|
||||||
|
split.addWidget(columns)
|
||||||
|
split.addWidget(self.tracks)
|
||||||
|
split.setSizes([170, 330])
|
||||||
|
host.addWidget(split)
|
||||||
|
layout.addWidget(self._browser_host, stretch=1)
|
||||||
|
|
||||||
|
self.genres.currentRowChanged.connect(lambda _: self._populate_artists())
|
||||||
|
self.artists.currentRowChanged.connect(lambda _: self._populate_albums())
|
||||||
|
self.albums.currentRowChanged.connect(lambda _: self._populate_tracks())
|
||||||
|
self.artists.itemChanged.connect(self._on_group_checked)
|
||||||
|
self.albums.itemChanged.connect(self._on_group_checked)
|
||||||
|
self.tracks.itemChanged.connect(self._on_track_checked)
|
||||||
|
self._browser_host.setEnabled(not self.selection.all_library)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _build_playlists_box(self) -> QWidget:
|
||||||
|
box = QGroupBox("Playlists")
|
||||||
|
layout = QVBoxLayout(box)
|
||||||
|
top = QHBoxLayout()
|
||||||
|
self.all_playlists = QCheckBox("Share all playlists")
|
||||||
|
self.all_playlists.setToolTip(
|
||||||
|
"Every playlist, including ones you make later. Songs you add to "
|
||||||
|
"a shared playlist are shared with it.")
|
||||||
|
self.all_playlists.setChecked(self.selection.all_playlists)
|
||||||
|
self.all_playlists.toggled.connect(self._on_all_playlists)
|
||||||
|
top.addWidget(self.all_playlists)
|
||||||
|
top.addStretch(1)
|
||||||
|
self.pl_all = QPushButton("Select All")
|
||||||
|
self.pl_none = QPushButton("Select None")
|
||||||
|
self.pl_all.clicked.connect(lambda: self._select_all_playlists(True))
|
||||||
|
self.pl_none.clicked.connect(lambda: self._select_all_playlists(False))
|
||||||
|
top.addWidget(self.pl_all)
|
||||||
|
top.addWidget(self.pl_none)
|
||||||
|
layout.addLayout(top)
|
||||||
|
self.playlists = QListWidget()
|
||||||
|
for playlist in sorted(share.shareable_playlists(self._library),
|
||||||
|
key=lambda p: p.name.casefold()):
|
||||||
|
item = QListWidgetItem(playlist.name)
|
||||||
|
item.setData(KEY_ROLE, playlist.persistent_id)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(CHECKED if playlist.persistent_id
|
||||||
|
in self.selection.playlist_ids else UNCHECKED)
|
||||||
|
self.playlists.addItem(item)
|
||||||
|
self.playlists.itemChanged.connect(self._on_playlist_checked)
|
||||||
|
self.playlists.setEnabled(not self.selection.all_playlists)
|
||||||
|
layout.addWidget(self.playlists)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# ---- state helpers ----
|
||||||
|
|
||||||
|
def _checked(self) -> set:
|
||||||
|
"""Tracks that show as checked: chosen ones plus locked ones."""
|
||||||
|
return self.selection.track_ids | set(self._locks)
|
||||||
|
|
||||||
|
def _refresh_locks(self):
|
||||||
|
self._locks = share.locks(self._library, self.selection)
|
||||||
|
|
||||||
|
def _filtered(self, up_to: str) -> list:
|
||||||
|
tracks = self._visible
|
||||||
|
genre = ""
|
||||||
|
item = self.genres.currentItem()
|
||||||
|
if item is not None and self.genres.row(item) > 0:
|
||||||
|
genre = item.text().casefold()
|
||||||
|
if genre:
|
||||||
|
tracks = [t for t in tracks if t.genre.casefold() == genre]
|
||||||
|
if up_to in ("artist", "album"):
|
||||||
|
artist = self.artists.selected_value().casefold()
|
||||||
|
if artist:
|
||||||
|
tracks = [t for t in tracks if _artist_of(t).casefold() == artist]
|
||||||
|
if up_to == "album":
|
||||||
|
album = self.albums.selected_value().casefold()
|
||||||
|
if album:
|
||||||
|
tracks = [t for t in tracks if t.album.casefold() == album]
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
# ---- populating ----
|
||||||
|
|
||||||
|
def _populate_genres(self):
|
||||||
|
genres = sorted(_canonical_values(t.genre for t in self._visible if t.genre),
|
||||||
|
key=str.casefold)
|
||||||
|
current = self.genres.currentItem()
|
||||||
|
keep = current.text() if current is not None and self.genres.row(current) > 0 else ""
|
||||||
|
self.genres.blockSignals(True)
|
||||||
|
self.genres.clear()
|
||||||
|
self.genres.addItem(f"All ({len(genres)} Genres)")
|
||||||
|
self.genres.addItems(genres)
|
||||||
|
row = 0
|
||||||
|
if keep:
|
||||||
|
found = self.genres.findItems(keep, Qt.MatchFlag.MatchExactly)
|
||||||
|
row = self.genres.row(found[0]) if found else 0
|
||||||
|
self.genres.setCurrentRow(row)
|
||||||
|
self.genres.blockSignals(False)
|
||||||
|
self._populate_artists()
|
||||||
|
|
||||||
|
def _populate_artists(self):
|
||||||
|
tracks = self._filtered("genre")
|
||||||
|
names = _canonical_values(_artist_of(t) for t in tracks if _artist_of(t))
|
||||||
|
self._updating = True
|
||||||
|
self.artists.set_values("Artists", sorted(names, key=_artist_sort_key))
|
||||||
|
self._updating = False
|
||||||
|
self._populate_albums()
|
||||||
|
|
||||||
|
def _populate_albums(self):
|
||||||
|
tracks = self._filtered("artist")
|
||||||
|
names = _canonical_values(t.album for t in tracks if t.album)
|
||||||
|
self._updating = True
|
||||||
|
self.albums.set_values("Albums", sorted(names, key=str.casefold))
|
||||||
|
self._updating = False
|
||||||
|
self._populate_tracks()
|
||||||
|
|
||||||
|
def _populate_tracks(self):
|
||||||
|
tracks = sorted(self._filtered("album"),
|
||||||
|
key=lambda t: (_artist_sort_key(_artist_of(t)),
|
||||||
|
t.album.casefold(), t.disc_number,
|
||||||
|
t.track_number, t.name.casefold()))
|
||||||
|
checked = self._checked()
|
||||||
|
self._updating = True
|
||||||
|
self.tracks.clear()
|
||||||
|
items = []
|
||||||
|
for track in tracks:
|
||||||
|
item = QTreeWidgetItem([track.name, track.artist, track.album])
|
||||||
|
item.setData(0, TID_ROLE, track.track_id)
|
||||||
|
locked = self._locks.get(track.track_id)
|
||||||
|
if locked and not self.selection.all_library:
|
||||||
|
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(0, CHECKED)
|
||||||
|
tip = lock_tooltip(locked)
|
||||||
|
for column in range(3):
|
||||||
|
item.setToolTip(column, tip)
|
||||||
|
else:
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(0, CHECKED if (self.selection.all_library
|
||||||
|
or track.track_id in checked)
|
||||||
|
else UNCHECKED)
|
||||||
|
items.append(item)
|
||||||
|
self.tracks.addTopLevelItems(items)
|
||||||
|
self._updating = False
|
||||||
|
self._refresh_group_states()
|
||||||
|
|
||||||
|
def _group_ids(self, column: _CheckList, key: str) -> list[int]:
|
||||||
|
key = key.casefold()
|
||||||
|
if column is self.artists:
|
||||||
|
pool = self._filtered("genre")
|
||||||
|
return [t.track_id for t in pool if _artist_of(t).casefold() == key]
|
||||||
|
pool = self._filtered("artist")
|
||||||
|
return [t.track_id for t in pool if t.album.casefold() == key]
|
||||||
|
|
||||||
|
def _refresh_group_states(self):
|
||||||
|
"""Artist and album boxes: checked / partly / not, from their tracks."""
|
||||||
|
checked = set(self._library.tracks) if self.selection.all_library \
|
||||||
|
else self._checked()
|
||||||
|
self._updating = True
|
||||||
|
for column in (self.artists, self.albums):
|
||||||
|
index = defaultdict(list)
|
||||||
|
pool = self._filtered("genre" if column is self.artists else "artist")
|
||||||
|
for t in pool:
|
||||||
|
name = _artist_of(t) if column is self.artists else t.album
|
||||||
|
index[name.casefold()].append(t.track_id)
|
||||||
|
for item in column.value_items():
|
||||||
|
item.setCheckState(_tristate(index.get(item.text().casefold(), []),
|
||||||
|
checked))
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Rebuild after the selection changed underneath (locks moved)."""
|
||||||
|
self._refresh_locks()
|
||||||
|
self._populate_tracks()
|
||||||
|
|
||||||
|
# ---- user actions ----
|
||||||
|
|
||||||
|
def _on_search(self, text: str):
|
||||||
|
self._search_text = text.strip().casefold()
|
||||||
|
tokens = self._search_text.split()
|
||||||
|
self._visible = ([t for t in self._tracks if _matches_search(t, tokens)]
|
||||||
|
if tokens else self._tracks)
|
||||||
|
self._populate_genres()
|
||||||
|
|
||||||
|
def _on_whole_library(self, on: bool):
|
||||||
|
self.selection.all_library = on
|
||||||
|
self._browser_host.setEnabled(not on)
|
||||||
|
self._populate_tracks()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _on_all_playlists(self, on: bool):
|
||||||
|
self.selection.all_playlists = on
|
||||||
|
self.playlists.setEnabled(not on)
|
||||||
|
self.refresh()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _on_group_checked(self, item: QListWidgetItem):
|
||||||
|
if self._updating:
|
||||||
|
return
|
||||||
|
column = item.listWidget()
|
||||||
|
ids = self._group_ids(column, item.text())
|
||||||
|
# A click on a partly-checked box means "all of it".
|
||||||
|
if item.checkState() == UNCHECKED:
|
||||||
|
self.selection.track_ids.difference_update(ids)
|
||||||
|
else:
|
||||||
|
self.selection.track_ids.update(ids)
|
||||||
|
self._populate_tracks()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _on_track_checked(self, item: QTreeWidgetItem, column: int):
|
||||||
|
if self._updating or column != 0:
|
||||||
|
return
|
||||||
|
tid = item.data(0, TID_ROLE)
|
||||||
|
if item.checkState(0) == CHECKED:
|
||||||
|
self.selection.track_ids.add(tid)
|
||||||
|
else:
|
||||||
|
self.selection.track_ids.discard(tid)
|
||||||
|
self._refresh_group_states()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _on_playlist_checked(self, item: QListWidgetItem):
|
||||||
|
pid = item.data(KEY_ROLE)
|
||||||
|
if item.checkState() == CHECKED:
|
||||||
|
self.selection.playlist_ids.add(pid)
|
||||||
|
else:
|
||||||
|
self.selection.playlist_ids.discard(pid)
|
||||||
|
self.refresh()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _select_all_tracks(self, on: bool):
|
||||||
|
if on:
|
||||||
|
self.selection.track_ids = set(self._library.tracks)
|
||||||
|
else:
|
||||||
|
self.selection.track_ids = set()
|
||||||
|
self._populate_tracks()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _select_all_playlists(self, on: bool):
|
||||||
|
pids = {p.persistent_id for p in share.shareable_playlists(self._library)}
|
||||||
|
self.selection.playlist_ids = set(pids) if on else set()
|
||||||
|
self.playlists.blockSignals(True)
|
||||||
|
for row in range(self.playlists.count()):
|
||||||
|
self.playlists.item(row).setCheckState(CHECKED if on else UNCHECKED)
|
||||||
|
self.playlists.blockSignals(False)
|
||||||
|
self.refresh()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def set_read_only(self, read_only: bool):
|
||||||
|
"""Greyed out on a friend page while "same for all friends" is on."""
|
||||||
|
for widget in self.findChildren(QGroupBox):
|
||||||
|
widget.setEnabled(not read_only)
|
||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
from PyQt6.QtGui import QImage, QPixmap
|
from PyQt6.QtGui import QImage, QPixmap
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
||||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
|
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
|
||||||
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
|
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
@@ -174,7 +174,16 @@ class GeneralPage(QWidget):
|
|||||||
avatar_row.addStretch(1)
|
avatar_row.addStretch(1)
|
||||||
you_form.addRow("Avatar:", avatar_row)
|
you_form.addRow("Avatar:", avatar_row)
|
||||||
layout.addWidget(you)
|
layout.addWidget(you)
|
||||||
layout.addStretch(1)
|
|
||||||
|
self.same_for_all = QCheckBox("Share the same selection with all friends")
|
||||||
|
self.same_for_all.setChecked(state.share_same_for_all)
|
||||||
|
layout.addWidget(self.same_for_all)
|
||||||
|
# The general selector is built the first time it's needed.
|
||||||
|
self.selector_host = QVBoxLayout()
|
||||||
|
layout.addLayout(self.selector_host, stretch=1)
|
||||||
|
self.selector = None
|
||||||
|
self._stretch = QWidget()
|
||||||
|
layout.addWidget(self._stretch, stretch=1)
|
||||||
|
|
||||||
self.show_probe(None)
|
self.show_probe(None)
|
||||||
|
|
||||||
@@ -257,7 +266,7 @@ class FriendPage(QWidget):
|
|||||||
|
|
||||||
def __init__(self, friend, root: Path, parent=None):
|
def __init__(self, friend, root: Path, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.friend = friend
|
self.friend = friend # the dialog's copy: the selector edits it
|
||||||
self.removed = False
|
self.removed = False
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
@@ -286,7 +295,13 @@ class FriendPage(QWidget):
|
|||||||
box_layout.addWidget(self.hint)
|
box_layout.addWidget(self.hint)
|
||||||
box_layout.addWidget(self.details)
|
box_layout.addWidget(self.details)
|
||||||
layout.addWidget(box)
|
layout.addWidget(box)
|
||||||
layout.addStretch(1)
|
|
||||||
|
self.using_general = QLabel("Using general settings — see General.")
|
||||||
|
self.using_general.setVisible(False)
|
||||||
|
layout.addWidget(self.using_general)
|
||||||
|
self.selector_host = QVBoxLayout()
|
||||||
|
layout.addLayout(self.selector_host, stretch=1)
|
||||||
|
self.selector = None
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
@@ -299,6 +314,18 @@ class FriendPage(QWidget):
|
|||||||
layout.addLayout(row)
|
layout.addLayout(row)
|
||||||
self.show_status(None)
|
self.show_status(None)
|
||||||
|
|
||||||
|
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
|
||||||
|
self.selector = ShareSelector(library, self.friend.selection)
|
||||||
|
self.selector_host.addWidget(self.selector)
|
||||||
|
self.set_same_for_all(same_for_all)
|
||||||
|
|
||||||
|
def set_same_for_all(self, on: bool):
|
||||||
|
self.using_general.setVisible(on)
|
||||||
|
if self.selector is not None:
|
||||||
|
self.selector.set_read_only(on)
|
||||||
|
|
||||||
def show_status(self, status):
|
def show_status(self, status):
|
||||||
if status is None:
|
if status is None:
|
||||||
self.status.setText("Checking…")
|
self.status.setText("Checking…")
|
||||||
@@ -337,11 +364,12 @@ class SyncSettingsDialog(QDialog):
|
|||||||
the change affects."""
|
the change affects."""
|
||||||
|
|
||||||
def __init__(self, state, root: Path, config: dict, parent=None,
|
def __init__(self, state, root: Path, config: dict, parent=None,
|
||||||
probe=None, service=None):
|
probe=None, service=None, library=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._service = service
|
self._service = service
|
||||||
|
self._library = library
|
||||||
self.setWindowTitle("Sync Settings")
|
self.setWindowTitle("Sync Settings")
|
||||||
self.resize(820, 560)
|
self.resize(1080, 880)
|
||||||
self.state = state.copy()
|
self.state = state.copy()
|
||||||
self._root = root
|
self._root = root
|
||||||
self._config = config
|
self._config = config
|
||||||
@@ -362,7 +390,9 @@ class SyncSettingsDialog(QDialog):
|
|||||||
page = FriendPage(friend, root)
|
page = FriendPage(friend, root)
|
||||||
self.friend_pages[friend.token] = page
|
self.friend_pages[friend.token] = page
|
||||||
self._add_page(friend.name, page)
|
self._add_page(friend.name, page)
|
||||||
self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex)
|
self.pages_list.currentRowChanged.connect(self._show_page)
|
||||||
|
self.general.same_for_all.toggled.connect(self._on_same_for_all)
|
||||||
|
self._on_same_for_all(self.state.share_same_for_all)
|
||||||
self.pages_list.setCurrentRow(0)
|
self.pages_list.setCurrentRow(0)
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
|
||||||
@@ -403,6 +433,27 @@ class SyncSettingsDialog(QDialog):
|
|||||||
pass
|
pass
|
||||||
super().done(result)
|
super().done(result)
|
||||||
|
|
||||||
|
def _show_page(self, row: int):
|
||||||
|
self.pages.setCurrentIndex(row)
|
||||||
|
page = self.pages.widget(row)
|
||||||
|
if isinstance(page, FriendPage):
|
||||||
|
page.ensure_selector(self._library, self.state.share_same_for_all)
|
||||||
|
|
||||||
|
def _on_same_for_all(self, on: bool):
|
||||||
|
"""General shows the shared selector; each friend page greys its own
|
||||||
|
out (kept, not cleared, so turning this off restores it)."""
|
||||||
|
self.state.share_same_for_all = on
|
||||||
|
general = self.general
|
||||||
|
if on and general.selector is None and self._library is not None:
|
||||||
|
from lintunes.gui.share_selector import ShareSelector
|
||||||
|
general.selector = ShareSelector(self._library, self.state.general)
|
||||||
|
general.selector_host.addWidget(general.selector)
|
||||||
|
if general.selector is not None:
|
||||||
|
general.selector.setVisible(on)
|
||||||
|
general._stretch.setVisible(not on)
|
||||||
|
for page in self.friend_pages.values():
|
||||||
|
page.set_same_for_all(on)
|
||||||
|
|
||||||
def _add_page(self, label: str, page: QWidget):
|
def _add_page(self, label: str, page: QWidget):
|
||||||
self.pages_list.addItem(label)
|
self.pages_list.addItem(label)
|
||||||
self.pages.addWidget(page)
|
self.pages.addWidget(page)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class LibraryManager(QObject):
|
|||||||
file_move_failed = pyqtSignal(str, str) # track name, error text
|
file_move_failed = pyqtSignal(str, str) # track name, error text
|
||||||
tracks_removed = pyqtSignal(list) # track ids gone from the library
|
tracks_removed = pyqtSignal(list) # track ids gone from the library
|
||||||
tracks_restored = pyqtSignal(list) # track ids back after an undo
|
tracks_restored = pyqtSignal(list) # track ids back after an undo
|
||||||
|
tracks_added = pyqtSignal(list) # track ids new to the library
|
||||||
music_folder_changed = pyqtSignal(str) # new media folder ('' if none)
|
music_folder_changed = pyqtSignal(str) # new media folder ('' if none)
|
||||||
|
|
||||||
def __init__(self, library: Library, data_dir: Path, parent=None):
|
def __init__(self, library: Library, data_dir: Path, parent=None):
|
||||||
@@ -391,6 +392,7 @@ class LibraryManager(QObject):
|
|||||||
self._dirty_metadata = True
|
self._dirty_metadata = True
|
||||||
self._schedule_save()
|
self._schedule_save()
|
||||||
self._touch_smart(None)
|
self._touch_smart(None)
|
||||||
|
self.tracks_added.emit([track.track_id])
|
||||||
return track.track_id
|
return track.track_id
|
||||||
|
|
||||||
def delete_tracks(self, track_ids: list[int],
|
def delete_tracks(self, track_ids: list[int],
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Round 66: the share selector and the trimmed library copy a friend reads.
|
||||||
|
|
||||||
|
* Nothing is shared by default. "Share whole library" is live (new songs are
|
||||||
|
offered too); checked artists/albums store the track ids they covered then,
|
||||||
|
so a new album shows up unchecked; shared playlists are live and their
|
||||||
|
tracks are locked in the Library section with a tooltip naming them.
|
||||||
|
* Offering never sends anything — the copy only lists what may be asked for,
|
||||||
|
and keeps the tags while dropping my listening history and anything local.
|
||||||
|
* The copy is written only when its bytes would change.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from lintunes.cassette import share
|
||||||
|
from lintunes.cassette.state import CassetteState, Friend, Selection, out_dir
|
||||||
|
from lintunes.models import Playlist, PlaylistType, Track
|
||||||
|
from lintunes.models.library import Library
|
||||||
|
|
||||||
|
|
||||||
|
def _library():
|
||||||
|
library = Library()
|
||||||
|
rows = [
|
||||||
|
(1, "Reel One", "Analog Ghost", "Tape Hiss", "Folk"),
|
||||||
|
(2, "Reel Two", "Analog Ghost", "Tape Hiss", "Jazz"),
|
||||||
|
(3, "B-Side", "Analog Ghost", "Other Album", "Jazz"),
|
||||||
|
(4, "Étoile", "Marguerite Vaux", "Nuit Blanche", "Jazz"),
|
||||||
|
(5, "Café", "Marguerite Vaux", "Nuit Blanche", "Electronic"),
|
||||||
|
]
|
||||||
|
for tid, name, artist, album, genre in rows:
|
||||||
|
library.tracks[tid] = Track(
|
||||||
|
track_id=tid, name=name, artist=artist, album=album, genre=genre,
|
||||||
|
year=1987, total_time=180000, size=4_000_000, play_count=41,
|
||||||
|
rating=80, loved=True, comments="private note",
|
||||||
|
location=f"/music/{tid}.mp3", date_added="2020-01-01T00:00:00",
|
||||||
|
play_date_utc="2026-01-01T00:00:00")
|
||||||
|
library.playlists["P1"] = Playlist(name="Road Trip", persistent_id="P1",
|
||||||
|
track_ids=[4, 1])
|
||||||
|
library.playlists["P2"] = Playlist(name="Morning", persistent_id="P2",
|
||||||
|
track_ids=[5])
|
||||||
|
library.playlists["F"] = Playlist(name="Boxes", persistent_id="F",
|
||||||
|
playlist_type=PlaylistType.FOLDER)
|
||||||
|
library.playlists["S"] = Playlist(name="Music", persistent_id="S",
|
||||||
|
is_system=True,
|
||||||
|
playlist_type=PlaylistType.SYSTEM)
|
||||||
|
return library
|
||||||
|
|
||||||
|
|
||||||
|
# ---- what is offered ----
|
||||||
|
|
||||||
|
class TestOffered:
|
||||||
|
def test_nothing_by_default(self):
|
||||||
|
assert share.offered_ids(_library(), Selection()) == set()
|
||||||
|
|
||||||
|
def test_whole_library_is_live(self):
|
||||||
|
library = _library()
|
||||||
|
selection = Selection(all_library=True)
|
||||||
|
library.tracks[9] = Track(track_id=9, name="New")
|
||||||
|
assert 9 in share.offered_ids(library, selection)
|
||||||
|
|
||||||
|
def test_checked_tracks_plus_shared_playlists(self):
|
||||||
|
selection = Selection(track_ids={2, 99}, playlist_ids={"P1"})
|
||||||
|
assert share.offered_ids(_library(), selection) == {1, 2, 4}
|
||||||
|
|
||||||
|
def test_shared_playlist_is_live(self):
|
||||||
|
library = _library()
|
||||||
|
selection = Selection(playlist_ids={"P1"})
|
||||||
|
library.playlists["P1"].track_ids.append(3)
|
||||||
|
assert 3 in share.offered_ids(library, selection)
|
||||||
|
|
||||||
|
def test_locks_name_every_playlist(self):
|
||||||
|
library = _library()
|
||||||
|
library.playlists["P2"].track_ids.append(4)
|
||||||
|
locks = share.locks(library, Selection(all_playlists=True))
|
||||||
|
assert sorted(locks[4]) == ["Morning", "Road Trip"]
|
||||||
|
|
||||||
|
def test_folders_and_system_playlists_are_never_shared(self):
|
||||||
|
names = [p.name for p in share.shared_playlists(
|
||||||
|
_library(), Selection(all_playlists=True))]
|
||||||
|
assert names == ["Morning", "Road Trip"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- what the copy holds ----
|
||||||
|
|
||||||
|
class TestCopy:
|
||||||
|
def test_tags_yes_history_no(self):
|
||||||
|
library = _library()
|
||||||
|
entry = share.build_library(library, Selection(track_ids={1}))["tracks"][0]
|
||||||
|
assert entry["id"] == 1 and entry["name"] == "Reel One"
|
||||||
|
assert entry["artist"] == "Analog Ghost" and entry["year"] == 1987
|
||||||
|
assert entry["size"] == 4_000_000 and entry["total_time"] == 180000
|
||||||
|
for private in ("play_count", "rating", "loved", "comments", "location",
|
||||||
|
"date_added", "play_date_utc", "persistent_id"):
|
||||||
|
assert private not in entry
|
||||||
|
|
||||||
|
def test_playlists_keep_order_and_drop_unoffered(self):
|
||||||
|
library = _library()
|
||||||
|
library.playlists["P1"].track_ids.append(77) # dangling id
|
||||||
|
data = share.build_playlists(library, Selection(playlist_ids={"P1"}))
|
||||||
|
assert data["playlists"] == [{"id": "P1", "name": "Road Trip",
|
||||||
|
"smart": False, "track_ids": [4, 1]}]
|
||||||
|
|
||||||
|
def test_written_only_when_changed(self, tmp_path):
|
||||||
|
library = _library()
|
||||||
|
selection = Selection(track_ids={1})
|
||||||
|
assert share.publish_share(tmp_path, library, selection)
|
||||||
|
mtime = (tmp_path / share.LIBRARY_FILE).stat().st_mtime_ns
|
||||||
|
assert not share.publish_share(tmp_path, library, selection)
|
||||||
|
assert (tmp_path / share.LIBRARY_FILE).stat().st_mtime_ns == mtime
|
||||||
|
selection.track_ids.add(2)
|
||||||
|
assert share.publish_share(tmp_path, library, selection)
|
||||||
|
data = json.loads((tmp_path / share.LIBRARY_FILE).read_text())
|
||||||
|
assert [t["id"] for t in data["tracks"]] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the service ----
|
||||||
|
|
||||||
|
class TestRepublish:
|
||||||
|
def test_each_friend_gets_their_selection(self, qapp, tmp_path):
|
||||||
|
from lintunes.cassette.service import CassetteService
|
||||||
|
library = _library()
|
||||||
|
service = CassetteService(root=tmp_path, config_loader=lambda: {})
|
||||||
|
service.set_library_provider(lambda: library)
|
||||||
|
service._state.friends["a"] = Friend(token="a", device_id="A", name="Amy",
|
||||||
|
selection=Selection(track_ids={1}))
|
||||||
|
service._state.friends["b"] = Friend(token="b", device_id="B", name="Bo",
|
||||||
|
selection=Selection(playlist_ids={"P2"}))
|
||||||
|
assert sorted(service.republish()) == ["a", "b"]
|
||||||
|
assert service.republish() == []
|
||||||
|
|
||||||
|
def ids(token):
|
||||||
|
data = json.loads((out_dir(tmp_path, token) / share.LIBRARY_FILE).read_text())
|
||||||
|
return [t["id"] for t in data["tracks"]]
|
||||||
|
assert ids("a") == [1]
|
||||||
|
assert ids("b") == [5]
|
||||||
|
|
||||||
|
service._state.share_same_for_all = True
|
||||||
|
service._state.general = Selection(track_ids={3})
|
||||||
|
service.republish()
|
||||||
|
assert ids("a") == ids("b") == [3]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the selector ----
|
||||||
|
|
||||||
|
def _selector(qapp, selection=None, library=None):
|
||||||
|
from lintunes.gui.share_selector import ShareSelector
|
||||||
|
return ShareSelector(library or _library(), selection or Selection())
|
||||||
|
|
||||||
|
|
||||||
|
def _track_items(selector):
|
||||||
|
return {selector.tracks.topLevelItem(i).data(0, Qt.ItemDataRole.UserRole):
|
||||||
|
selector.tracks.topLevelItem(i)
|
||||||
|
for i in range(selector.tracks.topLevelItemCount())}
|
||||||
|
|
||||||
|
|
||||||
|
def _row(column, text):
|
||||||
|
return column.findItems(text, Qt.MatchFlag.MatchExactly)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelector:
|
||||||
|
def test_starts_empty(self, qapp):
|
||||||
|
selector = _selector(qapp)
|
||||||
|
assert all(item.checkState(0) == Qt.CheckState.Unchecked
|
||||||
|
for item in _track_items(selector).values())
|
||||||
|
|
||||||
|
def test_checking_an_artist_checks_its_tracks(self, qapp):
|
||||||
|
selection = Selection()
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
_row(selector.artists, "Analog Ghost").setCheckState(Qt.CheckState.Checked)
|
||||||
|
assert selection.track_ids == {1, 2, 3}
|
||||||
|
assert _row(selector.albums, "Tape Hiss").checkState() == Qt.CheckState.Checked
|
||||||
|
|
||||||
|
def test_album_then_partial_artist(self, qapp):
|
||||||
|
selection = Selection()
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
_row(selector.albums, "Tape Hiss").setCheckState(Qt.CheckState.Checked)
|
||||||
|
assert selection.track_ids == {1, 2}
|
||||||
|
assert _row(selector.artists, "Analog Ghost").checkState() == \
|
||||||
|
Qt.CheckState.PartiallyChecked
|
||||||
|
|
||||||
|
def test_genre_filter_scopes_the_artist_box(self, qapp):
|
||||||
|
selection = Selection()
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
selector.genres.setCurrentItem(_row(selector.genres, "Jazz"))
|
||||||
|
_row(selector.artists, "Analog Ghost").setCheckState(Qt.CheckState.Checked)
|
||||||
|
assert selection.track_ids == {2, 3} # not the Folk one
|
||||||
|
|
||||||
|
def test_playlist_tracks_are_locked_with_a_tooltip(self, qapp):
|
||||||
|
selection = Selection()
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
_row(selector.playlists, "Road Trip").setCheckState(Qt.CheckState.Checked)
|
||||||
|
assert selection.playlist_ids == {"P1"}
|
||||||
|
item = _track_items(selector)[4]
|
||||||
|
assert item.checkState(0) == Qt.CheckState.Checked
|
||||||
|
assert not item.flags() & Qt.ItemFlag.ItemIsUserCheckable
|
||||||
|
assert "Road Trip" in item.toolTip(0)
|
||||||
|
assert 4 not in selection.track_ids # locked, not chosen
|
||||||
|
|
||||||
|
def test_select_none_keeps_locked_tracks(self, qapp):
|
||||||
|
selection = Selection(track_ids={2, 3}, playlist_ids={"P1"})
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
selector.lib_none.click()
|
||||||
|
assert selection.track_ids == set()
|
||||||
|
assert _track_items(selector)[1].checkState(0) == Qt.CheckState.Checked
|
||||||
|
|
||||||
|
def test_select_all_buttons(self, qapp):
|
||||||
|
selection = Selection()
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
selector.lib_all.click()
|
||||||
|
selector.pl_all.click()
|
||||||
|
assert selection.track_ids == {1, 2, 3, 4, 5}
|
||||||
|
assert selection.playlist_ids == {"P1", "P2"}
|
||||||
|
|
||||||
|
def test_whole_library_greys_the_browser(self, qapp):
|
||||||
|
selection = Selection()
|
||||||
|
selector = _selector(qapp, selection)
|
||||||
|
selector.whole_library.setChecked(True)
|
||||||
|
assert selection.all_library
|
||||||
|
assert not selector._browser_host.isEnabled()
|
||||||
|
assert all(i.checkState(0) == Qt.CheckState.Checked
|
||||||
|
for i in _track_items(selector).values())
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the dialog ----
|
||||||
|
|
||||||
|
class TestSameForAll:
|
||||||
|
def test_turning_it_off_restores_each_friend(self, qapp, tmp_path):
|
||||||
|
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
|
||||||
|
from tests.test_round64 import _NoProbe
|
||||||
|
state = CassetteState()
|
||||||
|
state.friends["a"] = Friend(token="a", device_id="A", name="Amy",
|
||||||
|
selection=Selection(track_ids={1}))
|
||||||
|
dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe(),
|
||||||
|
library=_library())
|
||||||
|
dialog._show_page(1)
|
||||||
|
page = dialog.friend_pages["a"]
|
||||||
|
dialog.general.same_for_all.setChecked(True)
|
||||||
|
assert dialog.general.selector is not None
|
||||||
|
assert page.using_general.isVisibleTo(dialog)
|
||||||
|
assert not page.selector.whole_library.isEnabled()
|
||||||
|
dialog.general.selector.lib_all.click()
|
||||||
|
dialog.general.same_for_all.setChecked(False)
|
||||||
|
assert dialog.state.friends["a"].selection.track_ids == {1}
|
||||||
|
assert dialog.state.general.track_ids == {1, 2, 3, 4, 5}
|
||||||
|
assert not dialog.state.share_same_for_all
|
||||||
Reference in New Issue
Block a user