The play journals keep running totals per track, which is enough for "most played ever" and nothing a Wrapped asks: top songs of this year, minutes listened, which month, what time of day, what got skipped. Those all come from one timestamped line per listen, so LinTunes now keeps that, while Preferences -> General -> "Keep a play history for my yearly Wrapped" is on. history/<machine>/<YYYY-MM>.jsonl, one writer per folder like the journals: local start time with its offset, ms actually heard (Player adds position steps under 1.5 s, so seeks and pauses don't count), how it ended, where it played and from what, and a name/artist/album snapshot that outlives a delete. Under 5 s is noise and never written. andTunes 0.3.0 writes the same lines on the Rabbit when sync says the desktop wants them, and sync copies each month home when the device's copy is longer. "Share this year's play history with friends" puts a trimmed copy (no ids, playlists or devices) in each friend's cassette folder, daily in November and December and monthly otherwise.
542 lines
23 KiB
Python
542 lines
23 KiB
Python
"""Cassette's place in the main window: its Connections-menu group, the
|
||
"which machine is the host" gate, the service that runs the handshake, and
|
||
the dialogs those menu items open.
|
||
|
||
Kept out of main_window.py, which is big enough; MainWindow owns one of these
|
||
and calls ``build_menu`` / ``refresh_menu`` / ``shutdown``.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from PyQt6.QtCore import QObject, QTimer
|
||
from PyQt6.QtGui import QAction
|
||
from PyQt6.QtWidgets import QDialog, QMenu, QMessageBox
|
||
|
||
from lintunes.cassette import host
|
||
from lintunes.cassette import syncthing_api as st
|
||
from lintunes.config import load_config, save_config
|
||
|
||
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
|
||
# How often to ask whether this year's play history is due to go to friends.
|
||
# The cadence itself (daily in Nov–Dec, else monthly) is cassette/wrapped.py's.
|
||
WRAPPED_CHECK_MS = 60 * 60 * 1000
|
||
|
||
|
||
class CassetteUi(QObject):
|
||
def __init__(self, window, prefs, manager=None, parent=None,
|
||
service_factory=None):
|
||
super().__init__(parent or window)
|
||
self._window = window
|
||
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._wrapped_was = None
|
||
self._wrapped_timer = QTimer(self)
|
||
self._wrapped_timer.setInterval(WRAPPED_CHECK_MS)
|
||
self._wrapped_timer.timeout.connect(self._check_wrapped)
|
||
self._elsewhere_action: QAction | None = None
|
||
self._actions: list[QAction] = []
|
||
self._service_factory = service_factory
|
||
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) ----
|
||
|
||
def start_if_host(self):
|
||
"""At startup: run the handshake loop if this machine is the host."""
|
||
if host.is_host(self._prefs):
|
||
self._ensure_service()
|
||
|
||
def _ensure_service(self):
|
||
if self.service is None:
|
||
if self._service_factory is not None:
|
||
self.service = self._service_factory()
|
||
else:
|
||
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)
|
||
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()
|
||
# Whatever changed while LinTunes was closed.
|
||
self._republish_timer.start()
|
||
if self._manager is not None:
|
||
from lintunes.gui.cassette_delivery import DeliveryCoordinator
|
||
self.delivery = DeliveryCoordinator(self.service, self._manager, self)
|
||
self.delivery.notice.connect(self._on_notice)
|
||
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()
|
||
if self._manager is not None:
|
||
self._prefs.changed.connect(self._on_prefs_for_wrapped)
|
||
self.service.friends_added.connect(self._check_wrapped)
|
||
self._wrapped_timer.start()
|
||
self._check_wrapped()
|
||
return self.service
|
||
|
||
def _wrapped_enabled(self) -> bool:
|
||
wrapped = self._prefs.get("wrapped") or {}
|
||
return bool(wrapped.get("track_history")
|
||
and wrapped.get("share_with_friends"))
|
||
|
||
def _on_prefs_for_wrapped(self):
|
||
# prefs.changed fires on every color-slider step; only the switch
|
||
# itself is news here.
|
||
if self._wrapped_enabled() != self._wrapped_was:
|
||
self._check_wrapped()
|
||
|
||
def _check_wrapped(self, *_):
|
||
if self.service is None or self._manager is None:
|
||
return
|
||
enabled = self._wrapped_was = self._wrapped_enabled()
|
||
try:
|
||
self.service.publish_wrapped(self._manager.data_dir, enabled)
|
||
except OSError as exc:
|
||
self._on_step_failed("Sharing your play history", str(exc))
|
||
|
||
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))
|
||
|
||
# ---- the ▾ library selector / friend mode ----
|
||
|
||
def _refresh_selector(self):
|
||
"""The ▾ shows once there's a friend to choose."""
|
||
has_friends = self.service is not None and bool(self.service.state().friends)
|
||
sidebar = getattr(self._window, "sidebar", None)
|
||
if sidebar is not None and sidebar.friend_button.isHidden() == has_friends:
|
||
sidebar.set_friend_selector_visible(has_friends)
|
||
|
||
def show_friend_menu(self):
|
||
"""The ▾ was clicked. With unsaved friend-mode changes, settle those
|
||
first; then offer my library and each friend by display name."""
|
||
if self.service is None:
|
||
return
|
||
if self.friend_mode is not None and self.friend_mode.is_dirty():
|
||
if not self.friend_mode.confirm_leave():
|
||
return
|
||
sidebar = self._window.sidebar
|
||
menu = QMenu(self._window)
|
||
mine = menu.addAction("My Library")
|
||
mine.setEnabled(self.friend_mode is not None and self.friend_mode.active)
|
||
menu.addSeparator()
|
||
friends = sorted(self.service.state().friends.values(),
|
||
key=lambda f: f.name.casefold())
|
||
by_action = {}
|
||
for friend in friends:
|
||
action = menu.addAction(friend.name)
|
||
by_action[action] = friend.token
|
||
button = sidebar.friend_button
|
||
chosen = menu.exec(button.mapToGlobal(button.rect().bottomLeft()))
|
||
if chosen is None:
|
||
return
|
||
if chosen is mine:
|
||
self.leave_friend_mode()
|
||
elif chosen in by_action:
|
||
self.enter_friend_mode(by_action[chosen])
|
||
|
||
def enter_friend_mode(self, token: str):
|
||
from lintunes.gui.friend_mode import FriendMode
|
||
if self.friend_mode is not None and self.friend_mode.active:
|
||
if not self.friend_mode.confirm_leave():
|
||
return
|
||
music_dir = None
|
||
if self._manager is not None:
|
||
music_dir = self._manager.organize_root()
|
||
self.friend_mode = FriendMode(
|
||
self._window, self.service, self._manager.library,
|
||
music_dir or self.service.root)
|
||
if self.delivery is not None:
|
||
# A save changed what I ask for: act on it now, not in ten minutes.
|
||
self.friend_mode.left.connect(self.delivery.schedule)
|
||
self.friend_mode.enter(token)
|
||
|
||
def leave_friend_mode(self):
|
||
if self.friend_mode is not None and self.friend_mode.active:
|
||
self.friend_mode.confirm_leave()
|
||
|
||
def confirm_quit(self) -> bool:
|
||
if self.friend_mode is None or not self.friend_mode.active:
|
||
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:
|
||
refresh(track_ids)
|
||
|
||
def shutdown(self):
|
||
if self.delivery is not None:
|
||
self.delivery.stop()
|
||
if self.service is not None:
|
||
self.service.stop()
|
||
|
||
def _on_notice(self, message: str):
|
||
self._window.statusBar().showMessage(message, NOTICE_MS)
|
||
|
||
def _on_step_failed(self, step: str, detail: str):
|
||
QMessageBox.warning(
|
||
self._window, "Friend Sharing",
|
||
f"{step} failed.\n\nSyncthing said: {detail}")
|
||
|
||
# ---- menu ----
|
||
|
||
def build_menu(self, menu):
|
||
"""Add the Cassette group, between two separators."""
|
||
menu.addSeparator()
|
||
self._elsewhere_action = QAction("", self._window)
|
||
self._elsewhere_action.setEnabled(False)
|
||
self._elsewhere_action.setVisible(False)
|
||
menu.addAction(self._elsewhere_action)
|
||
for text, slot in (("Share Library with a Friend…", self.share_library),
|
||
("Add Friend Library…", self.add_friend),
|
||
("Sync Settings…", self.show_sync_settings)):
|
||
action = QAction(text, self._window)
|
||
action.triggered.connect(slot)
|
||
menu.addAction(action)
|
||
self._actions.append(action)
|
||
menu.addSeparator()
|
||
|
||
def refresh_menu(self):
|
||
status, name = host.host_status(self._prefs)
|
||
elsewhere = status == host.ELSEWHERE
|
||
for action in self._actions:
|
||
action.setEnabled(not elsewhere)
|
||
if self._elsewhere_action is not None:
|
||
self._elsewhere_action.setText(f"Friend sharing runs on {name}")
|
||
self._elsewhere_action.setVisible(elsewhere)
|
||
|
||
# ---- host gate ----
|
||
|
||
def ensure_host(self) -> bool:
|
||
"""True when this machine may run Cassette — asking to claim it when
|
||
no machine has yet."""
|
||
status, name = host.host_status(self._prefs)
|
||
if status == host.HERE:
|
||
self._ensure_service()
|
||
return True
|
||
if status == host.ELSEWHERE:
|
||
QMessageBox.information(
|
||
self._window, "Friend Sharing",
|
||
f"Friend sharing runs on {name}. Open LinTunes there to "
|
||
"share with friends or see their libraries.")
|
||
return False
|
||
answer = QMessageBox.question(
|
||
self._window, "Friend Sharing",
|
||
f"Use this computer ({host.hostname()}) for sharing with friends?\n\n"
|
||
"Only one of your computers handles friends: it keeps their "
|
||
"folders and brings in the songs you ask for. Songs it adds to "
|
||
"your library still reach your other computers as usual.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||
QMessageBox.StandardButton.Yes)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return False
|
||
host.claim(self._prefs)
|
||
self._ensure_service()
|
||
return True
|
||
|
||
# ---- dialogs ----
|
||
|
||
def share_library(self):
|
||
if not self.ensure_host():
|
||
return
|
||
from lintunes.gui.cassette_dialogs import ShareInviteDialog, failure_text
|
||
try:
|
||
code = self.service.create_invite()
|
||
except st.SyncthingError as exc:
|
||
QMessageBox.warning(self._window, "Share Library with a Friend",
|
||
failure_text(exc))
|
||
return
|
||
ShareInviteDialog(code, self._window).exec()
|
||
|
||
def add_friend(self):
|
||
if not self.ensure_host():
|
||
return
|
||
from lintunes.gui.cassette_dialogs import AddFriendDialog
|
||
dialog = AddFriendDialog(self.service, self._window)
|
||
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.friend:
|
||
self._window.statusBar().showMessage(
|
||
f"Connecting to {dialog.friend.name}… You'll see "
|
||
f"\"{dialog.friend.name} is now connected.\" when it's done.",
|
||
NOTICE_MS)
|
||
|
||
def show_sync_settings(self):
|
||
if not self.ensure_host():
|
||
return
|
||
from lintunes.gui.cassette_dialogs import failure_text
|
||
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
|
||
service = self.service
|
||
config = load_config()
|
||
dialog = SyncSettingsDialog(service.state(), service.root, config,
|
||
self._window, service=service,
|
||
library=self._library())
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
dialog.apply()
|
||
changes = dialog.config_changes()
|
||
if changes:
|
||
config = load_config()
|
||
config.update(changes)
|
||
save_config(config)
|
||
try:
|
||
service.apply_settings(dialog.state,
|
||
cancel_invites=dialog.cancelled_invites,
|
||
remove_friends=dialog.removed_friends)
|
||
except st.SyncthingError as exc:
|
||
QMessageBox.warning(self._window, "Sync Settings", failure_text(exc))
|
||
service.kick()
|
||
if self.delivery is not None:
|
||
self.delivery.schedule()
|