diff --git a/GUI/app.py b/GUI/app.py index 28485c5..d69d496 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -236,6 +236,7 @@ def __init__(self, context: "AppContext | None" = None): self._back_sync_workers = [] self._cancelled_workers = [] self._podcast_plan_worker = None + self._subsonic_plan_worker = None self._album_conversion_worker = None self._chapter_split_worker = None self._sync_execute_worker = None @@ -368,6 +369,7 @@ def _build_ui(self): self.sidebar.deviceButton.clicked.connect(self.selectDevice) self.sidebar.rescanButton.clicked.connect(self.resyncDevice) self.sidebar.syncButton.clicked.connect(self.startPCSync) + self.sidebar.subsonicSyncButton.clicked.connect(self.startSubsonicSync) self.sidebar.settingsButton.clicked.connect(self.showSettings) self.sidebar.backupButton.clicked.connect(self.showBackupBrowser) self.sidebar.tag_fixes_requested.connect(self._onIpodTagFixesRequested) @@ -1067,6 +1069,10 @@ def _is_sync_running(self) -> bool: self._podcast_plan_worker is not None and self._podcast_plan_worker.isRunning() ) + or ( + self._subsonic_plan_worker is not None + and self._subsonic_plan_worker.isRunning() + ) or ( self._album_conversion_worker is not None and self._album_conversion_worker.isRunning() @@ -1720,6 +1726,199 @@ def _on_podcast_plan_error(self, plan, error_msg: str, worker=None) -> None: logger.warning("Failed to build podcast plan: %s", error_msg) self.syncReview.show_plan(plan) + # ── Subsonic sync ────────────────────────────────────────────────────── + + def startSubsonicSync(self) -> None: + """Plan a sync from the configured Subsonic-compatible server. + + Builds an ADD-only plan (starred songs + named playlists) in a + background worker, then routes it through the same sync-review / + execute pipeline as a PC sync. Track bytes are streamed during + execution (see ``SyncExecutor._fetch_subsonic_tracks``). + """ + if self._is_sync_running(): + QMessageBox.information( + self, "Sync In Progress", + "A sync is already running. Please wait for it to finish.", + ) + return + + device = self.device_manager + if not device.device_path: + QMessageBox.information( + self, "No Device", + "Select an iPod before syncing from Subsonic.", + ) + return + + # Finish queued quick writes so planning reads committed state. + quick_ready, blocked_label = ( + self._quick_write_controller.prepare_for_full_sync() + ) + if not quick_ready: + label = blocked_label or "quick changes" + QMessageBox.warning( + self, "Quick Changes Still Saving", + f"iOpenPod is still saving pending {label}. Please wait.", + ) + return + if self.library_cache.is_loading(): + QMessageBox.information( + self, "Library Loading", + "Please wait for the iPod library to finish loading.", + ) + return + + settings = self.settings_service.get_effective_settings() + if not (settings.subsonic_url and settings.subsonic_username): + QMessageBox.information( + self, "Subsonic Not Configured", + "Configure a Subsonic server in Settings → Subsonic first.", + ) + return + + ipod_tracks = self.library_cache.get_tracks() or [] + cache_dir = settings.transcode_cache_dir or "" + + ipod_playlists = self.library_cache.get_playlists() or [] + + # Resolve the Subsonic playlist mapping. When the user has selected + # playlists, fetch their names so we can show a mapping dialog (New vs + # merge into an existing iPod playlist) before planning. + playlist_ids = list(settings.subsonic_playlist_ids or ()) + mappings: dict[str, int] = dict(settings.subsonic_playlist_mappings or {}) + + if playlist_ids: + subsonic_playlists = self._fetch_subsonic_playlist_names( + settings.subsonic_url, + settings.subsonic_username, + settings.subsonic_password, + playlist_ids, + ) + if subsonic_playlists is not None: + from GUI.widgets.syncReview import SubsonicPlaylistMappingDialog + + ipod_pairs = [ + ( + int(p.get("playlist_id") or p.get("Playlist ID") or 0), + p.get("Title") or p.get("name") or "", + ) + for p in ipod_playlists + if (p.get("playlist_id") or p.get("Playlist ID")) + and not p.get("master_flag") + ] + dlg = SubsonicPlaylistMappingDialog( + self, + subsonic_playlists, + ipod_pairs, + saved_mappings=mappings, + ) + if dlg.exec() != dlg.DialogCode.Accepted: + return # user cancelled + mappings = dlg.mappings + # Persist the choice so it's remembered next time. + s = self.settings_service.get_global_settings() + s.subsonic_playlist_mappings = mappings + self.settings_service.save_global_settings(s) + + from app_core.jobs import SubsonicPlanRequest, SubsonicPlanWorker + + worker = SubsonicPlanWorker( + SubsonicPlanRequest( + url=settings.subsonic_url, + username=settings.subsonic_username, + password=settings.subsonic_password, + ipod_tracks=ipod_tracks, + cache_dir=cache_dir, + playlist_ids=tuple(playlist_ids), + playlist_mappings=tuple(mappings.items()), + ipod_playlists=tuple(ipod_playlists), + ) + ) + self._subsonic_plan_worker = worker + self.centralStack.setCurrentIndex(1) + self.syncReview.show_loading() + worker.finished.connect( + lambda plan, w=worker: self._on_subsonic_plan_ready(plan, w) + ) + worker.error.connect( + lambda err, w=worker: self._on_subsonic_plan_error(err, w) + ) + worker.start() + + def _fetch_subsonic_playlist_names( + self, url: str, username: str, password: str, playlist_ids: list[str] + ) -> list[tuple[str, str]] | None: + """Fetch (id, name) pairs for the named Subsonic playlists. + + Runs the network call off the UI thread with a wait cursor so the + mapping dialog can show real names. Returns None on failure (the + caller then skips the dialog and falls back to saved mappings). + """ + import threading + + from PyQt6.QtCore import QCoreApplication, Qt + from PyQt6.QtGui import QGuiApplication + + result: list[list] = [[]] # mutable holder + done = threading.Event() + + def _work(): + try: + from SubsonicManager.client import SubsonicClient + + client = SubsonicClient(url, username, password) + pairs: list[tuple[str, str]] = [] + for pid in playlist_ids: + pid = (pid or "").strip() + if not pid: + continue + try: + pl = client.get_playlist(pid) + pairs.append((pid, str(pl.get("name") or pid))) + except Exception: + pairs.append((pid, pid)) + result[0] = pairs + except Exception: + result[0] = [] + finally: + done.set() + + QGuiApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + t = threading.Thread(target=_work, daemon=True) + t.start() + # Spin the event loop while waiting so the UI stays responsive. + deadline_loops = 0 + while not done.is_set() and deadline_loops < 2000: + QCoreApplication.processEvents() + t.join(0.01) + deadline_loops += 1 + QGuiApplication.restoreOverrideCursor() + fetched = result[0] + return fetched if fetched is not None else None + + def _on_subsonic_plan_ready(self, plan, worker=None) -> None: + """Subsonic plan built — show it for review.""" + if worker is not None: + if self._subsonic_plan_worker is not worker: + return + self._subsonic_plan_worker = None + else: + self._subsonic_plan_worker = None + self._plan = plan + self.syncReview.show_plan(plan) + + def _on_subsonic_plan_error(self, error_msg: str, worker=None) -> None: + """Subsonic plan failed — surface the error.""" + if worker is not None: + if self._subsonic_plan_worker is not worker: + return + self._subsonic_plan_worker = None + else: + self._subsonic_plan_worker = None + logger.warning("Failed to build Subsonic plan: %s", error_msg) + self.syncReview.show_error(error_msg) + def _onSyncError(self, error_msg: str): """Called when sync diff fails.""" self.syncReview.show_error(error_msg) diff --git a/GUI/widgets/settingsPage.py b/GUI/widgets/settingsPage.py index b23cbfa..1d13924 100644 --- a/GUI/widgets/settingsPage.py +++ b/GUI/widgets/settingsPage.py @@ -232,6 +232,288 @@ def value(self, v: int): self.spin.setValue(v) +class _SubsonicServerRow(SettingRow): + """Setting row for a Subsonic server connection (URL/user/password). + + Emits ``test_requested`` when the user clicks "Test Connection"; the + owning page validates asynchronously and reports back via + ``set_connected`` / ``set_error``. Plain ``changed`` is emitted on any + field edit so the page can persist immediately (mirroring the way + scrobbling credentials are saved on edit, not on _save). + """ + + test_requested = pyqtSignal(str, str, str) # url, username, password + changed = pyqtSignal() + + def __init__(self, title: str, description: str = ""): + super().__init__(title, description) + + right_layout = QHBoxLayout() + right_layout.setSpacing(8) + right_layout.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.status_label = QLabel("") + self.status_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.status_label.setStyleSheet( + f"color: {Colors.TEXT_SECONDARY}; background: transparent; border: none;" + ) + self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight) + right_layout.addWidget(self.status_label) + + self.inputs_widget = QWidget() + inputs_layout = QHBoxLayout(self.inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.setSpacing(8) + + self.url_input = QLineEdit() + self.url_input.setPlaceholderText("https://music.example.com") + self.url_input.setFixedWidth(180) + self.url_input.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.url_input.setStyleSheet(input_css()) + self.url_input.textChanged.connect(self._on_field_changed) + inputs_layout.addWidget(self.url_input) + + self.username_input = QLineEdit() + self.username_input.setPlaceholderText("Username") + self.username_input.setFixedWidth(110) + self.username_input.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.username_input.setStyleSheet(input_css()) + self.username_input.textChanged.connect(self._on_field_changed) + inputs_layout.addWidget(self.username_input) + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("Password") + self.password_input.setFixedWidth(130) + self.password_input.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + self.password_input.setStyleSheet(input_css()) + self.password_input.textChanged.connect(self._on_field_changed) + inputs_layout.addWidget(self.password_input) + + right_layout.addWidget(self.inputs_widget) + + self.test_btn = QPushButton("Test") + self.test_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.test_btn.setFixedWidth(70) + self.test_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.test_btn.setStyleSheet(btn_css( + bg=Colors.ACCENT, bg_hover=Colors.ACCENT_LIGHT, bg_press=Colors.ACCENT, + fg=Colors.TEXT_ON_ACCENT, border="none", padding="4px 8px", + )) + self.test_btn.clicked.connect(self._on_test) + right_layout.addWidget(self.test_btn) + + container = QWidget() + container.setLayout(right_layout) + self.add_control(container) + + def _on_field_changed(self) -> None: + self.changed.emit() + + def _on_test(self) -> None: + url = self.url_input.text().strip() + username = self.username_input.text().strip() + password = self.password_input.text() + if not url or not username: + self.set_error("Enter URL and username") + return + self.test_requested.emit(url, username, password) + + def set_connected(self, username: str) -> None: + self.status_label.setText(f"✓ Connected as {username}") + self.status_label.setStyleSheet( + f"color: {Colors.SUCCESS}; background: transparent; border: none;" + ) + self.test_btn.setEnabled(True) + self.test_btn.setText("Test") + + def set_error(self, message: str) -> None: + self.status_label.setText(f"✕ {message}") + self.status_label.setStyleSheet( + f"color: {Colors.DANGER}; background: transparent; border: none;" + ) + self.test_btn.setEnabled(True) + self.test_btn.setText("Test") + + def set_validating(self) -> None: + self.status_label.setText("Testing…") + self.status_label.setStyleSheet( + f"color: {Colors.TEXT_SECONDARY}; background: transparent; border: none;" + ) + self.test_btn.setEnabled(False) + self.test_btn.setText("…") + + @property + def url(self) -> str: + return self.url_input.text().strip() + + @property + def username(self) -> str: + return self.username_input.text().strip() + + @property + def password(self) -> str: + return self.password_input.text() + + +class _SubsonicPlaylistRow(SettingRow): + """A selectable list of Subsonic playlists (checkbox per playlist). + + Shows a "Refresh" button that asks the owning page to fetch the server's + playlists via ``refresh_requested``. The fetched list is rendered as one + checkbox per playlist; checked ids are exposed via ``selected_ids`` and + emitted on every toggle via ``selection_changed``. Empty until the first + successful connection + refresh. + + Playlist *mapping* (which Subsonic playlist goes to which iPod playlist) is + chosen in the sync-time dialog, not here — this row only picks *which* + playlists to include. + """ + + refresh_requested = pyqtSignal() + selection_changed = pyqtSignal(list) # list[str] of selected ids + + def __init__(self, title: str, description: str = ""): + super().__init__(title, description) + + # Right side: a status label + a Refresh button. + right = QVBoxLayout() + right.setSpacing(6) + right.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight) + + top = QHBoxLayout() + top.setSpacing(8) + top.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.status_label = QLabel("Connect first to load playlists") + self.status_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.status_label.setStyleSheet( + f"color: {Colors.TEXT_SECONDARY}; background: transparent; border: none;" + ) + top.addWidget(self.status_label) + + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.refresh_btn.setFixedWidth(80) + self.refresh_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_btn.setEnabled(False) + self.refresh_btn.setStyleSheet(btn_css( + bg=Colors.ACCENT, bg_hover=Colors.ACCENT_LIGHT, bg_press=Colors.ACCENT, + fg=Colors.TEXT_ON_ACCENT, border="none", padding="4px 8px", + )) + self.refresh_btn.clicked.connect(self.refresh_requested.emit) + top.addWidget(self.refresh_btn) + right.addLayout(top) + + # Scrollable checkbox area. + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setMinimumHeight(180) + self._scroll.setMinimumWidth(360) + self._scroll.setStyleSheet( + f"QScrollArea {{ border: 1px solid {Colors.BORDER_SUBTLE}; " + f"border-radius: 6px; background: {Colors.SURFACE_ALT}; }}" + ) + self._list_widget = QWidget() + self._list_layout = QVBoxLayout(self._list_widget) + self._list_layout.setContentsMargins(8, 8, 8, 8) + self._list_layout.setSpacing(4) + self._list_layout.addStretch() + self._scroll.setWidget(self._list_widget) + right.addWidget(self._scroll) + + container = QWidget() + container.setLayout(right) + self._checkboxes: list[tuple[str, str, QCheckBox]] = [] + self.add_control(container) + + def set_enabled(self, enabled: bool) -> None: + """Enable/disable the refresh button (e.g. before/after connecting).""" + self.refresh_btn.setEnabled(enabled) + + def set_playlists( + self, + playlists: list[tuple[str, str]], + checked_ids: list[str] | None = None, + ) -> None: + """Render the fetched playlists as checkboxes. + + Args: + playlists: list of (id, name) pairs. + checked_ids: ids that should start checked (e.g. previously saved). + """ + checked_set = set(checked_ids or []) + + # Clear existing checkboxes (keep the trailing stretch item). + while self._list_layout.count() > 1: + item = self._list_layout.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + self._checkboxes.clear() + + for pid, name in playlists: + cb = QCheckBox(name or pid) + cb.setChecked(pid in checked_set) + cb.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + cb.setStyleSheet( + f"QCheckBox {{ color: {Colors.TEXT_PRIMARY}; background: transparent; }}" + ) + cb.toggled.connect(self._on_change) + self._list_layout.insertWidget(self._list_layout.count() - 1, cb) + self._checkboxes.append((pid, name, cb)) + + if playlists: + self.status_label.setText(f"{len(playlists)} playlist(s)") + self.status_label.setStyleSheet( + f"color: {Colors.TEXT_SECONDARY}; background: transparent; border: none;" + ) + else: + self.status_label.setText("No playlists found") + self.set_enabled(True) + + def set_error(self, message: str) -> None: + self.status_label.setText(f"✕ {message}") + self.status_label.setStyleSheet( + f"color: {Colors.DANGER}; background: transparent; border: none;" + ) + self.set_enabled(True) + + def _on_change(self) -> None: + self.selection_changed.emit(self.selected_ids) + + @property + def selected_ids(self) -> list[str]: + return [pid for pid, _name, cb in self._checkboxes if cb.isChecked()] + + +class TextEditRow(SettingRow): + """Setting row with a multi-line text edit (e.g. comma-separated ids).""" + + changed = pyqtSignal(str) + + def __init__(self, title: str, description: str = "", placeholder: str = ""): + super().__init__(title, description) + + from PyQt6.QtWidgets import QPlainTextEdit + + self.edit = QPlainTextEdit() + self.edit.setPlaceholderText(placeholder) + self.edit.setFixedHeight(64) + self.edit.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self.edit.setStyleSheet(input_css()) + self.edit.textChanged.connect(lambda: self.changed.emit(self.edit.toPlainText())) + self.add_control(self.edit) + + @property + def value(self) -> str: + return self.edit.toPlainText() + + @value.setter + def value(self, v: str): + self.edit.setPlainText(v or "") + + class FolderRow(SettingRow): """Setting row with folder path display and browse button.""" @@ -1224,6 +1506,7 @@ def __init__( self._settings_service = settings_service self._device_sessions = device_sessions self._pending_lb_result: tuple[str, str, str, str, str, bool] = ("", "", "global", "", "", False) + self._pending_subsonic_result: tuple[str, str, str] | None = None self._update_checker: object | None = None self._update_downloader: object | None = None self._update_progress: QProgressDialog | None = None @@ -1248,6 +1531,7 @@ def __init__( self._stack.addWidget(self._build_scrobbling_page()) # 4 self._stack.addWidget(self._build_storage_page()) # 5 self._stack.addWidget(self._build_backups_page()) # 6 + self._stack.addWidget(self._build_subsonic_page()) # 7 main.addWidget(self._stack, stretch=1) # Select first page @@ -1294,7 +1578,7 @@ def _build_sidebar(self) -> QFrame: self._nav_buttons: list[QPushButton] = [] nav_items = [ "General", "Sync", "Transcoding", - "External Tools", "Scrobbling", "Storage", "Backups", + "External Tools", "Scrobbling", "Storage", "Backups", "Subsonic", ] for i, name in enumerate(nav_items): btn = QPushButton(name) @@ -1872,6 +2156,35 @@ def _build_scrobbling_page(self) -> QScrollArea: ), ) + def _build_subsonic_page(self) -> QScrollArea: + """Settings page for the Subsonic-compatible server sync source.""" + self.subsonic_playlist_row = _SubsonicPlaylistRow( + "Playlists", + "Select server playlists to recreate on the iPod using songs " + "already in the library. Connect to the server first, then " + "Refresh to load the list.", + ) + + self.subsonic_server_row = _SubsonicServerRow( + "Server", + "Connect to a Subsonic-compatible server (Navidrome, Airsonic, " + "Gonic, original Subsonic) to sync its playlists.", + ) + self.subsonic_server_row.test_requested.connect(self._on_subsonic_test) + self.subsonic_server_row.changed.connect(self._save_subsonic_fields) + self.subsonic_playlist_row.refresh_requested.connect(self._on_subsonic_refresh_playlists) + self.subsonic_playlist_row.selection_changed.connect(self._on_subsonic_playlist_selection) + + return self._make_page( + "Subsonic", + "Server", + _SettingsCard(self.subsonic_server_row), + "Selection", + _SettingsCard( + self.subsonic_playlist_row, + ), + ) + def _build_storage_page(self) -> QScrollArea: from infrastructure.settings_paths import default_cache_dir self.transcode_cache_dir = ResettableFolderRow( @@ -2169,6 +2482,18 @@ def load_from_settings(self): else: self.lastfm_auth_row.set_disconnected(s.lastfm_api_key, s.lastfm_api_secret) + # Subsonic + self.subsonic_server_row.url_input.setText(s.subsonic_url) + self.subsonic_server_row.username_input.setText(s.subsonic_username) + self.subsonic_server_row.password_input.setText(s.subsonic_password) + # Playlists are loaded from the server on connect; we cannot render a + # checkbox selection before that, so just enable Refresh if configured. + if s.subsonic_url and s.subsonic_username: + self.subsonic_server_row.status_label.setText("Saved") + self.subsonic_playlist_row.set_enabled(True) + else: + self.subsonic_server_row.status_label.setText("") + self.show_art.value = s.show_art_in_tracklist self.rounded_artwork.value = s.rounded_artwork self.sharpen_artwork.value = s.sharpen_artwork @@ -2572,6 +2897,14 @@ def _read_controls_into_settings(self, s, include_global_only: bool) -> None: s.scrobble_on_sync = self.scrobble_on_sync.value + # Subsonic selection toggles (credentials are saved on edit via + # _save_subsonic_fields, but mirror them here for a clean _save round-trip). + s.subsonic_url = self.subsonic_server_row.url + s.subsonic_username = self.subsonic_server_row.username + s.subsonic_password = self.subsonic_server_row.password + s.subsonic_playlist_ids = list(self.subsonic_playlist_row.selected_ids) + s.subsonic_enabled = bool(s.subsonic_url and s.subsonic_username) + # Lossy encoder enc_text = self.lossy_encoder.value s.lossy_encoder = "auto" if enc_text == "Auto" else enc_text @@ -3173,6 +3506,130 @@ def _on_listenbrainz_validate_result(self): self.listenbrainz_token_row.set_connected(username) + # ── Subsonic handlers ───────────────────────────────────────────────── + + def _save_subsonic_fields(self, *_args) -> None: + """Persist all Subsonic fields from the UI immediately on edit. + + Credentials (URL/user/password) are stored in plaintext like the + Last.fm fields; no separate secret store is used. + """ + if self._loading_settings: + return + + playlist_ids = list(self.subsonic_playlist_row.selected_ids) + + s = self._settings_service.get_global_settings() + s.subsonic_url = self.subsonic_server_row.url + s.subsonic_username = self.subsonic_server_row.username + s.subsonic_password = self.subsonic_server_row.password + s.subsonic_playlist_ids = playlist_ids + s.subsonic_enabled = bool(s.subsonic_url and s.subsonic_username) + self._settings_service.save_global_settings(s) + + def _on_subsonic_playlist_selection(self, _ids: list) -> None: + """A playlist checkbox was toggled — persist the new selection.""" + self._save_subsonic_fields() + + def _on_subsonic_refresh_playlists(self) -> None: + """Fetch the server's playlists in the background and render them.""" + url = self.subsonic_server_row.url + username = self.subsonic_server_row.username + password = self.subsonic_server_row.password + if not (url and username): + self.subsonic_playlist_row.set_error("Enter URL and username") + return + self.subsonic_playlist_row.refresh_btn.setEnabled(False) + self.subsonic_playlist_row.status_label.setText("Loading…") + self._pending_subsonic_result = (url, username, password) + + import threading + + def _do_fetch(): + from SubsonicManager.client import SubsonicClient + + playlists: list[tuple[str, str]] = [] + ok = False + try: + client = SubsonicClient(url, username, password) + raw = client.get_playlists() + playlists = [ + (str(p.get("id", "")), str(p.get("name", p.get("id", "")))) + for p in raw + if p.get("id") + ] + ok = True + except Exception: + playlists = [] + self._pending_subsonic_playlists = playlists + self._pending_subsonic_playlists_ok = ok + from PyQt6.QtCore import QMetaObject + from PyQt6.QtCore import Qt as QtCore_Qt + + QMetaObject.invokeMethod( + self, + "_on_subsonic_playlists_loaded", + QtCore_Qt.ConnectionType.QueuedConnection, + ) + + threading.Thread(target=_do_fetch, daemon=True).start() + + @pyqtSlot() + def _on_subsonic_playlists_loaded(self) -> None: + """Called on main thread after the playlist fetch completes.""" + playlists = getattr(self, "_pending_subsonic_playlists", []) + ok = getattr(self, "_pending_subsonic_playlists_ok", False) + if not ok: + self.subsonic_playlist_row.set_error("Could not load playlists") + return + # Preserve the currently-saved selection when re-rendering. + saved = self._settings_service.get_global_settings() + saved_ids = list(saved.subsonic_playlist_ids) + self.subsonic_playlist_row.set_playlists(playlists, checked_ids=saved_ids) + + def _on_subsonic_test(self, url: str, username: str, password: str) -> None: + """Validate the Subsonic connection in a background thread.""" + self.subsonic_server_row.set_validating() + self._pending_subsonic_result = (url, username, password) + + import threading + + def _do_ping(): + from SubsonicManager.client import SubsonicClient + + ok = False + try: + client = SubsonicClient(url, username, password) + client.ping() + ok = True + except Exception: + ok = False + self._pending_subsonic_ok = ok + from PyQt6.QtCore import QMetaObject + from PyQt6.QtCore import Qt as QtCore_Qt + + QMetaObject.invokeMethod( + self, + "_on_subsonic_validate_result", + QtCore_Qt.ConnectionType.QueuedConnection, + ) + + threading.Thread(target=_do_ping, daemon=True).start() + + @pyqtSlot() + def _on_subsonic_validate_result(self) -> None: + """Called on main thread after the Subsonic ping completes.""" + ok = getattr(self, "_pending_subsonic_ok", False) + url, username, _password = self._pending_subsonic_result or ("", "", "") + if ok: + self.subsonic_server_row.set_connected(username) + # Persist now that the connection is confirmed. + self._save_subsonic_fields() + # Auto-load the playlist list now that we have a working connection. + self._on_subsonic_refresh_playlists() + else: + self.subsonic_server_row.set_error("Could not connect / bad credentials") + # ── Last.fm Scrobbling handlers ──────────────────────────────────────── def _save_lastfm_credentials( diff --git a/GUI/widgets/sidebar.py b/GUI/widgets/sidebar.py index e4b85aa..2062680 100644 --- a/GUI/widgets/sidebar.py +++ b/GUI/widgets/sidebar.py @@ -894,6 +894,16 @@ def __init__(self): self.syncButton.setIconSize(_icon_sz) self.sidebarLayout.addWidget(self.syncButton) + # Subsonic sync button - row 3 (full width, secondary accent) + self.subsonicSyncButton = QPushButton("Sync Subsonic") + self.subsonicSyncButton.setStyleSheet(accent_btn_css()) + self.subsonicSyncButton.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG, QFont.Weight.DemiBold)) + _bi = glyph_icon("cloud", (20), Colors.TEXT_ON_ACCENT) + if _bi: + self.subsonicSyncButton.setIcon(_bi) + self.subsonicSyncButton.setIconSize(_icon_sz) + self.sidebarLayout.addWidget(self.subsonicSyncButton) + # Backup button self.backupButton = QPushButton("Backups") self.backupButton.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG)) diff --git a/GUI/widgets/syncReview.py b/GUI/widgets/syncReview.py index 295061e..a66a608 100644 --- a/GUI/widgets/syncReview.py +++ b/GUI/widgets/syncReview.py @@ -19,7 +19,9 @@ from PyQt6.QtCore import QRectF, QSize, Qt, QTimer, pyqtSignal from PyQt6.QtGui import QColor, QFont, QPainter from PyQt6.QtWidgets import ( + QButtonGroup, QCheckBox, + QComboBox, QDialog, QFileDialog, QFrame, @@ -28,8 +30,10 @@ QMessageBox, QProgressBar, QPushButton, + QRadioButton, QSizePolicy, QStackedWidget, + QTabWidget, QVBoxLayout, QWidget, ) @@ -4070,3 +4074,282 @@ def _accept_back_sync(self): return self.sync_mode = "back_sync" self.accept() + + +class SubsonicPlaylistMappingDialog(QDialog): + """Choose how each Subsonic playlist maps onto the iPod. + + Shown right before a Subsonic sync starts. Two tabs: + + **Playlists** — select which iPod playlist each Subsonic list maps to + (new, or an existing one). + + **Mode** — for each *mapped* playlist, choose Merge (keep existing iPod + tracks + add new) or Overwrite (replace the iPod playlist with the + Subsonic one). + + ``mappings`` (read after ``exec()``) is a ``{subsonic_id: ipod_id}`` dict + — positive ids = merge, negative ids = overwrite. + """ + + def __init__( + self, + parent=None, + subsonic_playlists: list[tuple[str, str]] | None = None, + ipod_playlists: list[tuple[int, str]] | None = None, + *, + saved_mappings: dict[str, int] | None = None, + ): + super().__init__(parent) + self.setWindowTitle("Subsonic Sync — Map & Mode") + self.setMinimumSize(640, 500) + self.mappings: dict[str, int] = {} + self._subsonic = list(subsonic_playlists or []) + self._ipod = list(ipod_playlists or []) + self._saved = dict(saved_mappings or {}) + self._combos: list[tuple[str, QComboBox]] = [] + self._mode_rows: dict[str, QButtonGroup] = {} + self._setup_ui() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(8) + + title = QLabel("Subsonic Sync") + title.setFont(QFont(FONT_FAMILY, Metrics.FONT_HERO, QFont.Weight.Bold)) + title.setStyleSheet(f"color: {Colors.TEXT_PRIMARY}; background: transparent;") + layout.addWidget(title) + + tabs = QTabWidget() + tabs.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + tabs.addTab(self._build_playlist_tab(), "Mapping") + tabs.addTab(self._build_mode_tab(), "Sync Mode") + layout.addWidget(tabs, stretch=1) + + # Buttons. + btns = QHBoxLayout() + btns.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD)) + cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor) + cancel_btn.setStyleSheet(btn_css( + bg="transparent", bg_hover=Colors.SURFACE_ACTIVE, + fg=Colors.TEXT_SECONDARY, border=f"1px solid {Colors.BORDER}", + )) + cancel_btn.clicked.connect(self.reject) + btns.addWidget(cancel_btn) + + ok_btn = QPushButton("Continue") + ok_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD, QFont.Weight.DemiBold)) + ok_btn.setCursor(Qt.CursorShape.PointingHandCursor) + ok_btn.setStyleSheet(btn_css( + bg=Colors.ACCENT, bg_hover=Colors.ACCENT_LIGHT, bg_press=Colors.ACCENT, + fg=Colors.TEXT_ON_ACCENT, border="none", + )) + ok_btn.clicked.connect(self._accept) + btns.addWidget(ok_btn) + layout.addLayout(btns) + + # ---- Tab 1: Playlist mapping ---- + + def _build_playlist_tab(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(8, 12, 8, 8) + layout.setSpacing(8) + + hint = QLabel( + "Choose where each Subsonic playlist appears on the iPod.\n" + "\"New (same name)\" creates a fresh playlist; selecting an " + "existing playlist links it for sync." + ) + hint.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + hint.setWordWrap(True) + hint.setStyleSheet(f"color: {Colors.TEXT_SECONDARY}; background: transparent;") + layout.addWidget(hint) + + scroll = make_scroll_area() + content = QWidget() + content.setStyleSheet("background: transparent;") + rows = QVBoxLayout(content) + rows.setContentsMargins(0, 0, 0, 0) + rows.setSpacing(8) + + if not self._subsonic: + empty = QLabel("No Subsonic playlists selected.") + empty.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + empty.setStyleSheet(f"color: {Colors.TEXT_TERTIARY}; background: transparent;") + rows.addWidget(empty) + else: + for sid, name in self._subsonic: + row = QFrame() + row.setStyleSheet( + f"QFrame {{ background: {Colors.SURFACE}; border-radius: 6px; }}" + ) + rl = QHBoxLayout(row) + rl.setContentsMargins(12, 10, 12, 10) + rl.setSpacing(12) + + lbl = QLabel(name or sid) + lbl.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD)) + lbl.setStyleSheet(f"color: {Colors.TEXT_PRIMARY}; background: transparent;") + rl.addWidget(lbl, stretch=1) + + combo = QComboBox() + combo.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + combo.setMinimumWidth(220) + combo.addItem("New (same name)", None) + for ipod_id, ipod_name in self._ipod: + combo.addItem(ipod_name or str(ipod_id), ipod_id) + # Pre-select a saved mapping. + target = self._saved.get(sid) + if target: + idx = combo.findData(abs(target)) + if idx >= 0: + combo.setCurrentIndex(idx) + combo.currentIndexChanged.connect(lambda _i: self._rebuild_mode_tab()) + rl.addWidget(combo) + rows.addWidget(row) + self._combos.append((sid, combo)) + + rows.addStretch() + scroll.setWidget(content) + layout.addWidget(scroll, stretch=1) + return page + + # ---- Tab 2: Sync mode ---- + + def _build_mode_tab(self) -> QWidget: + self._mode_page = QWidget() + self._mode_content = None + self._rebuild_mode_tab() + return self._mode_page + + def _rebuild_mode_tab(self) -> None: + """Rebuild the mode tab when mapping combos change.""" + if not hasattr(self, "_mode_page") or self._mode_page is None: + return + # Clear. + if self._mode_content is not None: + self._mode_content.setParent(None) + layout = QVBoxLayout(self._mode_page) + layout.setContentsMargins(8, 12, 8, 8) + layout.setSpacing(8) + + hint = QLabel( + "For each mapped playlist, choose the sync direction and mode.\n" + "• Remote → Local (Overwrite): replace iPod playlist with Subsonic version.\n" + "• Remote → Local (Add): add Subsonic tracks to the iPod playlist (merge)." + ) + hint.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + hint.setWordWrap(True) + hint.setStyleSheet(f"color: {Colors.TEXT_SECONDARY}; background: transparent;") + layout.addWidget(hint) + + scroll = make_scroll_area() + content = QWidget() + content.setStyleSheet("background: transparent;") + rows = QVBoxLayout(content) + rows.setContentsMargins(0, 0, 0, 0) + rows.setSpacing(8) + self._mode_rows.clear() + + any_mapped = False + for sid, combo in self._combos: + target = combo.currentData() + if not isinstance(target, int) or not target: + continue + ipod_name = str(combo.currentText()) + sub_name = next((n for i, n in self._subsonic if i == sid), sid) + any_mapped = True + + row = QFrame() + row.setStyleSheet( + f"QFrame {{ background: {Colors.SURFACE}; border-radius: 6px; }}" + ) + rl = QVBoxLayout(row) + rl.setContentsMargins(12, 10, 12, 10) + rl.setSpacing(4) + + # Header + header = QLabel(f"{sub_name} (remote) → {ipod_name} (local)") + header.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM, QFont.Weight.Bold)) + header.setStyleSheet(f"color: {Colors.TEXT_PRIMARY}; background: transparent;") + rl.addWidget(header) + + group = QButtonGroup(row) + options = QHBoxLayout() + options.setSpacing(12) + + # Mode values: + # 0 = Remote → Local (Overwrite) → negative id → overwrite + # 1 = Remote → Local (Add) → positive id → merge (DEFAULT) + # 2 = Local → Remote (Overwrite) → not yet supported + # 3 = Local → Remote (Add) → not yet supported + + rb_overwrite = QRadioButton("Remote → Local (Overwrite)") + rb_add = QRadioButton("Remote → Local (Add)") + rb_local_overwrite = QRadioButton("Local → Remote (Overwrite)") + rb_local_add = QRadioButton("Local → Remote (Add)") + + for rb in (rb_overwrite, rb_add, rb_local_overwrite, rb_local_add): + rb.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + rb.setStyleSheet( + f"QRadioButton {{ color: {Colors.TEXT_PRIMARY}; background: transparent; }}" + f"QRadioButton:disabled {{ color: {Colors.TEXT_TERTIARY}; }}" + ) + options.addWidget(rb) + + group.addButton(rb_overwrite, 0) + group.addButton(rb_add, 1) + group.addButton(rb_local_overwrite, 2) + group.addButton(rb_local_add, 3) + + # Local → Remote not yet supported — grey out. + rb_local_overwrite.setEnabled(False) + rb_local_add.setEnabled(False) + rb_local_overwrite.setToolTip("Writing to Subsonic server is not yet supported") + rb_local_add.setToolTip("Writing to Subsonic server is not yet supported") + + # Pre-select based on saved mapping. + saved = self._saved.get(sid) + if saved and saved < 0: + rb_overwrite.setChecked(True) + else: + rb_add.setChecked(True) + + rl.addLayout(options) + rows.addWidget(row) + self._mode_rows[sid] = group + + if not any_mapped: + empty = QLabel( + "No playlists are mapped to an existing iPod list yet.\n" + "Go back to the Mapping tab and select a target playlist." + ) + empty.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + empty.setWordWrap(True) + empty.setStyleSheet(f"color: {Colors.TEXT_TERTIARY}; background: transparent;") + rows.addWidget(empty) + + rows.addStretch() + scroll.setWidget(content) + layout.addWidget(scroll, stretch=1) + self._mode_content = content + + # ---- accept ---- + + def _accept(self) -> None: + self.mappings = {} + for sid, combo in self._combos: + target = combo.currentData() + if not isinstance(target, int) or not target: + continue + # Mode 0 (Remote→Local Overwrite) → negative id + # Mode 1 (Remote→Local Add) → positive id (merge) + group = self._mode_rows.get(sid) + if group is not None and group.checkedId() == 0: + target = -target + self.mappings[sid] = target + self.accept() diff --git a/SubsonicManager/__init__.py b/SubsonicManager/__init__.py new file mode 100644 index 0000000..b05964d --- /dev/null +++ b/SubsonicManager/__init__.py @@ -0,0 +1,17 @@ +"""Subsonic-compatible server sync source. + +Provides music sync from Subsonic-compatible servers (Navidrome, Airsonic, +Gonic, original Subsonic) to iPod devices. Mirrors the structure of +``PodcastManager``: a self-contained source module that builds a +``SyncPlan`` directly (bypassing the PC-library diff engine) and streams +track bytes during execution. +""" + +from .client import SubsonicClient, SubsonicConnectionError +from .plan_builder import build_subsonic_sync_plan + +__all__ = [ + "SubsonicClient", + "SubsonicConnectionError", + "build_subsonic_sync_plan", +] diff --git a/SubsonicManager/artwork.py b/SubsonicManager/artwork.py new file mode 100644 index 0000000..e2de6dd --- /dev/null +++ b/SubsonicManager/artwork.py @@ -0,0 +1,95 @@ +"""Cover-art handling for the Subsonic source. + +Two concerns: + +1. Navidrome (and some other servers) serve a static placeholder image for + *every* album's cover art, even albums with no real cover. A non-empty + ``coverArt`` id therefore does not imply real artwork. We detect the + placeholder at connect time by hashing the response to an empty + ``getCoverArt`` id and comparing later covers against it (lightweight + version of podkit's ``subsonic/cache.ts`` placeholder probe). + +2. Computing an ``art_hash`` (MD5) for a Subsonic cover so the sync plan can + report artwork presence; the actual bytes are re-extracted from the + downloaded file by the standard ArtworkDB pipeline at write time. +""" + +from __future__ import annotations + +import logging + +from ArtworkDB_Writer.art_extractor import art_hash + +from .client import SubsonicClient, SubsonicConnectionError + +log = logging.getLogger(__name__) + +# Covers smaller than this are almost certainly not real artwork. +_MIN_ARTWORK_BYTES = 100 + + +def detect_placeholder_cover(client: SubsonicClient) -> str | None: + """Probe the server for a Navidrome-style placeholder cover. + + Requests ``getCoverArt`` with an empty id. If the server returns a + real image, its MD5 is cached on ``client.placeholder_cover_hash`` and + also returned, so callers can later treat matching covers as "no real + artwork". Servers that 404/error (Gonic) yield ``None`` and disable + placeholder filtering. + + Safe to ignore failures — worst case we just don't filter placeholders. + """ + try: + # Empty id: Navidrome returns its placeholder; Gonic errors out. + bytes_ = client.get_cover_art_bytes("") + except SubsonicConnectionError: + client.placeholder_cover_hash = None + return None + except Exception as exc: # HTTP errors from raise_for_status, etc. + log.debug("Placeholder cover probe failed: %s", exc) + client.placeholder_cover_hash = None + return None + + if not bytes_ or len(bytes_) < _MIN_ARTWORK_BYTES: + client.placeholder_cover_hash = None + return None + + digest = art_hash(bytes_) + client.placeholder_cover_hash = digest + log.debug("Detected Subsonic placeholder cover hash %s", digest) + return digest + + +def classify_cover( + client: SubsonicClient, + cover_art_id: str | None, +) -> tuple[bool, str | None]: + """Determine whether a song has real artwork. + + Returns ``(has_artwork, art_hash)``. ``has_artwork`` is True only when a + cover id is present and the fetched bytes are a real image (not the + server's placeholder). ``art_hash`` is the MD5 of the cover bytes when + real, else None. + + When ``client.placeholder_cover_hash`` is None (placeholder detection + disabled or unsupported) we cannot reliably tell, so we return + ``(True, None)`` for any present cover id — meaning "assume real, hash + unknown" — which matches podkit's flag-off behaviour. + """ + if not cover_art_id: + return (False, None) + + try: + bytes_ = client.get_cover_art_bytes(cover_art_id) + except Exception as exc: + log.debug("Cover art fetch failed for %s: %s", cover_art_id, exc) + # If placeholder detection is off we can't classify; be optimistic. + return (True, None) if client.placeholder_cover_hash is None else (False, None) + + if not bytes_ or len(bytes_) < _MIN_ARTWORK_BYTES: + return (False, None) + + digest = art_hash(bytes_) + if client.placeholder_cover_hash and digest == client.placeholder_cover_hash: + return (False, None) + return (True, digest) diff --git a/SubsonicManager/client.py b/SubsonicManager/client.py new file mode 100644 index 0000000..7710647 --- /dev/null +++ b/SubsonicManager/client.py @@ -0,0 +1,282 @@ +"""Subsonic / OpenSubsonic REST API client. + +Implements the standard OpenSubsonic authentication scheme (per-request +random salt + ``token = md5(password + salt)``) and the small set of +endpoints iOpenPod needs: ``ping``, ``getStarred2``, ``getPlaylists``, +``getPlaylist``, ``getAlbum``, ``getCoverArt`` and ``download``. + +Reference: https://opensubsonic.netlify.app/docs/api-reference/ + +The streaming download path mirrors ``PodcastManager.downloader.download_episode``: +``requests`` streaming, ``.part`` temp file + atomic rename, chunk-level +cancellation and progress callbacks. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import secrets +import tempfile +from collections.abc import Callable +from typing import Protocol + +import requests + +log = logging.getLogger(__name__) + +_TIMEOUT = 30 # seconds (connect timeout; read is streaming) +_CHUNK_SIZE = 64 * 1024 # 64 KB +_API_VERSION = "1.16.1" # Subsonic API version; widely supported +_CLIENT_NAME = "iOpenPod" + + +class CancelToken(Protocol): + """Protocol for cancellation tokens (mirrors PodcastManager.downloader).""" + + def is_cancelled(self) -> bool: ... + + +class SubsonicConnectionError(RuntimeError): + """Raised when a Subsonic server cannot be reached or rejects credentials. + + Carries the server URL and the OpenSubsonic error code/message (when the + server responds with ``status="failed"``) so the GUI can show a useful + diagnostic. + """ + + def __init__( + self, + message: str, + *, + url: str = "", + error_code: int | None = None, + error_message: str = "", + ) -> None: + super().__init__(message) + self.url = url + self.error_code = error_code + self.error_message = error_message + + +def _make_token(password: str, salt: str) -> str: + """Compute the OpenSubsonic ``token`` = hex(md5(password + salt)).""" + return hashlib.md5((password + salt).encode("utf-8")).hexdigest() + + +def _normalize_server_url(url: str) -> str: + """Strip trailing slashes from the server base URL.""" + return (url or "").rstrip("/") + + +class SubsonicClient: + """Minimal OpenSubsonic REST client for iOpenPod's sync source.""" + + def __init__( + self, + url: str, + username: str, + password: str, + *, + timeout: int = _TIMEOUT, + client_name: str = _CLIENT_NAME, + ) -> None: + self.base_url = _normalize_server_url(url) + self.username = username or "" + self.password = password or "" + self.timeout = timeout + self.client_name = client_name + # Cached hash of a Navidrome-style placeholder cover (set by + # detect_placeholder_cover); when non-None, a cover whose MD5 matches + # is treated as "no real artwork". See artwork.py. + self.placeholder_cover_hash: str | None = None + + # -- internals --------------------------------------------------------- + + def _auth_params(self) -> dict[str, str]: + """Per-request auth params (token+salt scheme).""" + salt = secrets.token_hex(8) + return { + "u": self.username, + "t": _make_token(self.password, salt), + "s": salt, + "v": _API_VERSION, + "c": self.client_name, + "f": "json", + } + + def _endpoint_url(self, endpoint: str, params: dict | None = None) -> str: + """Build the full REST URL for an endpoint with auth + extra params.""" + merged = self._auth_params() + if params: + merged.update(params) + query = "&".join(f"{k}={v}" for k, v in merged.items() if v is not None) + return f"{self.base_url}/rest/{endpoint}.view?{query}" + + def _get_json(self, endpoint: str, params: dict | None = None) -> dict: + """GET an endpoint and return the inner ``subsonic-response`` dict. + + Raises ``SubsonicConnectionError`` on network failure or when the + server reports ``status="failed"``. + """ + url = self._endpoint_url(endpoint, params) + try: + resp = requests.get(url, timeout=self.timeout) + except requests.RequestException as exc: + raise SubsonicConnectionError( + f"Could not reach Subsonic server: {exc}", url=self.base_url + ) from exc + + # Non-JSON responses (e.g. a reverse proxy login page) must not crash us. + try: + payload = resp.json() + except ValueError as exc: + raise SubsonicConnectionError( + f"Subsonic server returned a non-JSON response (HTTP {resp.status_code})", + url=self.base_url, + ) from exc + + body = payload.get("subsonic-response", payload) + status = body.get("status") + if status != "ok": + error = body.get("error") or {} + code = error.get("code") + msg = error.get("message") or status or "unknown error" + raise SubsonicConnectionError( + f"Subsonic server rejected the request: {msg}", + url=self.base_url, + error_code=code, + error_message=msg, + ) + return body + + # -- public API -------------------------------------------------------- + + def ping(self) -> None: + """Validate credentials / connectivity. + + Raises ``SubsonicConnectionError`` on failure; returns None on success. + """ + self._get_json("ping") + + def get_starred2(self) -> dict: + """Return the user's starred songs/albums/artists. + + Uses the ID3-typed ``getStarred2`` (modern) endpoint. The response + contains ``starred2.song`` and ``starred2.album`` arrays. + """ + body = self._get_json("getStarred2") + return body.get("starred2") or {} + + def get_playlists(self) -> list[dict]: + """Return the user's playlists (without entries).""" + body = self._get_json("getPlaylists") + playlists = body.get("playlists") or {} + return playlists.get("playlist") or [] + + def get_playlist(self, playlist_id: str) -> dict: + """Return a single playlist with its entries. + + The response contains ``playlist.entry`` (list of songs). + """ + body = self._get_json("getPlaylist", {"id": playlist_id}) + return body.get("playlist") or {} + + def get_album(self, album_id: str) -> dict: + """Return an album with its songs (AlbumWithSongsID3). + + The response contains ``album.song`` (list of songs). + """ + body = self._get_json("getAlbum", {"id": album_id}) + return body.get("album") or {} + + def get_cover_art_bytes(self, cover_art_id: str) -> bytes: + """Download cover-art bytes for the given ``coverArt`` id. + + Returns the raw image bytes. Raises ``SubsonicConnectionError`` on + network failure. Some servers (Navidrome) serve a placeholder image + for missing covers rather than erroring; callers compare the MD5 + against ``placeholder_cover_hash`` to detect this. + """ + url = self._endpoint_url("getCoverArt", {"id": cover_art_id}) + try: + resp = requests.get(url, timeout=self.timeout) + except requests.RequestException as exc: + raise SubsonicConnectionError( + f"Could not fetch cover art: {exc}", url=self.base_url + ) from exc + resp.raise_for_status() + return resp.content + + def download_track( + self, + track_id: str, + dest_path: str, + *, + progress_cb: Callable[[int, int], None] | None = None, + cancel_token: CancelToken | None = None, + ) -> str: + """Stream a track's original audio bytes to ``dest_path``. + + Uses the ``download`` endpoint (full-quality original file, not the + transcoding-capable ``stream`` endpoint). Mirrors the podcast + downloader: idempotent if the destination already exists, writes to a + ``.part`` temp file, atomically renames on completion, and checks the + cancel token per chunk. + + Args: + track_id: Subsonic track id. + dest_path: Absolute destination path (extension already decided). + progress_cb: Called with (bytes_downloaded, total_bytes); + total_bytes is 0 when the server omits Content-Length. + cancel_token: Optional token; download aborts if cancelled. + + Returns: + The destination path (== ``dest_path``). + """ + # Idempotent: skip if already fully downloaded. + if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0: + return dest_path + + os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True) + url = self._endpoint_url("download", {"id": track_id}) + + try: + resp = requests.get( + url, + stream=True, + timeout=self.timeout, + headers={"User-Agent": f"{self.client_name} (Subsonic sync)"}, + ) + resp.raise_for_status() + except requests.RequestException as exc: + raise SubsonicConnectionError( + f"Could not download track {track_id}: {exc}", url=self.base_url + ) from exc + + total = int(resp.headers.get("Content-Length", 0)) + downloaded = 0 + + fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(dest_path), suffix=".part") + try: + with os.fdopen(fd, "wb") as f: + for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE): + if cancel_token and cancel_token.is_cancelled(): + raise RuntimeError("Subsonic download cancelled") + if not chunk: + continue + f.write(chunk) + downloaded += len(chunk) + if progress_cb: + progress_cb(downloaded, total) + os.replace(tmp_path, dest_path) + except Exception: + try: + os.remove(tmp_path) + except OSError: + pass + raise + + log.debug("Downloaded Subsonic track %s -> %s (%d bytes)", track_id, dest_path, downloaded) + return dest_path diff --git a/SubsonicManager/mapping.py b/SubsonicManager/mapping.py new file mode 100644 index 0000000..9057ef7 --- /dev/null +++ b/SubsonicManager/mapping.py @@ -0,0 +1,125 @@ +"""Map OpenSubsonic song dicts into iOpenPod's ``PCTrack`` domain model. + +Mirrors podkit's ``mapSongToTrack`` (``adapters/subsonic.ts``), adapted to +the field set of ``SyncEngine.pc_library.PCTrack``. A song's ``path`` is +set to the virtual URI ``subsonic://``; the executor's fetch +phase (``_fetch_subsonic_tracks``) recognises this scheme and replaces it +with a real local cache path before the track is copied to the device. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from SyncEngine._formats import IPOD_NATIVE_AUDIO +from SyncEngine.pc_library import PCTrack + +log = logging.getLogger(__name__) + +# IPOD_NATIVE_AUDIO is imported from the canonical SyncEngine._formats module +# to avoid a divergent duplicate set (see SyncEngine/_formats.py). + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_str(value: Any, default: str = "") -> str: + if value is None: + return default + return str(value) + + +def song_to_pc_track( + song: dict, + *, + album: dict | None = None, + check_artwork: bool = False, + client: Any = None, +) -> PCTrack: + """Convert an OpenSubsonic song dict into a ``PCTrack``. + + Args: + song: A ``Child`` (song) dict from ``getStarred2``/``getPlaylist``/ + ``getAlbum``. Fields follow OpenSubsonic naming (title, artist, + album, suffix, duration, bitRate, track, discNumber, ...). + album: Optional parent album dict (``AlbumWithSongsID3``) used to fill + gaps (albumArtist, genre, year, isCompilation). + check_artwork: When True and ``client`` is provided, probe the cover + via :func:`SubsonicManager.artwork.classify_cover` to set + ``art_hash``. When False, ``art_hash`` is left None and resolved + later from the downloaded file (matches podcast behaviour). + client: ``SubsonicClient`` used for the artwork probe. + + Returns: + A ``PCTrack`` with all required fields populated. ``path`` is the + virtual ``subsonic://`` URI pending execution-time download. + """ + album = album or {} + track_id = _as_str(song.get("id")) + suffix = _as_str(song.get("suffix")).lower() + ext = f".{suffix}" if suffix else "" + + title = _as_str(song.get("title"), "Unknown Title") + artist = _as_str(song.get("artist") or album.get("artist"), "Unknown Artist") + album_name = _as_str(song.get("album") or album.get("name"), "Unknown Album") + album_artist = song.get("albumArtist") or album.get("artist") + genre = song.get("genre") or album.get("genre") + year = _as_int(song.get("year") or album.get("year")) or None + + # OpenSubsonic durations are whole seconds; PCTrack uses milliseconds. + duration_ms = _as_int(song.get("duration")) * 1000 + bitrate = _as_int(song.get("bitRate")) or None + size = _as_int(song.get("size")) + sample_rate = _as_int(song.get("sampleRate")) or None + + track_number = _as_int(song.get("track")) or None + track_total = _as_int(song.get("trackCount")) or None + disc_number = _as_int(song.get("discNumber")) or None + disc_total = _as_int(song.get("discCount")) or None + + # Optional artwork hash when explicitly probing (otherwise resolved later). + art_hash: str | None = None + if check_artwork and client is not None: + try: + from .artwork import classify_cover + + _, art_hash = classify_cover(client, song.get("coverArt")) + except Exception as exc: # artwork failures must never break the track + log.debug("Artwork probe failed for track %s: %s", track_id, exc) + + needs_transcoding = bool(ext) and ext not in IPOD_NATIVE_AUDIO + + return PCTrack( + # File info — path is virtual until the fetch phase downloads it. + path=f"subsonic://{track_id}", + relative_path=f"{track_id}{ext}", + filename=f"{track_id}{ext}", + extension=ext, + mtime=time.time(), + size=size, + # Metadata + title=title, + artist=artist, + album=album_name, + album_artist=album_artist or None, + genre=genre or None, + year=year, + track_number=track_number, + track_total=track_total, + disc_number=disc_number, + disc_total=disc_total, + duration_ms=duration_ms, + bitrate=bitrate, + sample_rate=sample_rate, + rating=None, + # Derived flags + compilation=bool(album.get("isCompilation")), + needs_transcoding=needs_transcoding, + art_hash=art_hash, + ) diff --git a/SubsonicManager/plan_builder.py b/SubsonicManager/plan_builder.py new file mode 100644 index 0000000..d7e37b9 --- /dev/null +++ b/SubsonicManager/plan_builder.py @@ -0,0 +1,229 @@ +"""Build a ``SyncPlan`` from a Subsonic-compatible server. + +Mirrors ``PodcastManager.podcast_sync.build_podcast_sync_plan``: collect +candidate tracks (here: songs from named playlists), filter out those not +on the iPod, and emit **playlist-only** ``SyncItem``s. No songs are +downloaded — the Subsonic source is playlist-only, referencing tracks +already in the iPod library by ``db_track_id``. + +Selection model (per the approved design): the user chooses which Subsonic +playlists to sync; each playlist's entries are matched (by title+artist) +against the existing iPod library. Matches become ``db_track_id`` +references; non-matches are silently dropped. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from SyncEngine.contracts import StorageSummary, SyncPlan + +if TYPE_CHECKING: + from .client import SubsonicClient + +log = logging.getLogger(__name__) + +SUBSONIC_PLAYLIST_SOURCE = "subsonic" + +# Transliteration table that folds curly quotes and similar punctuation into +# ASCII equivalents for fuzzy (title, artist) matching. +_PUNCT_MAP = {0x2018: "'", 0x2019: "'", 0x201C: '"', 0x201D: '"', 0x2013: "-", 0x2014: "-"} + + +def _match_key(title: str, artist: str) -> tuple[str, str]: + """Build a normalized (title, artist) key for fuzzy de-duplication. + + Folds case, strips leading/trailing whitespace, collapses internal + whitespace, removes punctuation, and transliterates curly quotes to ASCII. + """ + + def _norm(s: str) -> str: + s = (s or "").translate(_PUNCT_MAP) + s = s.lower() + s = "".join(c if (c.isalnum() or c.isspace()) else " " for c in s) + return " ".join(s.split()) + + return (_norm(title), _norm(artist)) + + +def _collect_playlists( + client: SubsonicClient, playlist_ids: list[str] +) -> list[tuple[str, str, list[dict]]]: + """Fetch each named playlist's songs. + + Returns a list of ``(playlist_id, playlist_name, songs)`` tuples. + Fetch failures are logged and skipped. + """ + result: list[tuple[str, str, list[dict]]] = [] + for pid in playlist_ids: + pid = (pid or "").strip() + if not pid: + continue + try: + playlist = client.get_playlist(pid) + except Exception as exc: + log.warning("Could not fetch Subsonic playlist %s: %s", pid, exc) + continue + name = str(playlist.get("name") or pid) + entries = list(playlist.get("entry") or []) + result.append((pid, name, entries)) + return result + + +def _playlist_id_for(subsonic_id: str, name: str) -> int: + """Deterministic iPod playlist id from a Subsonic playlist id+name.""" + import hashlib + + key = f"{subsonic_id}\x00{name}".encode("utf-8", errors="surrogatepass") + suffix = int.from_bytes(hashlib.blake2b(key, digest_size=5).digest(), "big") + return (0x534F4E << 40) | suffix # "SON" prefix + + +def _existing_playlist_items(existing: dict) -> list[dict]: + """Extract the existing track references from an iPod playlist row.""" + items = existing.get("items") or existing.get("Playlist Items") or [] + result: list[dict] = [] + for it in items: + if not isinstance(it, dict): + continue + db_id = it.get("db_track_id") or it.get("db_id") + if db_id: + result.append({"db_track_id": int(db_id)}) + else: + tid = it.get("track_id") or it.get("Track ID") + if tid: + result.append({"track_id": int(tid)}) + return result + + +def build_subsonic_sync_plan( + client: SubsonicClient, + ipod_tracks: list[dict], + cache_dir: str, + *, + playlist_ids: list[str] | None = None, + playlist_mappings: dict[str, int] | None = None, + ipod_playlists: list[dict] | None = None, +) -> SyncPlan: + """Build a playlist-only ``SyncPlan`` from a Subsonic server. + + No tracks are downloaded — Subsonic sync is purely about organising songs + already in the iPod library into playlists that mirror the server's + playlists. Each Subsonic playlist entry is matched (by title+artist, + normalised) against the iPod library. Matches become ``db_track_id`` + references; non-matching entries are silently dropped. + + When ``playlist_mappings`` maps a Subsonic playlist id to an existing + iPod playlist id, the tracks are merged into that playlist instead of + creating a new one, preserving the iPod playlist's existing members. + + Args: + client: An authenticated ``SubsonicClient``. + ipod_tracks: Parsed track dicts from the iPod library (must carry + ``Title``, ``Artist``, and ``db_track_id`` fields). + cache_dir: Transcode-cache dir (reserved). + playlist_ids: Subsonic playlist ids to sync. + playlist_mappings: Optional ``{subsonic_id: ipod_playlist_id}`` map. + ipod_playlists: Existing iPod playlist rows for merging. + + Returns: + A ``SyncPlan`` with ``playlists_to_add`` (new playlists), + optional ``playlists_to_edit`` (merged), empty ``to_add``, and + ``storage``. + """ + playlist_mappings = playlist_mappings or {} + + # 1. Fetch playlists from the server. + playlists = _collect_playlists(client, list(playlist_ids or [])) if playlist_ids else [] + + # 2. Index existing iPod music tracks by (title, artist) so playlist + # entries can be resolved to db_track_ids. We use _match_key for + # fuzzy matching (handles curly quotes, case, punctuation). + ipod_track_id_by_key: dict[tuple[str, str], int] = {} + for t in ipod_tracks: + key = _match_key(t.get("Title") or "", t.get("Artist") or "") + if key == ("", ""): + continue + try: + db_id = int(t.get("db_track_id") or t.get("db_id") or 0) + except (TypeError, ValueError): + db_id = 0 + if db_id: + ipod_track_id_by_key.setdefault(key, db_id) + + # Index existing iPod playlists for merging. + ipod_playlist_by_id: dict[int, dict] = {} + for pl in ipod_playlists or []: + try: + pid = int(pl.get("playlist_id") or pl.get("Playlist ID") or 0) + except (TypeError, ValueError): + pid = 0 + if pid: + ipod_playlist_by_id.setdefault(pid, pl) + + # 3. Build playlist payloads. Each playlist entry is matched against + # the iPod library; matches resolve to db_track_id, non-matches are + # dropped. No songs are added to the device. + playlists_to_add: list[dict] = [] + playlists_to_edit: list[dict] = [] + for subsonic_id, name, entries in playlists: + items: list[dict] = [] + for entry in entries: + db_id = ipod_track_id_by_key.get( + _match_key(entry.get("title") or "", entry.get("artist") or "") + ) + if db_id: + items.append({"db_track_id": db_id}) + if not items: + continue + + target_ipod_id = playlist_mappings.get(subsonic_id) + if target_ipod_id is not None: + overwrite = target_ipod_id < 0 + abs_id = abs(target_ipod_id) + if abs_id in ipod_playlist_by_id: + existing = ipod_playlist_by_id[abs_id] + existing_items = [] if overwrite else _existing_playlist_items(existing) + playlists_to_edit.append( + { + "Title": existing.get("Title") or existing.get("name") or name, + "playlist_id": abs_id, + "_isNew": False, + "_source": SUBSONIC_PLAYLIST_SOURCE, + "_mhsd_dataset_type": 2, + "_mhsd_result_key": "mhlp", + "items": existing_items + items, + "mhip_child_count": len(existing_items) + len(items), + } + ) + continue + # Negative without a matching iPod playlist → fall through to create. + # Fallback: create a new same-named iPod playlist or overwrite-mode + # without a matching target. + playlists_to_add.append( + { + "Title": name, + "playlist_id": _playlist_id_for(subsonic_id, name), + "_isNew": True, + "_source": SUBSONIC_PLAYLIST_SOURCE, + "_mhsd_dataset_type": 2, + "_mhsd_result_key": "mhlp", + "items": items, + "mhip_child_count": len(items), + } + ) + + log.info( + "Subsonic plan: %d playlist(s) — %d new, %d merged", + len(playlists), + len(playlists_to_add), + len(playlists_to_edit), + ) + + return SyncPlan( + to_add=[], + playlists_to_add=playlists_to_add, + playlists_to_edit=playlists_to_edit, + storage=StorageSummary(bytes_to_add=0), + ) diff --git a/app_core/__init__.py b/app_core/__init__.py index a9e30b3..4e789bf 100644 --- a/app_core/__init__.py +++ b/app_core/__init__.py @@ -30,6 +30,8 @@ DroppedImportFiles, PodcastPlanRequest, PodcastPlanWorker, + SubsonicPlanRequest, + SubsonicPlanWorker, SyncDiffRequest, SyncDiffWorker, SyncExecuteWorker, @@ -99,6 +101,8 @@ "SettingsSnapshot", "StartupDeviceRestoreController", "StartupUpdateController", + "SubsonicPlanRequest", + "SubsonicPlanWorker", "refresh_device_disk_usage", "resolve_device_image_filename", "resolve_ipod_image_color", diff --git a/app_core/jobs.py b/app_core/jobs.py index 207fb63..131c929 100644 --- a/app_core/jobs.py +++ b/app_core/jobs.py @@ -875,6 +875,74 @@ def run(self) -> None: self.error.emit(str(exc)) +@dataclass(frozen=True) +class SubsonicPlanRequest: + """Typed request for building a Subsonic ADD-only sync plan. + + Carries the connection credentials and selection knobs. The + ``SubsonicClient`` is constructed inside the worker (off the GUI thread) + so a misbehaving server never blocks the UI. + """ + + url: str + username: str + password: str + ipod_tracks: list + cache_dir: str = "" + playlist_ids: tuple[str, ...] = () + playlist_mappings: tuple[tuple[str, int], ...] = () + ipod_playlists: tuple = () + + +class SubsonicPlanWorker(QThread): + """Background worker that connects to a Subsonic server and builds a plan. + + Mirrors ``PodcastPlanWorker``: validate the connection, build an ADD-only + ``SyncPlan`` (starred songs + named playlists), and emit it. The actual + track downloads happen later during execution (see + ``SyncExecutor._fetch_subsonic_tracks``). + """ + + finished = pyqtSignal(object) + error = pyqtSignal(str) + + def __init__(self, request: SubsonicPlanRequest): + super().__init__() + self._request = request + + def run(self) -> None: + try: + request = self._request + from SubsonicManager.client import SubsonicClient, SubsonicConnectionError + from SubsonicManager.plan_builder import build_subsonic_sync_plan + + client = SubsonicClient( + request.url, + request.username, + request.password, + ) + try: + client.ping() + except SubsonicConnectionError: + raise + + plan = build_subsonic_sync_plan( + client, + request.ipod_tracks, + request.cache_dir, + playlist_ids=list(request.playlist_ids), + playlist_mappings=dict(request.playlist_mappings), + ipod_playlists=list(request.ipod_playlists), + ) + if not self.isInterruptionRequested(): + self.finished.emit(plan) + except Exception as exc: + if self.isInterruptionRequested(): + return + logger.exception("SubsonicPlanWorker failed") + self.error.emit(str(exc)) + + @dataclass(frozen=True) class BackupDeviceContext: """Stable backup identity and metadata for a device.""" @@ -2671,6 +2739,8 @@ def _delete_imported_otg_files(ipod_path: str) -> None: logger.debug("OTG cleanup after playlist write failed: %s", exc) + + class SyncExecuteWorker(QThread): """Background worker for executing a reviewed sync plan.""" diff --git a/app_core/services.py b/app_core/services.py index b065f2e..c99bf6d 100644 --- a/app_core/services.py +++ b/app_core/services.py @@ -90,6 +90,12 @@ class SettingsSnapshot: backup_before_sync: bool backup_before_sync_mode: str max_backups: int + subsonic_enabled: bool + subsonic_url: str + subsonic_username: str + subsonic_password: str + subsonic_playlist_ids: tuple[str, ...] + subsonic_playlist_mappings: dict[str, int] @classmethod def from_settings(cls, settings: AppSettings) -> SettingsSnapshot: @@ -102,6 +108,10 @@ def from_settings(cls, settings: AppSettings) -> SettingsSnapshot: value = tuple(value) elif field_info.name == "track_list_columns_by_content": value = copy.deepcopy(value) + elif field_info.name == "subsonic_playlist_ids": + value = tuple(value) + elif field_info.name == "subsonic_playlist_mappings": + value = copy.deepcopy(value) data[field_info.name] = value return cls(**data) diff --git a/infrastructure/settings_schema.py b/infrastructure/settings_schema.py index 210b0f5..82da41f 100644 --- a/infrastructure/settings_schema.py +++ b/infrastructure/settings_schema.py @@ -180,6 +180,18 @@ class AppSettings: backup_before_sync_mode: str = "" max_backups: int = 10 + # Subsonic-compatible server sync source (Navidrome / Airsonic / Gonic). + # Stored in plaintext like the Last.fm credentials above; not synced to the + # device as an obfuscated secret (see DEVICE_SECRET_KEYS). + subsonic_enabled: bool = False + subsonic_url: str = "" + subsonic_username: str = "" + subsonic_password: str = "" + subsonic_playlist_ids: list[str] = field(default_factory=list) + # Mapping of Subsonic playlist id -> existing iPod playlist id (int) to merge + # into, instead of creating a new same-named playlist. Empty/unset => create. + subsonic_playlist_mappings: dict[str, int] = field(default_factory=dict) + def __post_init__(self) -> None: apply_backup_before_sync_mode(self) diff --git a/scripts/diagnose_subsonic.py b/scripts/diagnose_subsonic.py new file mode 100644 index 0000000..de97a7f --- /dev/null +++ b/scripts/diagnose_subsonic.py @@ -0,0 +1,85 @@ +"""One-shot Subsonic sync diagnostic. + +Run: uv run python scripts/diagnose_subsonic.py + +Reads your saved Subsonic settings, connects, fetches starred songs, and +compares against the iPod library to show exactly how many would be skipped +vs added — and WHY. +""" +from __future__ import annotations + +import os +import sys + +# Ensure project root is importable. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def main() -> None: + from infrastructure.settings_persistence import load_app_settings + + s = load_app_settings() + if not (s.subsonic_url and s.subsonic_username): + print("✗ Subsonic not configured in settings. Configure it in the GUI first.") + return + + print(f"Server: {s.subsonic_url}") + print(f"User: {s.subsonic_username}") + print(f"sync_starred: {s.subsonic_sync_starred}") + print(f"playlist_ids: {s.subsonic_playlist_ids}") + print() + + # --- Fetch starred songs from the server --- + from SubsonicManager.client import SubsonicClient, SubsonicConnectionError + + try: + client = SubsonicClient(s.subsonic_url, s.subsonic_username, s.subsonic_password) + client.ping() + print("✓ Connected to server") + except SubsonicConnectionError as exc: + print(f"✗ Connection failed: {exc}") + return + + from SubsonicManager.plan_builder import _collect_starred_songs, _dedupe_songs + + songs = [] + if s.subsonic_sync_starred: + songs = _collect_starred_songs(client) + print(f"Starred songs fetched: {len(songs)}") + songs = _dedupe_songs(songs) + print(f"After dedupe: {len(songs)}") + print() + + if not songs: + print("✗ No starred songs found. Is sync_starred enabled? Are there starred songs?") + return + + # --- Show what the server returns for a sample --- + print("=== Sample of what the server returns (first 5) ===") + for song in songs[:5]: + print(f" id={song.get('id')!r}") + print(f" title={song.get('title')!r} artist={song.get('artist')!r}") + print(f" album={song.get('album')!r}") + print() + + # --- We can't read the iPod library here without a device path --- + # But we CAN show whether the matching would work by printing the keys. + print("=== Matching keys that would be used ===") + keys = set() + for song in songs: + title = (song.get("title") or "").strip().lower() + artist = (song.get("artist") or "").strip().lower() + if title and artist: + keys.add((title, artist)) + print(f"Unique (title, artist) pairs from server: {len(keys)}") + print() + + print("NOTE: To complete the diagnosis, the iPod must be connected.") + print("If you see this output, the server fetch is working.") + print("If 'Starred songs fetched' = 516 but nothing syncs, the dedup is") + print("matching against your existing 466 iPod tracks. Check the Sync") + print("Review 'Music' card — if it shows 0 to add, that's the cause.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_subsonic_artwork.py b/tests/test_subsonic_artwork.py new file mode 100644 index 0000000..2132f1b --- /dev/null +++ b/tests/test_subsonic_artwork.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from SubsonicManager import artwork +from SubsonicManager.client import SubsonicConnectionError + + +class _FakeClient: + """Minimal client stub for artwork tests.""" + + def __init__(self, cover_bytes_by_id: dict[str, bytes]) -> None: + self._covers = cover_bytes_by_id + self.placeholder_cover_hash: str | None = None + + def get_cover_art_bytes(self, cover_art_id: str) -> bytes: + if cover_art_id not in self._covers: + raise SubsonicConnectionError("no cover") + return self._covers[cover_art_id] + + +def test_detect_placeholder_caches_hash(monkeypatch) -> None: + placeholder = b"x" * 200 + monkeypatch.setattr(artwork, "art_hash", lambda b: "PLACEHASH") + c = _FakeClient({"": placeholder}) + result = artwork.detect_placeholder_cover(c) # type: ignore[arg-type] + assert result == "PLACEHASH" + assert c.placeholder_cover_hash == "PLACEHASH" + + +def test_detect_placeholder_returns_none_when_server_errors(monkeypatch) -> None: + c = _FakeClient({}) # empty id not in dict -> raises + result = artwork.detect_placeholder_cover(c) # type: ignore[arg-type] + assert result is None + assert c.placeholder_cover_hash is None + + +def test_detect_placeholder_ignores_tiny_images(monkeypatch) -> None: + c = _FakeClient({"": b"tiny"}) # < _MIN_ARTWORK_BYTES + result = artwork.detect_placeholder_cover(c) # type: ignore[arg-type] + assert result is None + assert c.placeholder_cover_hash is None + + +def test_classify_cover_no_id_means_no_artwork(monkeypatch) -> None: + has, h = artwork.classify_cover(_FakeClient({}), None) # type: ignore[arg-type] + assert has is False + assert h is None + + +def test_classify_cover_real_artwork(monkeypatch) -> None: + real = b"r" * 500 + monkeypatch.setattr(artwork, "art_hash", lambda b: "REALHASH") + c = _FakeClient({"cov1": real}) + has, h = artwork.classify_cover(c, "cov1") # type: ignore[arg-type] + assert has is True + assert h == "REALHASH" + + +def test_classify_cover_filters_placeholder(monkeypatch) -> None: + placeholder = b"p" * 500 + monkeypatch.setattr(artwork, "art_hash", lambda b: "PH" if b == placeholder else "OTHER") + c = _FakeClient({"": placeholder, "cov1": placeholder}) + artwork.detect_placeholder_cover(c) # type: ignore[arg-type] + has, h = artwork.classify_cover(c, "cov1") # type: ignore[arg-type] + assert has is False + assert h is None + + +def test_classify_cover_optimistic_when_detection_disabled(monkeypatch) -> None: + # placeholder_cover_hash is None (detection disabled); fetch error -> optimistic True + monkeypatch.setattr(artwork, "art_hash", lambda b: "X") + c = _FakeClient({}) # cov1 missing -> raises + has, h = artwork.classify_cover(c, "cov1") # type: ignore[arg-type] + assert has is True + assert h is None diff --git a/tests/test_subsonic_client.py b/tests/test_subsonic_client.py new file mode 100644 index 0000000..74a82b2 --- /dev/null +++ b/tests/test_subsonic_client.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import hashlib +import os + +import pytest + +from SubsonicManager import client as client_mod +from SubsonicManager.client import SubsonicClient, SubsonicConnectionError + + +class _JsonResponse: + """Minimal stand-in for a requests json() response.""" + + def __init__(self, payload: dict, status: int = 200) -> None: + self._payload = payload + self.status_code = status + self.content = b"" + + def json(self) -> dict: + return self._payload + + +class _BytesResponse: + """Stand-in for a binary response (getCoverArt / download).""" + + def __init__(self, content: bytes, headers: dict | None = None, status: int = 200) -> None: + self.content = content + self.status_code = status + self.headers = headers or {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class _StreamResponse(_BytesResponse): + def __init__(self, content: bytes, headers: dict | None = None) -> None: + super().__init__(content, headers) + self._chunks = [content[i : i + 16] for i in range(0, len(content), 16)] + + def iter_content(self, chunk_size: int = 16): + yield from self._chunks + + def raise_for_status(self) -> None: + pass + + +def _ok(extra: dict | None = None) -> dict: + return {"subsonic-response": {"status": "ok", **(extra or {})}} + + +def _failed(code: int, message: str) -> dict: + return {"subsonic-response": {"status": "failed", "error": {"code": code, "message": message}}} + + +# --------------------------------------------------------------------------- +# auth / URL building +# --------------------------------------------------------------------------- + + +def test_token_is_hex_md5_of_password_plus_salt() -> None: + salt = "abc123" + token = client_mod._make_token("hunter2", salt) + assert token == hashlib.md5(b"hunter2abc123").hexdigest() + + +def test_endpoint_url_carries_token_salt_and_json_format() -> None: + c = SubsonicClient("https://music.test/", "bob", "pw") + url = c._endpoint_url("ping") + assert url.startswith("https://music.test/rest/ping.view?") + assert "u=bob" in url + assert "f=json" in url + assert "&c=iOpenPod" in url + assert "&t=" in url + assert "&s=" in url + + +def test_normalize_server_url_strips_trailing_slash() -> None: + assert client_mod._normalize_server_url("https://x.test/") == "https://x.test" + assert client_mod._normalize_server_url("https://x.test") == "https://x.test" + + +# --------------------------------------------------------------------------- +# ping / error handling +# --------------------------------------------------------------------------- + + +def test_ping_ok(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: _JsonResponse(_ok())) + c.ping() # no raise + + +def test_ping_failed_raises_connection_error_with_code(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "wrong") + monkeypatch.setattr( + client_mod.requests, "get", lambda *a, **k: _JsonResponse(_failed(40, "Wrong username or password")) + ) + with pytest.raises(SubsonicConnectionError) as exc_info: + c.ping() + assert exc_info.value.error_code == 40 + assert "Wrong username or password" in str(exc_info.value) + assert exc_info.value.url == "https://music.test" + + +def test_non_json_response_raises(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + + class NotJson: + status_code = 302 + + def json(self): + raise ValueError("not json") + + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: NotJson()) + with pytest.raises(SubsonicConnectionError): + c.ping() + + +def test_network_error_raises_connection_error(monkeypatch: pytest.MonkeyPatch) -> None: + import requests as real_requests + + c = SubsonicClient("https://music.test", "bob", "pw") + + def boom(*a, **k): + raise real_requests.ConnectionError("down") + + monkeypatch.setattr(client_mod.requests, "get", boom) + with pytest.raises(SubsonicConnectionError) as exc_info: + c.ping() + assert "Could not reach" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# catalog endpoints +# --------------------------------------------------------------------------- + + +def test_get_starred2_returns_inner_object(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + body = _ok({"starred2": {"song": [{"id": "s1"}], "album": []}}) + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: _JsonResponse(body)) + assert c.get_starred2() == {"song": [{"id": "s1"}], "album": []} + + +def test_get_playlists_returns_list(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + body = _ok({"playlists": {"playlist": [{"id": "p1", "name": "Faves"}]}}) + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: _JsonResponse(body)) + assert c.get_playlists() == [{"id": "p1", "name": "Faves"}] + + +def test_get_playlists_empty_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: _JsonResponse(_ok())) + assert c.get_playlists() == [] + + +def test_get_playlist_returns_with_entries(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + body = _ok({"playlist": {"id": "p1", "entry": [{"id": "s1"}]}}) + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: _JsonResponse(body)) + pl = c.get_playlist("p1") + assert pl["entry"] == [{"id": "s1"}] + + +def test_get_album_returns_with_songs(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + body = _ok({"album": {"id": "a1", "song": [{"id": "s1"}]}}) + monkeypatch.setattr(client_mod.requests, "get", lambda *a, **k: _JsonResponse(body)) + album = c.get_album("a1") + assert album["song"] == [{"id": "s1"}] + + +# --------------------------------------------------------------------------- +# cover art + download +# --------------------------------------------------------------------------- + + +def test_get_cover_art_bytes_returns_content(monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + monkeypatch.setattr( + client_mod.requests, "get", lambda *a, **k: _BytesResponse(b"\x89PNGfake") + ) + assert c.get_cover_art_bytes("cov1") == b"\x89PNGfake" + + +def test_download_skips_when_dest_exists(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + dest = tmp_path / "track.mp3" + dest.write_bytes(b"already here") + + called = {"n": 0} + + def fake_get(*a, **k): + called["n"] += 1 + return _StreamResponse(b"should not be used") + + monkeypatch.setattr(client_mod.requests, "get", fake_get) + result = c.download_track("t1", str(dest)) + assert result == str(dest) + assert called["n"] == 0 # no network call + + +def test_download_streams_to_dest_and_reports_progress(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + dest = tmp_path / "track.mp3" + payload = bytes(range(64)) # 64 bytes, will be chunked into 16-byte pieces + + monkeypatch.setattr( + client_mod.requests, + "get", + lambda *a, **k: _StreamResponse(payload, headers={"Content-Length": str(len(payload))}), + ) + + seen: list[tuple[int, int]] = [] + result = c.download_track("t1", str(dest), progress_cb=lambda d, t: seen.append((d, t))) + + assert result == str(dest) + assert os.path.exists(dest) + assert dest.read_bytes() == payload + # final progress callback reports total downloaded and full content-length + assert seen and seen[-1] == (64, 64) + + +def test_download_cancels_and_leaves_no_partial(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + dest = tmp_path / "track.mp3" + payload = bytes(range(64)) + + monkeypatch.setattr( + client_mod.requests, "get", lambda *a, **k: _StreamResponse(payload) + ) + + class Cancelled: + def is_cancelled(self) -> bool: + return True + + with pytest.raises(RuntimeError, match="cancelled"): + c.download_track("t1", str(dest), cancel_token=Cancelled()) + + assert not dest.exists() + # no .part left behind + assert not any(p.suffix == ".part" for p in tmp_path.iterdir()) + + +def test_download_cleans_up_part_on_failure(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + c = SubsonicClient("https://music.test", "bob", "pw") + dest = tmp_path / "track.mp3" + import requests as real_requests + + def boom(*a, **k): + raise real_requests.ConnectionError("boom") + + monkeypatch.setattr(client_mod.requests, "get", boom) + with pytest.raises(SubsonicConnectionError): + c.download_track("t1", str(dest)) + assert not dest.exists() diff --git a/tests/test_subsonic_mapping.py b/tests/test_subsonic_mapping.py new file mode 100644 index 0000000..ccc0e01 --- /dev/null +++ b/tests/test_subsonic_mapping.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from SubsonicManager.mapping import IPOD_NATIVE_AUDIO, song_to_pc_track + + +def _song(**overrides): + base = { + "id": "t1", + "title": "Song One", + "artist": "Artist A", + "album": "Album X", + "suffix": "mp3", + "duration": 200, + "bitRate": 320, + "size": 6_400_000, + "track": 1, + "discNumber": 2, + "year": 2019, + "genre": "Rock", + "sampleRate": 44100, + } + base.update(overrides) + return base + + +def test_basic_field_mapping() -> None: + pc = song_to_pc_track(_song()) + assert pc.path == "subsonic://t1" + assert pc.title == "Song One" + assert pc.artist == "Artist A" + assert pc.album == "Album X" + assert pc.extension == ".mp3" + assert pc.duration_ms == 200_000 # seconds -> ms + assert pc.bitrate == 320 + assert pc.size == 6_400_000 + assert pc.year == 2019 + assert pc.genre == "Rock" + assert pc.track_number == 1 + assert pc.disc_number == 2 + assert pc.sample_rate == 44100 + assert pc.rating is None + assert pc.is_podcast is False + assert pc.is_video is False + + +def test_path_is_virtual_subsonic_uri() -> None: + pc = song_to_pc_track(_song(id="abc9")) + assert pc.path == "subsonic://abc9" + + +def test_falls_back_to_album_fields() -> None: + song = _song() + song.pop("artist") + song.pop("album") + song.pop("genre") + song.pop("year") + album = { + "artist": "Album Artist", + "name": "Album From Album", + "genre": "Jazz", + "year": 2001, + "isCompilation": True, + } + pc = song_to_pc_track(song, album=album) + assert pc.artist == "Album Artist" + assert pc.album == "Album From Album" + assert pc.genre == "Jazz" + assert pc.year == 2001 + assert pc.compilation is True + + +def test_needs_transcoding_for_non_native_formats() -> None: + assert ".flac" not in IPOD_NATIVE_AUDIO + pc = song_to_pc_track(_song(suffix="flac")) + assert pc.needs_transcoding is True + + +def test_native_format_not_flagged_for_transcode() -> None: + pc = song_to_pc_track(_song(suffix="m4a")) + assert pc.needs_transcoding is False + + +def test_unknown_artist_and_title_defaults() -> None: + pc = song_to_pc_track({"id": "x", "suffix": "mp3"}) + assert pc.title == "Unknown Title" + assert pc.artist == "Unknown Artist" + assert pc.album == "Unknown Album" + + +def test_art_hash_left_none_without_artwork_probe() -> None: + pc = song_to_pc_track(_song(coverArt="cov1")) + assert pc.art_hash is None + + +def test_check_artwork_false_classifies_missing_when_no_cover() -> None: + # no coverArt id -> has_artwork False regardless of check flag + pc = song_to_pc_track(_song(), check_artwork=True, client=object()) + assert pc.art_hash is None + + +def test_duration_zero_when_missing() -> None: + pc = song_to_pc_track({"id": "x", "suffix": "mp3"}) + assert pc.duration_ms == 0 diff --git a/tests/test_subsonic_plan_builder.py b/tests/test_subsonic_plan_builder.py new file mode 100644 index 0000000..ee8c453 --- /dev/null +++ b/tests/test_subsonic_plan_builder.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from SubsonicManager.plan_builder import ( + _collect_playlists, + _match_key, + build_subsonic_sync_plan, +) + + +class _FakeClient: + """Stub client returning canned catalog data.""" + + def __init__( + self, + *, + playlists_by_id: dict[str, dict] | None = None, + ) -> None: + self._playlists = playlists_by_id or {} + + def get_playlist(self, playlist_id: str) -> dict: + return self._playlists.get(playlist_id, {}) + + +def _song(sid, artist="A", title="T", **kw): + base = {"id": sid, "artist": artist, "title": title, "suffix": "mp3"} + base.update(kw) + return base + + +# --------------------------------------------------------------------------- +# _match_key (fuzzy de-dup) +# --------------------------------------------------------------------------- + + +def test_match_key_normalizes_curly_quotes() -> None: + assert _match_key("I Don\u2019t Know Why", "Imagine Dragons") == _match_key( + "I Don't Know Why", "Imagine Dragons" + ) + + +def test_match_key_normalizes_case_and_whitespace() -> None: + assert _match_key(" Hello World ", "The Artist") == _match_key( + "hello world", "the artist" + ) + + +def test_match_key_strips_punctuation() -> None: + assert _match_key("Song! (Remix)", "Artist") == _match_key( + "Song Remix", "Artist" + ) + + +# --------------------------------------------------------------------------- +# collect playlists +# --------------------------------------------------------------------------- + + +def test_collect_playlists_returns_id_name_songs() -> None: + client = _FakeClient( + playlists_by_id={ + "p1": {"name": "Faves", "entry": [_song("e1"), _song("e2")]}, + } + ) + result = _collect_playlists(client, ["p1"]) + assert len(result) == 1 + pid, name, songs = result[0] + assert pid == "p1" and name == "Faves" + assert [s["id"] for s in songs] == ["e1", "e2"] + + +def test_collect_playlists_ignores_blank_ids() -> None: + client = _FakeClient(playlists_by_id={"p1": {"entry": [_song("e1")]}}) + assert len(_collect_playlists(client, ["", " ", "p1"])) == 1 + + +def test_collect_playlists_tolerates_fetch_failure() -> None: + class BoomClient(_FakeClient): + def get_playlist(self, pid): + raise RuntimeError("boom") + + assert _collect_playlists(BoomClient(), ["p1"]) == [] + + +# --------------------------------------------------------------------------- +# build_subsonic_sync_plan — playlist-only +# --------------------------------------------------------------------------- + + +def test_plan_no_playlists_when_none_selected() -> None: + client = _FakeClient() + plan = build_subsonic_sync_plan(client, ipod_tracks=[], cache_dir="/tmp") + assert plan.to_add == [] + assert plan.playlists_to_add == [] + assert plan.playlists_to_edit == [] + + +def test_plan_creates_playlist_with_matched_tracks() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "My Favorites", "entry": [_song("s1", "A", "One")]}} + ) + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "One", "Artist": "A", "db_track_id": 10}], + cache_dir="/tmp", + playlist_ids=["p1"], + ) + assert plan.to_add == [] # no songs downloaded + assert len(plan.playlists_to_add) == 1 + pl = plan.playlists_to_add[0] + assert pl["Title"] == "My Favorites" + assert pl["_isNew"] is True + assert pl["_source"] == "subsonic" + assert pl["items"] == [{"db_track_id": 10}] + + +def test_plan_skips_unmatched_tracks() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "PL", "entry": [_song("s1", "A", "One"), + _song("s2", "B", "Two")]}} + ) + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "One", "Artist": "A", "db_track_id": 1}], # s2 not on iPod + cache_dir="/tmp", + playlist_ids=["p1"], + ) + pl = plan.playlists_to_add[0] + # Only s1 is matched; s2 dropped. + assert pl["items"] == [{"db_track_id": 1}] + + +def test_plan_drops_playlist_with_no_matches() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "PL", "entry": [_song("s1", "X", "Y")]}} + ) + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "Other", "Artist": "Other", "db_track_id": 99}], + cache_dir="/tmp", + playlist_ids=["p1"], + ) + assert plan.playlists_to_add == [] + + +def test_plan_fuzzy_match_tolerates_curly_quotes() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "PL", "entry": [ + {"id": "s1", "title": "I Don\u2019t Know Why", "artist": "Imagine Dragons"} + ]}} + ) + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "I Don't Know Why", "Artist": "Imagine Dragons", + "db_track_id": 77}], + cache_dir="/tmp", + playlist_ids=["p1"], + ) + pl = plan.playlists_to_add[0] + assert pl["items"] == [{"db_track_id": 77}] + + +def test_plan_multiple_playlists() -> None: + client = _FakeClient( + playlists_by_id={ + "p1": {"name": "Rock", "entry": [_song("r1")]}, + "p2": {"name": "Jazz", "entry": [_song("j1")]}, + } + ) + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[ + {"Title": "T", "Artist": "A", "db_track_id": 1}, # matches both _song defaults + ], + cache_dir="/tmp", + playlist_ids=["p1", "p2"], + ) + titles = sorted(pl["Title"] for pl in plan.playlists_to_add) + assert titles == ["Jazz", "Rock"] + + +def test_plan_playlist_id_stable() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "PL", "entry": [_song("s1")]}} + ) + plan_a = build_subsonic_sync_plan( + client, ipod_tracks=[{"Title": "T", "Artist": "A", "db_track_id": 1}], + cache_dir="/tmp", playlist_ids=["p1"], + ) + plan_b = build_subsonic_sync_plan( + client, ipod_tracks=[{"Title": "T", "Artist": "A", "db_track_id": 1}], + cache_dir="/tmp", playlist_ids=["p1"], + ) + assert plan_a.playlists_to_add[0]["playlist_id"] == plan_b.playlists_to_add[0]["playlist_id"] + + +# --------------------------------------------------------------------------- +# playlist mapping / merge +# --------------------------------------------------------------------------- + + +def _ipod_playlist(pid: int, name: str, member_ids: list[int]) -> dict: + return { + "playlist_id": pid, + "Title": name, + "items": [{"db_track_id": mid} for mid in member_ids], + } + + +def test_plan_mapped_playlist_merges_into_existing() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "Subsonic PL", "entry": [_song("s1")]}} + ) + ipod_pls = [_ipod_playlist(500, "Favorites", [10, 20])] + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "T", "Artist": "A", "db_track_id": 1}], + cache_dir="/tmp", + playlist_ids=["p1"], + playlist_mappings={"p1": 500}, + ipod_playlists=ipod_pls, + ) + # mapped -> merged (not new) + assert plan.playlists_to_add == [] + assert len(plan.playlists_to_edit) == 1 + pl = plan.playlists_to_edit[0] + assert pl["_isNew"] is False + assert pl["playlist_id"] == 500 + assert pl["Title"] == "Favorites" + # existing + new track, both resolved by db_track_id + db_ids = sorted(it["db_track_id"] for it in pl["items"]) + assert db_ids == [1, 10, 20] + assert pl["mhip_child_count"] == 3 + + +def test_plan_mapping_target_missing_falls_back_to_create() -> None: + client = _FakeClient( + playlists_by_id={"p1": {"name": "PL", "entry": [_song("s1")]}} + ) + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "T", "Artist": "A", "db_track_id": 1}], + cache_dir="/tmp", + playlist_ids=["p1"], + playlist_mappings={"p1": 9999}, + ipod_playlists=[], + ) + assert len(plan.playlists_to_add) == 1 + assert plan.playlists_to_add[0]["_isNew"] is True + assert plan.playlists_to_edit == [] + + +def test_plan_partial_mapping() -> None: + client = _FakeClient( + playlists_by_id={ + "p1": {"name": "Mapped", "entry": [_song("s1")]}, + "p2": {"name": "New", "entry": [_song("s2")]}, + } + ) + ipod_pls = [_ipod_playlist(100, "Existing", [5])] + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "T", "Artist": "A", "db_track_id": 1}], + cache_dir="/tmp", + playlist_ids=["p1", "p2"], + playlist_mappings={"p1": 100}, + ipod_playlists=ipod_pls, + ) + assert len(plan.playlists_to_edit) == 1 + assert plan.playlists_to_edit[0]["playlist_id"] == 100 + assert len(plan.playlists_to_add) == 1 + assert plan.playlists_to_add[0]["Title"] == "New" + + +def test_plan_overwrite_mapping_replaces_existing_members() -> None: + """A negative mapping target means overwrite — existing iPod members replaced.""" + client = _FakeClient( + playlists_by_id={"p1": {"name": "PL", "entry": [_song("s1")]}} + ) + ipod_pls = [_ipod_playlist(500, "Favorites", [10, 20, 30])] + plan = build_subsonic_sync_plan( + client, + ipod_tracks=[{"Title": "T", "Artist": "A", "db_track_id": 1}], + cache_dir="/tmp", + playlist_ids=["p1"], + playlist_mappings={"p1": -500}, + ipod_playlists=ipod_pls, + ) + assert len(plan.playlists_to_edit) == 1 + pl = plan.playlists_to_edit[0] + assert pl["playlist_id"] == 500 + assert pl["_isNew"] is False + # Only the new remote track — existing members 10,20,30 are gone. + assert pl["items"] == [{"db_track_id": 1}]