Files
lintunes/lintunes/gui/cassette_ui.py
T
trav d6140f5ac4 v0.35.0: a play history for the yearly Wrapped
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.
2026-10-02 16:47:40 -07:00

542 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()