diff --git a/CLAUDE.md b/CLAUDE.md index 4121288..922efb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,6 +245,30 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal undated — so a second MusicBrainz call would return the same wrong year, and the file's own name is the only place 1961 exists. +- **`lintunes/url_import.py`** — `File → Import from URL…` (Round 49): trav's + `song` shell helper (`yt-dlp --extract-audio --audio-format mp3 "$@"`) run + from inside the app. Those flags are passed **verbatim**; everything else on + the command line is plumbing: `-P `, `--print before_dl:LTSTART …`, + `--print after_move:LTFILE %(filepath)s` and an `LTPROG` progress template, + so output is read from markers rather than scraped. yt-dlp is a *runtime + CLI tool* (`shutil.which`, the fpcalc pattern), never a pip dep, and needs + ffmpeg for the mp3. **No `-o`**: yt-dlp's default `Title [id].mp3` is what + `filename_tags` parses. `UrlImportWorker` (daemon thread, `ExportWorker`'s + shape, shares the status-bar progress widgets and so `_busy_worker()`) + downloads into a private `mkdtemp`, emits `downloaded(path)` per song, and + the GUI imports each one *as it lands*, through the normal `import_files` + (a copy into the organized tree) and straight into the Identify queue, so + song 1's dialog can be up while song 5 downloads. The GUI deletes the temp + dir only on `finished`/`failed`, which are queued after every `downloaded`. + `resolve_target` is the rule for "and add to current playlist?": above the + selected song in the shown playlist, else the end of the *playing* + playlist, else the end of the shown one, else the checkbox is grayed. + Smart/folder/system playlists are never targets. A batch keeps the link's + order (`position + songs already inserted`). With no AcoustID key, + `TrackIdentifier` proposes from the filename alone and never touches fpcalc + or the network. Only a URL import reaches that path, since the menu route + still insists on setup first. + - **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus so the desktop's media keys / now-playing popup control playback. Spacebar and arrow keys are handled locally via `MainWindow.eventFilter`. diff --git a/TASKS.md b/TASKS.md index 984c21e..c207245 100644 --- a/TASKS.md +++ b/TASKS.md @@ -9,6 +9,25 @@ When a round closes, move its finished items to `tasks-done.md`. - [ ] archive the done tasks in here to another file, this is crufty.... +## Round 49 (2026-09-10) — Import from URL: done, see tasks-done.md + +File ▸ Import from URL… (the `song` helper in-process), plus the album art +opening on one click, art-shaped and screen-tall. Left on the table: + +- [ ] **Machine 2 needs yt-dlp.** A runtime CLI tool, not a pip dep: + `pip install --user yt-dlp` (and ffmpeg, which it already has for + Qt). The menu says so if it's missing. +- [ ] **Importing the same link twice makes a second copy.** The importer + dedups by file location, and a fresh download has a fresh path. The + yt-dlp id (the `[jNQXAC9IVRw]` in the filename) is a stable key to + check against before downloading. +- [ ] **"Stop Identifying (N left)" doesn't count songs still downloading.** + The queue only knows about songs that have landed. +- [ ] **The art window's title-bar allowance is a fixed 48 px.** Wayland + doesn't report frame size before the window is mapped. If GNOME's + title bar is taller at some scale, the bottom of the cover can clip by + a few pixels. + ## Round 48 (2026-09-10) — A library to break: done, see tasks-done.md `scripts/make_dev_library.py` + the andTunes resume docs. Left on the table: diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 334a1a7..6f9af11 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.17.1" +__version__ = "0.18.0" diff --git a/lintunes/fingerprint.py b/lintunes/fingerprint.py index 91582fe..ebff5c3 100644 --- a/lintunes/fingerprint.py +++ b/lintunes/fingerprint.py @@ -364,6 +364,13 @@ class TrackIdentifier(QObject): def work(): try: + # No key: there's nobody to ask, so skip fpcalc and the + # network entirely and fall through to the filename. Only an + # Import from URL reaches here, because the menu route insists + # on setup first. A missing fpcalc lands in the same place, + # via fingerprint_file raising. + if not api_key: + raise RuntimeError("no AcoustID key set") duration, fp = fingerprint_file(location) candidates = lookup_fingerprint(api_key, duration, fp, hint) except Exception as e: diff --git a/lintunes/gui/art_window.py b/lintunes/gui/art_window.py index b0d1a52..831a0a2 100644 --- a/lintunes/gui/art_window.py +++ b/lintunes/gui/art_window.py @@ -1,7 +1,32 @@ from PyQt6.QtWidgets import QWidget, QApplication -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QSize from PyQt6.QtGui import QPainter, QPixmap +# Height left free for the window's own title bar. On Wayland the frame size +# isn't known until the window is mapped, so this is a fixed allowance. +DECORATION_ALLOWANCE = 48 + + +def fit_size(art: QSize, available: QSize, + decorations: int = DECORATION_ALLOWANCE) -> QSize: + """The largest size with the art's own shape that fits on the screen. + + Scales *up* as well as down: a 300 px cover still opens screen-tall. The + window takes the art's shape rather than the screen's, so there are no + black bars. + """ + room = QSize(max(available.width(), 1), + max(available.height() - decorations, 1)) + if art.isEmpty(): + return room + return art.scaled(room, Qt.AspectRatioMode.KeepAspectRatio) + + +def _aspect(pixmap: QPixmap | None) -> float | None: + if pixmap is None or pixmap.isNull() or pixmap.height() == 0: + return None + return pixmap.width() / pixmap.height() + class ArtWindow(QWidget): """Non-modal window showing the current track's album art as large as @@ -13,16 +38,40 @@ class ArtWindow(QWidget): self.setMinimumSize(120, 120) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) + def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None): + """Open (or re-raise) the window, sized to the art on ``screen``.""" + self.set_artwork(pixmap, title) + self.fit_to_screen(screen) + self.show() + self.raise_() + self.activateWindow() + def set_artwork(self, pixmap: QPixmap | None, title: str): self.setWindowTitle(title or "Album Art") + reshape = (self.isVisible() and _aspect(pixmap) is not None + and _aspect(self._pixmap) is not None + and abs(_aspect(pixmap) - _aspect(self._pixmap)) > 0.01) self._pixmap = pixmap - if pixmap is not None and not self.isVisible(): - screen = QApplication.primaryScreen().availableGeometry() - width = min(pixmap.width(), int(screen.width() * 0.8)) - height = min(pixmap.height(), int(screen.height() * 0.8)) - self.resize(max(width, 240), max(height, 240)) + if reshape: + # A track change brought a different shape of cover: re-fit, or + # the new art would sit between black bars. + self.fit_to_screen() self.update() + def fit_to_screen(self, screen=None): + if self._pixmap is None or self._pixmap.isNull(): + return + screen = screen or self.screen() or QApplication.primaryScreen() + if screen is None: + return + geo = screen.availableGeometry() + size = fit_size(self._pixmap.size(), geo.size()) + self.resize(size) + # Wayland ignores move() (the compositor places the window); X11 + # honors it. + self.move(geo.x() + (geo.width() - size.width()) // 2, + geo.y() + (geo.height() - size.height()) // 2) + def paintEvent(self, event): painter = QPainter(self) painter.fillRect(self.rect(), Qt.GlobalColor.black) diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 5818595..6ec563b 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -9,7 +9,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import Qt, QEvent, QTimer from PyQt6.QtGui import QAction, QKeySequence -from lintunes import device_sync, mpris, music_folder, tagging, theme +from lintunes import device_sync, mpris, music_folder, tagging, theme, url_import from lintunes.andtunes import layout as andtunes_layout from lintunes.andtunes import sync as andtunes_sync from lintunes.art_search import AlbumArtFetcher @@ -29,6 +29,7 @@ from lintunes.gui.sidebar import SidebarPanel from lintunes.gui.library_view import LibraryView from lintunes.gui.playlist_view import PlaylistView from lintunes.gui.transport import TransportBar +from lintunes.gui.url_import_dialog import UrlImportDialog from lintunes.gui.info_dialog import InfoDialog from lintunes.gui.music_folder_dialog import MusicFolderDialog from lintunes.gui.preferences_dialog import PreferencesDialog @@ -170,6 +171,17 @@ class MainWindow(QMainWindow): self._identify_queue = [] self._identify_total = 0 self._identifier = None + # True from the first lookup until the queue drains, so tracks queued + # mid-run (Import from URL) wait their turn instead of starting a + # second lookup alongside. + self._identify_running = False + # Import from URL: also shares the status-bar progress widgets. + self._url_worker = None + self._url_target = None # url_import.ImportTarget, or None + self._url_inserted = 0 # songs added to the target so far + self._url_imported = 0 + self._url_identify = True # False after "Stop Identifying" + self._url_note = "" # Separate instance from the playback inhibitor: pausing music # mid-sync must not drop the sync's hold. Logout flag included so # GNOME's shutdown/restart dialog names the transfer as the blocker. @@ -298,9 +310,12 @@ class MainWindow(QMainWindow): file_menu.addSeparator() self._add_action(file_menu, "Add Files to Library…", "Ctrl+O", self._open_files_dialog) + self._url_import_action = self._add_action( + file_menu, "Import from URL…", "", self._import_from_url) self._export_action = self._add_action( file_menu, "Export Playlist…", "", self._export_playlist) file_menu.aboutToShow.connect(self._refresh_export_action) + file_menu.aboutToShow.connect(self._refresh_url_import_action) file_menu.addSeparator() self._add_action(file_menu, "Quit", "Ctrl+Q", self.close) @@ -488,12 +503,15 @@ class MainWindow(QMainWindow): def _busy_worker(self): """The transfer currently owning the status-bar progress widgets.""" for worker in (self._sync_worker, self._andtunes_worker, - self._export_worker): + self._export_worker, self._url_worker): if worker is not None and worker.busy(): return worker return None def _confirm_cancel_sync(self): + if self._url_worker is not None and self._url_worker.busy(): + self._confirm_cancel_url_import() + return if self._export_worker is not None and self._export_worker.busy(): self._confirm_cancel_export() return @@ -1005,8 +1023,7 @@ class MainWindow(QMainWindow): if paths: self.import_files([Path(p) for p in paths]) - def import_files(self, paths, playlist_pid: str = "", - position: int | None = None): + def _ensure_music_dir(self) -> Path | None: music_dir = self._music_import_dir() if music_dir is None: # Ask rather than guess — better one question than music copied @@ -1017,6 +1034,12 @@ class MainWindow(QMainWindow): self.statusBar().showMessage( "Choose where to keep your music first — Edit ▸ Preferences.", 8000) + return music_dir + + def import_files(self, paths, playlist_pid: str = "", + position: int | None = None) -> list: + music_dir = self._ensure_music_dir() + if music_dir is None: return [] self.statusBar().showMessage(f"Importing {len(paths)} item(s)…") imported = file_importer.import_paths( @@ -1025,10 +1048,136 @@ class MainWindow(QMainWindow): self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000) if imported: self._library_view.reload() + return imported def _import_files_to_playlist(self, paths, pid, position=None): self.import_files([Path(p) for p in paths], pid, position) + # ---- importing from a link (trav's `song` helper, in-process) ---- + + def _refresh_url_import_action(self): + """One transfer owns the status-bar progress widgets at a time.""" + self._url_import_action.setEnabled(self._busy_worker() is None) + + def _url_import_target(self): + shown, row = "", None + if self._content.currentWidget() is self._playlist_view: + shown = self._playlist_view.playlist_id + rows = self._playlist_view.table.selected_source_rows() + row = rows[0] if rows else None + return url_import.resolve_target( + self._manager.library, shown, row, self._now_playing_context) + + def _import_from_url(self): + if self._busy_worker() is not None: + return + missing = [name for name, ok in ( + ("yt-dlp", url_import.ytdlp_available()), + ("ffmpeg", exporter.web_support.ffmpeg_available())) if not ok] + if missing: + QMessageBox.warning( + self, "Import from URL", + f"Importing from a link needs {' and '.join(missing)}, " + f"which {'is' if len(missing) == 1 else 'are'} not " + f"installed.\n\n" + "yt-dlp: pip install --user yt-dlp (or your distro's " + "package)\nffmpeg: your distro's “ffmpeg” package") + return + target = self._url_import_target() + dialog = UrlImportDialog(target.description if target else None, self) + if not dialog.exec(): + return + if self._ensure_music_dir() is None: + return + self._url_target = target if dialog.add_to_playlist() else None + self._url_inserted = 0 + self._url_imported = 0 + self._url_identify = True + problem = self._identify_setup_problem() + self._url_note = (f"tags proposed from filenames only ({problem})" + if problem else "") + + worker = url_import.UrlImportWorker(dialog.url(), self) + worker.item_started.connect(self._on_url_item_started) + worker.progress.connect(self._sync_progress.setValue) + worker.downloaded.connect(self._on_url_downloaded) + worker.finished.connect(self._on_url_finished) + worker.failed.connect(self._on_url_failed) + self._url_worker = worker + self._sync_label.setText("Downloading…") + self._sync_progress.setRange(0, 100) + self._sync_progress.setValue(0) + self._sync_cancel.show() + self._sync_label.show() + self._sync_progress.show() + worker.start() + + def _on_url_item_started(self, index: int, count: int, title: str): + short = title if len(title) <= 40 else title[:39] + "…" + where = f"{index} of {count} · " if count > 1 else "" + self._sync_label.setText(f"Downloading {where}{short}") + self._sync_progress.setValue(0) + + def _on_url_downloaded(self, path: str): + """One song is off the network: file it and identify it now, so the + first dialog can be up while the rest are still downloading.""" + target = self._url_target + pid, position = "", None + if target is not None and target.pid in self._manager.library.playlists: + pid = target.pid + if target.position is not None: + # Each song goes after the ones already added, so the batch + # keeps the link's order, all of it above the selected song. + position = target.position + self._url_inserted + imported = self.import_files([Path(path)], pid, position) + if pid: + self._url_inserted += len(imported) + self._url_imported += len(imported) + if imported and self._url_identify: + self._enqueue_identify(imported) + + def _finish_url_import(self): + if self._url_worker is not None: + self._url_worker.cleanup() + self._hide_sync_widgets() + + def _on_url_finished(self, summary: dict): + self._finish_url_import() + count = self._url_imported + if summary["cancelled"]: + msg = f"Import cancelled — {count} song(s) imported" + else: + msg = f"Imported {count} song(s) from the link" + target = self._url_target + playlist = (self._manager.library.playlists.get(target.pid) + if target is not None else None) + if playlist is not None and count: + msg += f" into “{playlist.name}”" + if summary["errors"]: + msg += f" · {len(summary['errors'])} couldn't be downloaded" + if self._url_note and count and self._url_identify: + msg += f" · {self._url_note}" + self.statusBar().showMessage(msg, 12000) + + def _on_url_failed(self, message: str): + self._finish_url_import() + QMessageBox.warning(self, "Import from URL", + f"Couldn't import from that link:\n\n{message}") + + def _confirm_cancel_url_import(self): + box = QMessageBox(self) + box.setWindowTitle("Stop downloading") + box.setText("Stop downloading from the link?\n" + "Songs already imported stay in your library.") + stop = box.addButton("Stop Downloading", + QMessageBox.ButtonRole.DestructiveRole) + keep = box.addButton("Keep Downloading", + QMessageBox.ButtonRole.RejectRole) + box.setDefaultButton(keep) + box.exec() + if box.clickedButton() is stop and self._url_worker is not None: + self._url_worker.cancel() + # ---- deleting tracks ---- def _delete_tracks(self, track_ids: list[int], delete_files: bool): @@ -1229,14 +1378,37 @@ class MainWindow(QMainWindow): f"Skipped {skipped} track(s) with no file to fingerprint", 6000) if not tracks: return - self._identify_queue = tracks - self._identify_total = len(tracks) - self._identify_next(api_key) + self._enqueue_identify(tracks) - def _identify_next(self, api_key: str): + def _identify_setup_problem(self) -> str: + """Why an AcoustID lookup can't run on this machine, or ''.""" + if not fpcalc_available(): + return "fpcalc isn't installed" + if not self._prefs.acoustid.get("api_key", "").strip(): + return "no AcoustID key in Preferences" + return "" + + def _enqueue_identify(self, tracks: list): + """Queue tracks for Identify and start the queue if it's idle. An + Import from URL adds songs one at a time while an earlier one is still + behind its dialog, so the queue can grow mid-run.""" + if not self._identify_running: + self._identify_queue = [] + self._identify_total = 0 + self._identify_queue.extend(tracks) + self._identify_total += len(tracks) + if not self._identify_running: + self._identify_next() + + def _identify_next(self): if not self._identify_queue: + self._identify_running = False return + self._identify_running = True track = self._identify_queue.pop(0) + if track.track_id not in self._manager.library.tracks: + self._identify_next() # deleted while it waited its turn + return done = self._identify_total - len(self._identify_queue) counter = (f" ({done} of {self._identify_total})" if self._identify_total > 1 else "") @@ -1246,10 +1418,14 @@ class MainWindow(QMainWindow): # source) outlives this method. self._identifier = TrackIdentifier() self._identifier.finished.connect( - lambda result, t=track, k=api_key: self._on_identified(t, k, result)) - self._identifier.identify(track, api_key) + lambda result, t=track: self._on_identified(t, result)) + # Read fresh for every track. With no key (only an Import from URL + # gets this far without one) the identifier proposes from the + # filename alone. + self._identifier.identify( + track, self._prefs.acoustid.get("api_key", "").strip()) - def _on_identified(self, track, api_key: str, result: dict): + def _on_identified(self, track, result: dict): if "error" in result: self.statusBar().showMessage( f"Couldn't identify “{track.name}”: {result['error']}", 6000) @@ -1273,9 +1449,13 @@ class MainWindow(QMainWindow): f"Updated tags for “{track.name}”", 5000) elif dialog.cancel_all: self._identify_queue.clear() + self._identify_running = False + # Also covers songs a URL import has yet to download: they + # still import, they just don't get a dialog each. + self._url_identify = False self.statusBar().clearMessage() return - self._identify_next(api_key) + self._identify_next() # ---- relocating a moved/renamed file ---- @@ -1418,8 +1598,11 @@ class MainWindow(QMainWindow): def closeEvent(self, event): # Quitting mid-transfer abandons a partial file on the device (it # self-heals next sync, but silently) — make it a deliberate choice. - if self._busy_worker() is not None and self._busy_worker() is not ( - self._export_worker): + # A link import has nothing half-written on a device to warn about: + # it is simply stopped below. + busy = self._busy_worker() + if busy is not None and busy not in (self._export_worker, + self._url_worker): box = QMessageBox(self) box.setWindowTitle("Sync in progress") box.setText( @@ -1435,6 +1618,9 @@ class MainWindow(QMainWindow): if box.clickedButton() is not quit_button: event.ignore() return + if self._url_worker is not None: + self._url_worker.cancel() + self._url_worker.cleanup() self._inhibitor.release() self._sync_inhibitor.release() # Before the player: this stops the device and kills the HTTP server it diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 4921963..9676ef4 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -88,7 +88,7 @@ class SidebarPanel(QWidget): class SidebarArt(QWidget): """Square album art pinned to the sidebar bottom; gray square when the - current track has no embedded art. Double-click opens the big art window.""" + current track has no embedded art. A click opens the big art window.""" def __init__(self, player, parent=None): super().__init__(parent) @@ -96,7 +96,7 @@ class SidebarArt(QWidget): self._pixmap: QPixmap | None = None self._track = None self._art_window = ArtWindow() - self.setToolTip("Double-click to view full size") + self.setToolTip("Click to view full size") player.track_changed.connect(self.set_track) def set_track(self, track): @@ -128,11 +128,15 @@ class SidebarArt(QWidget): self.setFixedHeight(self.width()) super().resizeEvent(event) - def mouseDoubleClickEvent(self, event): - if self._pixmap is not None: - self._art_window.set_artwork(self._pixmap, self._title()) - self._art_window.show() - self._art_window.raise_() + def mouseReleaseEvent(self, event): + # A click is a release that lands on the art. Opening on press would + # also fire when a drag merely starts here. + if (event.button() == Qt.MouseButton.LeftButton + and self.rect().contains(event.position().toPoint()) + and self._pixmap is not None): + self._art_window.show_artwork(self._pixmap, self._title(), + self.screen()) + super().mouseReleaseEvent(event) def paintEvent(self, event): painter = QPainter(self) diff --git a/lintunes/gui/url_import_dialog.py b/lintunes/gui/url_import_dialog.py new file mode 100644 index 0000000..d7ad27b --- /dev/null +++ b/lintunes/gui/url_import_dialog.py @@ -0,0 +1,63 @@ +"""File ▸ Import from URL…: paste a link, optionally add to the current playlist. + +Passive, like IdentifyDialog: the caller works out where the songs would go +(``url_import.resolve_target``) and passes its description in. The dialog only +reports the link and whether the box was ticked. +""" +from PyQt6.QtWidgets import ( + QApplication, QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, + QVBoxLayout, +) + +from lintunes.url_import import looks_like_url + + +class UrlImportDialog(QDialog): + def __init__(self, target_description: str | None, parent=None): + super().__init__(parent) + self.setWindowTitle("Import from URL") + self.setMinimumWidth(460) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel("Paste a link to a song, or to a playlist of " + "songs:")) + self._url = QLineEdit() + self._url.setPlaceholderText("https://…") + clipboard = QApplication.clipboard().text().strip() + if looks_like_url(clipboard): + self._url.setText(clipboard) + self._url.selectAll() + layout.addWidget(self._url) + + self._add = QCheckBox("and add to current playlist?") + layout.addWidget(self._add) + self._hint = QLabel(target_description + or "Select or play a playlist to add songs to it.") + self._hint.setEnabled(False) # the greyed-hint idiom + self._hint.setWordWrap(True) + self._hint.setContentsMargins(22, 0, 0, 0) + layout.addWidget(self._hint) + if target_description is None: + self._add.setEnabled(False) + else: + self._add.setChecked(True) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok + | QDialogButtonBox.StandardButton.Cancel) + self._ok = buttons.button(QDialogButtonBox.StandardButton.Ok) + self._ok.setText("Import") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self._url.textChanged.connect(self._update_ok) + self._update_ok() + + def _update_ok(self): + self._ok.setEnabled(looks_like_url(self._url.text())) + + def url(self) -> str: + return self._url.text().strip() + + def add_to_playlist(self) -> bool: + return self._add.isEnabled() and self._add.isChecked() diff --git a/lintunes/storage/play_journal.py b/lintunes/storage/play_journal.py index 8df72f5..031e7bb 100644 --- a/lintunes/storage/play_journal.py +++ b/lintunes/storage/play_journal.py @@ -108,8 +108,13 @@ class PlayJournal: entries = read_json(path) except (ValueError, OSError): continue # a half-synced journal must never abort startup + if not isinstance(entries, dict): + continue + # Nor must one in a shape we don't know. Round 48's dev + # fixture wrapped the map in {"machine", "tracks"}, and + # int("tracks") took startup down with it. entries = {int(k): v for k, v in entries.items() - if isinstance(v, dict)} + if isinstance(v, dict) and str(k).isdigit()} if path.stem == self.machine: self._own = entries else: diff --git a/lintunes/url_import.py b/lintunes/url_import.py new file mode 100644 index 0000000..400ba91 --- /dev/null +++ b/lintunes/url_import.py @@ -0,0 +1,271 @@ +"""File ▸ Import from URL… — trav's `song` shell helper, run from inside LinTunes. + +`song` is ``yt-dlp --extract-audio --audio-format mp3 "$@"``. This module runs +exactly those flags; everything else on the command line is plumbing for +reading back what yt-dlp did. yt-dlp is a *runtime* CLI tool, detected with +``shutil.which`` like fpcalc and ffmpeg, never a pip dependency. + +Shaped like ``fingerprint.py``: pure helpers first (the command line, the +marker parser, and where the songs should go), then the subprocess on a daemon +thread behind ``UrlImportWorker``. + +Downloads land in a private temp dir and are handed to the GUI one at a time, +which imports each as it arrives. The normal importer *copies* it into the +organized tree, so the temp dir only ever holds our own downloads. The GUI +deletes it (``cleanup``) once it has seen ``finished`` or ``failed``: those +signals are queued after every ``downloaded``, so by then every file has been +copied. yt-dlp's default filename, ``Title [id].mp3``, is kept on purpose, +because it's exactly what ``filename_tags.parse_filename`` reads. +""" +import os +import shutil +import signal +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from pathlib import Path + +from PyQt6.QtCore import QObject, pyqtSignal + +from lintunes.models.playlist import PlaylistType + +# The `song` helper's own flags, verbatim. +SONG_ARGS = ["--extract-audio", "--audio-format", "mp3"] + +# Line prefixes we ask yt-dlp to print, so its output can be read reliably +# instead of scraped. +START = "LTSTART" +FILE = "LTFILE" +PROGRESS = "LTPROG" + + +def ytdlp_available() -> bool: + """Whether the yt-dlp *binary* is on PATH (the fpcalc_available pattern).""" + return shutil.which("yt-dlp") is not None + + +def looks_like_url(text: str) -> bool: + text = (text or "").strip() + return (text.startswith(("http://", "https://")) + and len(text) > len("https://") + and not any(c.isspace() for c in text)) + + +def build_command(url: str, dest_dir) -> list[str]: + """yt-dlp with the `song` flags, downloading into ``dest_dir``. + + ``before_dl`` names each item as it starts (with its place in a playlist), + ``after_move`` gives the final mp3's path once ffmpeg has finished with it, + and the progress template reduces the progress bar to one number per line. + yt-dlp's default already carries on past an unavailable video in a + playlist, so "import everything at the link" needs no extra flag. + """ + return [ + "yt-dlp", *SONG_ARGS, + "-P", str(dest_dir), + "--print", + f"before_dl:{START} %(playlist_index|1)s\t%(n_entries|1)s\t%(title)s", + "--print", f"after_move:{FILE} %(filepath)s", + "--progress", "--newline", + "--progress-template", f"download:{PROGRESS} %(progress._percent_str)s", + "--", url, + ] + + +def _int(text: str, default: int) -> int: + try: + return int(text) + except (TypeError, ValueError): + return default + + +def parse_line(line: str): + """One line of yt-dlp output → ("start", i, n, title) | ("file", path) | + ("progress", percent) | None for everything else.""" + line = line.rstrip("\r\n") + if line.startswith(START + " "): + parts = line[len(START) + 1:].split("\t", 2) + if len(parts) != 3: + return None + return ("start", _int(parts[0], 1), _int(parts[1], 1), parts[2]) + if line.startswith(FILE + " "): + path = line[len(FILE) + 1:] + return ("file", path) if path.strip() else None + if line.startswith(PROGRESS + " "): + text = line[len(PROGRESS) + 1:].strip().rstrip("%") + try: + return ("progress", max(0, min(100, int(float(text))))) + except ValueError: + return None + return None + + +# ---- where the songs go ---- + +@dataclass +class ImportTarget: + pid: str + position: int | None # playlist index to insert before; None appends + description: str # what the dialog tells the user + + +def accepts_adds(playlist) -> bool: + """A playlist you can add songs to by hand: not a folder, not smart (its + membership comes from its rules), not a system list.""" + return (playlist is not None + and playlist.playlist_type == PlaylistType.REGULAR + and not playlist.is_system) + + +def resolve_target(library, shown_pid: str, selected_row: int | None, + playing_context: str) -> ImportTarget | None: + """Where "and add to current playlist?" puts the songs. + + 1. A playlist is shown and a song in it is selected: above that song. + 2. Otherwise, the playlist that's playing: at its end. + 3. Otherwise, the playlist that's shown: at its end. + 4. Otherwise nowhere, and the checkbox is grayed out. + + ``selected_row`` is the selected song's row in the table's *source* model, + which is playlist order whatever the column sort is. That model skips + ids with no track behind them, so the row is mapped back to a real index + into ``track_ids``. + """ + shown = library.playlists.get(shown_pid) if shown_pid else None + if not accepts_adds(shown): + shown = None + + if shown is not None and selected_row is not None: + present = [i for i, tid in enumerate(shown.track_ids) + if tid in library.tracks] + if 0 <= selected_row < len(present): + index = present[selected_row] + song = library.tracks[shown.track_ids[index]].name or "(untitled)" + return ImportTarget(shown.persistent_id, index, + f"Adds above “{song}” in “{shown.name}”") + + if playing_context.startswith("playlist:"): + playing = library.playlists.get(playing_context.split(":", 1)[1]) + if accepts_adds(playing): + return ImportTarget(playing.persistent_id, None, + f"Adds to the end of “{playing.name}” " + f"(playing)") + + if shown is not None: + return ImportTarget(shown.persistent_id, None, + f"Adds to the end of “{shown.name}”") + return None + + +# ---- the download ---- + +class UrlImportWorker(QObject): + """Runs yt-dlp on a daemon thread, reporting each finished file as it + lands. Threading matches ExportWorker: a plain daemon thread, not a + QThread. + + ``finished`` carries {"downloaded": int, "errors": [str], "cancelled": + bool}. ``failed`` means nothing downloaded at all, and carries yt-dlp's + last error line. + """ + + item_started = pyqtSignal(int, int, str) # index, count, title + progress = pyqtSignal(int) # percent of the current item + downloaded = pyqtSignal(str) # final path of one mp3 + finished = pyqtSignal(dict) + failed = pyqtSignal(str) + + def __init__(self, url: str, parent=None): + super().__init__(parent) + self._url = url + self._busy = False + self._cancel = threading.Event() + self._proc = None + self.temp_dir: Path | None = None + + def busy(self) -> bool: + return self._busy + + def cancel(self): + self._cancel.set() + self._terminate() + + def _terminate(self): + proc = self._proc + if proc is None or proc.poll() is not None: + return + # yt-dlp runs ffmpeg as a child. Signal the whole session, so a cancel + # during conversion doesn't leave ffmpeg writing into the temp dir. + try: + os.killpg(proc.pid, signal.SIGTERM) + except (OSError, AttributeError): + proc.terminate() + + def start(self): + if self._busy: + return + self._busy = True + self.temp_dir = Path(tempfile.mkdtemp(prefix="lintunes-url-")) + threading.Thread(target=self._run_guarded, daemon=True).start() + + def cleanup(self): + """Remove our temp dir. Called by the GUI once it has imported every + file, meaning after ``finished`` or ``failed``.""" + if self.temp_dir is not None: + shutil.rmtree(self.temp_dir, ignore_errors=True) + self.temp_dir = None + + def _run_guarded(self): + try: + self._run() + except Exception as e: # never leave the GUI waiting forever + self._busy = False + self.failed.emit(str(e)) + + def _run(self): + errors: list[str] = [] + count = 0 + try: + proc = subprocess.Popen( + build_command(self._url, self.temp_dir), + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + # One stream: the markers are on stdout, the ERROR: lines + # on stderr, and one reader can't deadlock on the other. + stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", bufsize=1, + start_new_session=True) + except OSError as e: + self._busy = False + self.failed.emit(f"couldn't run yt-dlp: {e}") + return + self._proc = proc + if self._cancel.is_set(): # cancelled before the process existed + self._terminate() + + for line in proc.stdout: + parsed = parse_line(line) + if parsed is None: + if line.startswith("ERROR:"): + errors.append(line[len("ERROR:"):].strip()) + continue + kind = parsed[0] + if kind == "start": + self.item_started.emit(parsed[1], parsed[2], parsed[3]) + elif kind == "progress": + self.progress.emit(parsed[1]) + elif kind == "file" and not self._cancel.is_set(): + count += 1 + self.downloaded.emit(parsed[1]) + proc.wait() + + cancelled = self._cancel.is_set() + self._busy = False + if count == 0 and not cancelled: + self.failed.emit( + errors[-1] if errors else + f"yt-dlp found nothing to download (exit code " + f"{proc.returncode})") + return + self.finished.emit({"downloaded": count, "errors": errors, + "cancelled": cancelled}) diff --git a/scripts/make_dev_library.py b/scripts/make_dev_library.py index 9870aba..59dabf9 100644 --- a/scripts/make_dev_library.py +++ b/scripts/make_dev_library.py @@ -330,12 +330,14 @@ def _write_plays(data_dir: Path, ids: list): """One machine's play journal, in the per-machine-totals shape.""" plays = data_dir / "plays" plays.mkdir(parents=True, exist_ok=True) - entries = {str(tid): {"play_count": random.randint(1, 12), - "play_date_utc": _iso(random.randint(0, 200))} + entries = {str(tid): {"plays": random.randint(1, 12), + "last_played": _iso(random.randint(0, 200))} for tid in random.sample(ids, k=min(8, len(ids)))} + # The same flat {track id: totals} map PlayJournal.save() writes, with + # record_play()'s keys. The machine is the filename; there is no wrapper + # object. (plays / "devbox.json").write_text( - json.dumps({"machine": "devbox", "tracks": entries}, indent=2), - encoding="utf-8") + json.dumps(entries, indent=2), encoding="utf-8") def _write_prefs(data_dir: Path, library: Library): diff --git a/tasks-done.md b/tasks-done.md index ef6862e..dc737aa 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,47 @@ ## Done +### Round 49 (2026-09-10) — Import from URL (v0.18.0) + +trav's `song` shell helper is `yt-dlp --extract-audio --audio-format mp3`, +followed by a drag into LinTunes. Now it's one menu item. + +- [x] **File ▸ Import from URL…** sits under Add Files to Library. You paste a + link (it's prefilled from the clipboard). Everything at the link + downloads, whether one song or a playlist, with the `song` flags + verbatim (`lintunes/url_import.py`). Output is read from `--print` + markers, not scraped. Downloads go to a private temp dir, each song is + imported the moment it lands, and the temp dir is removed at the end. + Progress and cancel use the status-bar transfer widgets. +- [x] **"and add to current playlist?"** puts the songs above the selected + song in the shown playlist, else at the end of the playing one, else at + the end of the shown one. With none of those the checkbox is grayed. A + hint under the checkbox names the actual target, so the rule is never a + surprise. A batch keeps the link's order, and smart and folder + playlists are never targets. +- [x] **Every imported song goes through Identify Track** with the usual + checkbox dialog, song 1 while the rest still download (the Identify + queue can now grow mid-run). With no AcoustID key the proposal comes + from the filename alone, with no fpcalc and no network. yt-dlp's + `Title [id].mp3` is exactly what the filename parser was built for. +- [x] **Album art opens on a single click**, in a window shaped like the + cover and as big as the screen allows (`art_window.fit_size`): square + for a square cover, no black bars, and a small cover scales up. +- [x] **Found while verifying: the dev fixture's play journal crashed + startup.** `make_dev_library.py` got it wrong twice. First, it wrapped + the journal as `{"machine", "tracks"}`, but `PlayJournal` reads a flat + `{track id: totals}` map, and `int("tracks")` raised straight through + a loader whose own comment says a bad journal must never abort + startup. Second, even unwrapped, its entries used the `library.json` + names (`play_count`/`play_date_utc`) instead of `record_play()`'s + (`plays`/`last_played`), so the fixture's plays would have been + silently ignored. The generator now writes the real shape and keys + (checked: base + journal = the effective count on every journaled + track), and the loader skips shapes it doesn't know. +- [x] Verified end to end against a copy of the dev library, with real + yt-dlp on a 19-second video. The song landed above the selected row in + “Inside B”, the Identify dialog proposed a name from the filename, and + the temp dir was gone afterwards. + ### Round 48 (2026-09-10) — A library to break (v0.17.1) Round 47 was verified by loading trav's real 21,531-track library. Nothing was diff --git a/tests/test_round49.py b/tests/test_round49.py new file mode 100644 index 0000000..112ba25 --- /dev/null +++ b/tests/test_round49.py @@ -0,0 +1,474 @@ +"""Round 49 — Import from URL, and the album art on a single click. + +trav's `song` shell helper is ``yt-dlp --extract-audio --audio-format mp3``, +and now File ▸ Import from URL… runs it for him. Everything at the link lands +in the library, optionally in the current playlist, and each song goes +through Identify Track as it arrives. The rule to protect is *where* the songs +go: +1. above the selected song in the shown playlist, +2. else at the end of the playlist that's playing, +3. else at the end of the one that's shown, +4. else nowhere, with the checkbox grayed out. + +A batch keeps the link's order. + +The side fix: the sidebar art opens its window on one click, sized to the +art's own shape and as big as the screen allows. + +yt-dlp itself is never run here. A fake on PATH prints the same marker lines +the real command is told to print. +""" +import os +import shutil +import stat +import time + +import pytest +from PyQt6.QtCore import QEvent, QObject, QPointF, QSize, Qt, pyqtSignal +from PyQt6.QtGui import QMouseEvent, QPixmap + +from lintunes import fingerprint, url_import +from lintunes.library_manager import LibraryManager +from lintunes.models import Library, Track +from lintunes.models.playlist import Playlist, PlaylistType +from lintunes.preferences import Preferences +from lintunes.url_import import ImportTarget, UrlImportWorker + + +# -------------------------------------------------------------------------- +# helpers + +FAKE_YTDLP = '''#!/usr/bin/env python3 +import os, sys, time +args = sys.argv[1:] +dest = args[args.index("-P") + 1] +url = args[-1] +mode = os.environ.get("FAKE_YTDLP_MODE", "ok") +if mode == "fail": + print("ERROR: [generic] Unsupported URL: " + url, file=sys.stderr, flush=True) + sys.exit(1) +src = os.environ.get("FAKE_YTDLP_AUDIO") +titles = ["First Song", "Second Song"] +for i, title in enumerate(titles, 1): + print(f"LTSTART {i}\\t{len(titles)}\\t{title}", flush=True) + print("[download] some noise yt-dlp prints", flush=True) + print("LTPROG 50.0%", flush=True) + path = os.path.join(dest, f"{title} [id{i}].mp3") + with open(path, "wb") as f: + f.write(open(src, "rb").read() if src else b"ID3fake") + print(f"LTFILE {path}", flush=True) + if mode == "slow": + time.sleep(30) +''' + + +@pytest.fixture +def fake_ytdlp(tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "yt-dlp" + script.write_text(FAKE_YTDLP) + script.chmod(script.stat().st_mode | stat.S_IEXEC) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + return script + + +@pytest.fixture +def isolated_config(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + +def _library(tmp_path): + """Four tracks; regular playlists "a" (with a dangling id) and "b", plus + a smart one and a folder.""" + media = tmp_path / "media" + library = Library(music_folder=str(media)) + for tid in range(1, 5): + path = media / "Music" / f"Artist{tid}" / "Album" / f"T{tid}.mp3" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"audio") + library.tracks[tid] = Track(track_id=tid, name=f"T{tid}", + artist=f"Artist{tid}", location=str(path)) + library.playlists["a"] = Playlist(name="Road Trip", persistent_id="a", + track_ids=[1, 99, 2, 3]) + library.playlists["b"] = Playlist(name="Chill", persistent_id="b", + track_ids=[4]) + library.playlists["s"] = Playlist(name="Smart", persistent_id="s", + playlist_type=PlaylistType.SMART, + track_ids=[1]) + library.playlists["f"] = Playlist(name="Folder", persistent_id="f", + playlist_type=PlaylistType.FOLDER) + return library + + +def _pump(qapp, until, timeout=10.0): + deadline = time.monotonic() + timeout + while not until() and time.monotonic() < deadline: + qapp.processEvents() + time.sleep(0.01) + assert until(), "timed out waiting" + + +# -------------------------------------------------------------------------- +# A. the command line and its output + + +class TestCommand: + def test_runs_the_song_flags_with_the_url_last(self, tmp_path): + cmd = url_import.build_command("https://example.com/v?id=1", tmp_path) + assert cmd[0] == "yt-dlp" + assert cmd[1:4] == ["--extract-audio", "--audio-format", "mp3"] + assert cmd[-2:] == ["--", "https://example.com/v?id=1"] + assert cmd[cmd.index("-P") + 1] == str(tmp_path) + + def test_keeps_the_default_filename(self, tmp_path): + """filename_tags reads yt-dlp's own "Title [id]", so no -o.""" + cmd = url_import.build_command("https://x.test/v", tmp_path) + assert "-o" not in cmd and "--output" not in cmd + + @pytest.mark.parametrize("line, expected", [ + ("LTSTART 2\t5\tSome Song\n", ("start", 2, 5, "Some Song")), + ("LTSTART NA\tNA\tSolo\n", ("start", 1, 1, "Solo")), + ("LTSTART 1\t1\tTab\tin title\n", ("start", 1, 1, "Tab\tin title")), + ("LTFILE /tmp/x/Song [abc].mp3\n", ("file", "/tmp/x/Song [abc].mp3")), + ("LTPROG 45.3%\n", ("progress", 45)), + ("LTPROG 100.0%\r\n", ("progress", 100)), + ("LTPROG N/A\n", None), + ("LTFILE \n", None), + ("[youtube] Extracting URL\n", None), + ("ERROR: Video unavailable\n", None), + ]) + def test_parse_line(self, line, expected): + assert url_import.parse_line(line) == expected + + @pytest.mark.parametrize("text, ok", [ + ("https://www.youtube.com/watch?v=abc", True), + (" http://soundcloud.com/x/y ", True), + ("https://", False), + ("youtube.com/watch?v=abc", False), + ("https://a b", False), + ("", False), + ]) + def test_looks_like_url(self, text, ok): + assert url_import.looks_like_url(text) is ok + + +# -------------------------------------------------------------------------- +# B. where the songs go + + +class TestResolveTarget: + def test_above_the_selected_song(self, tmp_path): + library = _library(tmp_path) + # Source row 1 is T2: the dangling id 99 isn't a row, so the + # playlist index is 2, not 1. + target = url_import.resolve_target(library, "a", 1, "library") + assert (target.pid, target.position) == ("a", 2) + assert "T2" in target.description and "Road Trip" in target.description + + def test_no_selection_goes_to_the_end_of_the_playing_playlist(self, tmp_path): + library = _library(tmp_path) + target = url_import.resolve_target(library, "a", None, "playlist:b") + assert (target.pid, target.position) == ("b", None) + assert "(playing)" in target.description + + def test_selection_beats_playing(self, tmp_path): + library = _library(tmp_path) + target = url_import.resolve_target(library, "a", 0, "playlist:b") + assert (target.pid, target.position) == ("a", 0) + + def test_shown_playlist_when_nothing_is_playing(self, tmp_path): + library = _library(tmp_path) + target = url_import.resolve_target(library, "a", None, "") + assert (target.pid, target.position) == ("a", None) + + def test_smart_and_folder_are_never_targets(self, tmp_path): + library = _library(tmp_path) + assert url_import.resolve_target(library, "s", 0, "library") is None + assert url_import.resolve_target(library, "f", None, + "playlist:s") is None + # ...but a shown smart playlist falls through to a playing regular one. + target = url_import.resolve_target(library, "s", 0, "playlist:b") + assert target.pid == "b" + + def test_nothing_shown_or_playing(self, tmp_path): + library = _library(tmp_path) + assert url_import.resolve_target(library, "", None, "") is None + assert url_import.resolve_target(library, "", None, "library") is None + + def test_a_deleted_playing_playlist_falls_through(self, tmp_path): + library = _library(tmp_path) + target = url_import.resolve_target(library, "a", None, "playlist:gone") + assert target.pid == "a" + + +# -------------------------------------------------------------------------- +# C. the worker, against a fake yt-dlp + + +def _run_worker(url, dest, cancel_on_first=False): + worker = UrlImportWorker(url) + worker.temp_dir = dest + events = [] + worker.item_started.connect(lambda i, n, t: events.append(("start", i, n, t))) + worker.progress.connect(lambda p: events.append(("progress", p))) + + def on_file(path): + events.append(("file", path)) + if cancel_on_first: + worker.cancel() + worker.downloaded.connect(on_file) + worker.finished.connect(lambda d: events.append(("finished", d))) + worker.failed.connect(lambda m: events.append(("failed", m))) + worker._busy = True + worker._run() + return worker, events + + +class TestWorker: + def test_reports_each_song_in_order(self, tmp_path, fake_ytdlp): + dest = tmp_path / "dl" + dest.mkdir() + worker, events = _run_worker("https://x.test/list", dest) + assert [e[0] for e in events] == [ + "start", "progress", "file", "start", "progress", "file", + "finished"] + assert events[0] == ("start", 1, 2, "First Song") + assert events[2] == ("file", str(dest / "First Song [id1].mp3")) + assert events[-1][1] == {"downloaded": 2, "errors": [], + "cancelled": False} + assert not worker.busy() + + def test_nothing_downloaded_is_a_failure_with_yt_dlps_reason( + self, tmp_path, fake_ytdlp, monkeypatch): + monkeypatch.setenv("FAKE_YTDLP_MODE", "fail") + dest = tmp_path / "dl" + dest.mkdir() + _worker, events = _run_worker("https://x.test/nope", dest) + assert events == [("failed", + "[generic] Unsupported URL: https://x.test/nope")] + + def test_cancel_stops_after_the_song_in_hand(self, tmp_path, fake_ytdlp, + monkeypatch): + monkeypatch.setenv("FAKE_YTDLP_MODE", "slow") + dest = tmp_path / "dl" + dest.mkdir() + started = time.monotonic() + _worker, events = _run_worker("https://x.test/list", dest, + cancel_on_first=True) + assert time.monotonic() - started < 10 # didn't sit out the sleep + assert events[-1] == ("finished", {"downloaded": 1, "errors": [], + "cancelled": True}) + + def test_threaded_run_and_temp_dir_lifecycle(self, qapp, fake_ytdlp): + worker = UrlImportWorker("https://x.test/list") + out = {} + worker.finished.connect(lambda d: out.setdefault("finished", d)) + worker.start() + temp = worker.temp_dir + assert temp.name.startswith("lintunes-url-") + _pump(qapp, lambda: "finished" in out) + assert sorted(p.name for p in temp.iterdir()) == [ + "First Song [id1].mp3", "Second Song [id2].mp3"] + worker.cleanup() + assert not temp.exists() + worker.cleanup() # idempotent + + +# -------------------------------------------------------------------------- +# D. the window: import, order, identify + + +@pytest.fixture +def window(qapp, tmp_path, isolated_config, monkeypatch): + from lintunes.gui.main_window import MainWindow + manager = LibraryManager(_library(tmp_path), tmp_path / "data") + win = MainWindow(manager, Preferences(tmp_path / "data")) + queued = [] + monkeypatch.setattr(win, "_enqueue_identify", + lambda tracks: queued.extend(tracks)) + win.identify_queued = queued + yield win + win.close() + + +class TestWindow: + def test_import_files_returns_what_it_imported(self, window, mp3_file): + imported = window.import_files([mp3_file]) + assert len(imported) == 1 and imported[0].location + + def test_a_batch_lands_above_the_selected_song_in_order( + self, window, tmp_path, mp3_file): + manager = window._manager + window._url_target = ImportTarget("a", 2, "") + window._url_inserted = window._url_imported = 0 + window._url_identify = True + for name in ("First Song [id1].mp3", "Second Song [id2].mp3"): + path = tmp_path / name + shutil.copy(mp3_file, path) + window._on_url_downloaded(str(path)) + + ids = manager.library.playlists["a"].track_ids + new = [t.track_id for t in window.identify_queued] + assert len(new) == 2 + assert ids == [1, 99, *new, 2, 3] + assert [manager.library.tracks[t].name for t in new] == [ + "First Song [id1]", "Second Song [id2]"] + + def test_stop_identifying_still_imports(self, window, tmp_path, mp3_file): + window._url_target = None + window._url_identify = False + path = tmp_path / "Song [x].mp3" + shutil.copy(mp3_file, path) + window._on_url_downloaded(str(path)) + assert window._url_imported == 1 + assert window.identify_queued == [] + + def test_end_to_end_through_the_menu(self, window, qapp, tmp_path, + fake_ytdlp, mp3_file, monkeypatch): + from lintunes.gui import main_window as mw + monkeypatch.setenv("FAKE_YTDLP_AUDIO", str(mp3_file)) + + class _Dialog: + def __init__(self, description, parent=None): + assert "T2" in description + def exec(self): + return True + def url(self): + return "https://x.test/list" + def add_to_playlist(self): + return True + monkeypatch.setattr(mw, "UrlImportDialog", _Dialog) + + window._show_playlist("a") + window._playlist_view.reveal_track(2) + window._import_from_url() + worker = window._url_worker + temp = worker.temp_dir + _pump(qapp, lambda: worker.temp_dir is None) + + ids = window._manager.library.playlists["a"].track_ids + new = [t.track_id for t in window.identify_queued] + assert ids == [1, 99, *new, 2, 3] and len(new) == 2 + assert not temp.exists() + assert window._sync_progress.isHidden() + + def test_menu_action_sits_under_add_files(self, window): + texts = [a.text() for a in window.menuBar().actions()[0].menu().actions()] + at = texts.index("Add Files to Library…") + assert texts[at + 1] == "Import from URL…" + + +# -------------------------------------------------------------------------- +# E. no AcoustID key: the filename, and nothing else + + +def test_no_key_proposes_from_the_filename_without_asking_anyone( + qapp, monkeypatch): + calls = [] + monkeypatch.setattr(fingerprint, "fingerprint_file", + lambda *a: calls.append("fpcalc")) + monkeypatch.setattr(fingerprint, "lookup_fingerprint", + lambda *a, **k: calls.append("network")) + track = Track(track_id=7, name="B. Clem - Zuuso [1025657891]", + location="/m/Unknown Artist/Unknown Album/" + "B. Clem - Zuuso [1025657891].mp3") + identifier = fingerprint.TrackIdentifier() + out = {} + identifier.finished.connect(lambda r: out.setdefault("r", r)) + identifier.identify(track, "") + _pump(qapp, lambda: "r" in out) + assert calls == [] + [candidate] = out["r"]["candidates"] + assert candidate.source == "filename" + assert (candidate.artist, candidate.name) == ("B. Clem", "Zuuso") + + +def test_a_journal_in_a_foreign_shape_never_aborts_startup(tmp_path): + """Found verifying this round: Round 48's dev fixture wrapped the journal + as {"machine", "tracks"} and the loader died on int("tracks"), taking + startup with it. A journal it can't read is skipped; a good one beside it + still counts.""" + import json + from lintunes.storage.play_journal import PlayJournal + plays = tmp_path / "plays" + plays.mkdir() + (plays / "wrapped.json").write_text(json.dumps( + {"machine": "wrapped", "tracks": {"1": {"plays": 30}}})) + (plays / "list.json").write_text("[1, 2, 3]") + (plays / "good.json").write_text(json.dumps({"1": {"plays": 2}})) + library = Library() + library.tracks[1] = Track(track_id=1, name="T1", play_count=1) + PlayJournal(machine="me").load(tmp_path, library) + assert library.tracks[1].play_count == 3 # base 1 + good 2 + + +# -------------------------------------------------------------------------- +# F. the album art window + + +class TestFitSize: + def test_square_cover_fills_the_height_and_stays_square(self): + size = fit_size_(QSize(600, 600), QSize(1920, 1080)) + assert size.width() == size.height() == 1080 - 48 + + def test_a_small_cover_is_scaled_up(self): + assert fit_size_(QSize(300, 300), QSize(1920, 1080)) == QSize(1032, 1032) + + def test_a_wide_cover_is_bounded_by_the_width(self): + assert fit_size_(QSize(2000, 500), QSize(1920, 1080)) == QSize(1920, 480) + + def test_an_empty_cover_takes_the_room(self): + assert fit_size_(QSize(0, 0), QSize(800, 600)) == QSize(800, 552) + + +def fit_size_(art, available): + from lintunes.gui.art_window import fit_size + return fit_size(art, available) + + +class _FakePlayer(QObject): + track_changed = pyqtSignal(object) + + +def _release(button, x=5, y=5): + return QMouseEvent(QEvent.Type.MouseButtonRelease, QPointF(x, y), + QPointF(x, y), button, Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier) + + +class TestSidebarArtClick: + def _art(self): + from lintunes.gui.sidebar import SidebarArt + art = SidebarArt(_FakePlayer()) + art.resize(100, 100) + return art + + def test_one_click_opens_the_art_sized_to_the_screen(self, qapp): + art = self._art() + pixmap = QPixmap(64, 64) + pixmap.fill(Qt.GlobalColor.red) + art._pixmap = pixmap + art.mouseReleaseEvent(_release(Qt.MouseButton.LeftButton)) + window = art._art_window + try: + assert window.isVisible() + available = window.screen().availableGeometry().size() + assert window.size() == fit_size_(pixmap.size(), available) + assert window.width() == window.height() + finally: + window.hide() + + def test_no_art_or_right_click_opens_nothing(self, qapp): + art = self._art() + art.mouseReleaseEvent(_release(Qt.MouseButton.LeftButton)) + assert not art._art_window.isVisible() + art._pixmap = QPixmap(10, 10) + art.mouseReleaseEvent(_release(Qt.MouseButton.RightButton)) + assert not art._art_window.isVisible() + + def test_a_release_outside_the_art_is_not_a_click(self, qapp): + art = self._art() + art._pixmap = QPixmap(10, 10) + art.mouseReleaseEvent(_release(Qt.MouseButton.LeftButton, 500, 500)) + assert not art._art_window.isVisible()