v0.18.0: paste a link, get the song

File > Import from URL... runs trav's `song` helper (yt-dlp --extract-audio
--audio-format mp3) from inside the app. Everything at the link downloads to
a private temp dir and is imported as each song lands. "and add to current
playlist?" puts the batch above the selected song, else at the end of the
playing playlist, else at the end of the shown one, in the link's order.
Each song goes straight into Identify Track. With no AcoustID key, the
proposal comes from the filename alone.

Album art now opens on a single click, in a window shaped like the cover
and as big as the screen allows.

Also fixed the dev fixture's play journal, which crashed startup (wrong
shape) and would have been ignored anyway (wrong keys). The loader now
skips a journal it can't read instead of aborting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5m9mXFPHNro78BdD69mG2
This commit is contained in:
2026-09-10 23:44:45 -05:00
co-authored by Claude Opus 5
parent bcba621f3f
commit 8188a5b389
13 changed files with 1179 additions and 33 deletions
+24
View File
@@ -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 undated — so a second MusicBrainz call would return the same wrong year, and
the file's own name is the only place 1961 exists. 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 <tempdir>`, `--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 - **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
so the desktop's media keys / now-playing popup control playback. Spacebar and so the desktop's media keys / now-playing popup control playback. Spacebar and
arrow keys are handled locally via `MainWindow.eventFilter`. arrow keys are handled locally via `MainWindow.eventFilter`.
+19
View File
@@ -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.... - [ ] 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 ## 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: `scripts/make_dev_library.py` + the andTunes resume docs. Left on the table:
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.17.1" __version__ = "0.18.0"
+7
View File
@@ -364,6 +364,13 @@ class TrackIdentifier(QObject):
def work(): def work():
try: 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) duration, fp = fingerprint_file(location)
candidates = lookup_fingerprint(api_key, duration, fp, hint) candidates = lookup_fingerprint(api_key, duration, fp, hint)
except Exception as e: except Exception as e:
+55 -6
View File
@@ -1,7 +1,32 @@
from PyQt6.QtWidgets import QWidget, QApplication from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QPainter, QPixmap 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): class ArtWindow(QWidget):
"""Non-modal window showing the current track's album art as large as """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.setMinimumSize(120, 120)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) 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): def set_artwork(self, pixmap: QPixmap | None, title: str):
self.setWindowTitle(title or "Album Art") 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 self._pixmap = pixmap
if pixmap is not None and not self.isVisible(): if reshape:
screen = QApplication.primaryScreen().availableGeometry() # A track change brought a different shape of cover: re-fit, or
width = min(pixmap.width(), int(screen.width() * 0.8)) # the new art would sit between black bars.
height = min(pixmap.height(), int(screen.height() * 0.8)) self.fit_to_screen()
self.resize(max(width, 240), max(height, 240))
self.update() 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): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.fillRect(self.rect(), Qt.GlobalColor.black) painter.fillRect(self.rect(), Qt.GlobalColor.black)
+200 -14
View File
@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QTimer from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence 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 layout as andtunes_layout
from lintunes.andtunes import sync as andtunes_sync from lintunes.andtunes import sync as andtunes_sync
from lintunes.art_search import AlbumArtFetcher 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.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView from lintunes.gui.playlist_view import PlaylistView
from lintunes.gui.transport import TransportBar from lintunes.gui.transport import TransportBar
from lintunes.gui.url_import_dialog import UrlImportDialog
from lintunes.gui.info_dialog import InfoDialog from lintunes.gui.info_dialog import InfoDialog
from lintunes.gui.music_folder_dialog import MusicFolderDialog from lintunes.gui.music_folder_dialog import MusicFolderDialog
from lintunes.gui.preferences_dialog import PreferencesDialog from lintunes.gui.preferences_dialog import PreferencesDialog
@@ -170,6 +171,17 @@ class MainWindow(QMainWindow):
self._identify_queue = [] self._identify_queue = []
self._identify_total = 0 self._identify_total = 0
self._identifier = None 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 # Separate instance from the playback inhibitor: pausing music
# mid-sync must not drop the sync's hold. Logout flag included so # mid-sync must not drop the sync's hold. Logout flag included so
# GNOME's shutdown/restart dialog names the transfer as the blocker. # GNOME's shutdown/restart dialog names the transfer as the blocker.
@@ -298,9 +310,12 @@ class MainWindow(QMainWindow):
file_menu.addSeparator() file_menu.addSeparator()
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O", self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
self._open_files_dialog) 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( self._export_action = self._add_action(
file_menu, "Export Playlist…", "", self._export_playlist) file_menu, "Export Playlist…", "", self._export_playlist)
file_menu.aboutToShow.connect(self._refresh_export_action) file_menu.aboutToShow.connect(self._refresh_export_action)
file_menu.aboutToShow.connect(self._refresh_url_import_action)
file_menu.addSeparator() file_menu.addSeparator()
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close) self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
@@ -488,12 +503,15 @@ class MainWindow(QMainWindow):
def _busy_worker(self): def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets.""" """The transfer currently owning the status-bar progress widgets."""
for worker in (self._sync_worker, self._andtunes_worker, 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(): if worker is not None and worker.busy():
return worker return worker
return None return None
def _confirm_cancel_sync(self): 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(): if self._export_worker is not None and self._export_worker.busy():
self._confirm_cancel_export() self._confirm_cancel_export()
return return
@@ -1005,8 +1023,7 @@ class MainWindow(QMainWindow):
if paths: if paths:
self.import_files([Path(p) for p in paths]) self.import_files([Path(p) for p in paths])
def import_files(self, paths, playlist_pid: str = "", def _ensure_music_dir(self) -> Path | None:
position: int | None = None):
music_dir = self._music_import_dir() music_dir = self._music_import_dir()
if music_dir is None: if music_dir is None:
# Ask rather than guess — better one question than music copied # Ask rather than guess — better one question than music copied
@@ -1017,6 +1034,12 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage( self.statusBar().showMessage(
"Choose where to keep your music first — Edit ▸ Preferences.", "Choose where to keep your music first — Edit ▸ Preferences.",
8000) 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 [] return []
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…") self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
imported = file_importer.import_paths( imported = file_importer.import_paths(
@@ -1025,10 +1048,136 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000) self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
if imported: if imported:
self._library_view.reload() self._library_view.reload()
return imported
def _import_files_to_playlist(self, paths, pid, position=None): def _import_files_to_playlist(self, paths, pid, position=None):
self.import_files([Path(p) for p in paths], pid, position) 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 ---- # ---- deleting tracks ----
def _delete_tracks(self, track_ids: list[int], delete_files: bool): 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) f"Skipped {skipped} track(s) with no file to fingerprint", 6000)
if not tracks: if not tracks:
return return
self._identify_queue = tracks self._enqueue_identify(tracks)
self._identify_total = len(tracks)
self._identify_next(api_key)
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: if not self._identify_queue:
self._identify_running = False
return return
self._identify_running = True
track = self._identify_queue.pop(0) 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) done = self._identify_total - len(self._identify_queue)
counter = (f" ({done} of {self._identify_total})" counter = (f" ({done} of {self._identify_total})"
if self._identify_total > 1 else "") if self._identify_total > 1 else "")
@@ -1246,10 +1418,14 @@ class MainWindow(QMainWindow):
# source) outlives this method. # source) outlives this method.
self._identifier = TrackIdentifier() self._identifier = TrackIdentifier()
self._identifier.finished.connect( self._identifier.finished.connect(
lambda result, t=track, k=api_key: self._on_identified(t, k, result)) lambda result, t=track: self._on_identified(t, result))
self._identifier.identify(track, api_key) # 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: if "error" in result:
self.statusBar().showMessage( self.statusBar().showMessage(
f"Couldn't identify “{track.name}”: {result['error']}", 6000) f"Couldn't identify “{track.name}”: {result['error']}", 6000)
@@ -1273,9 +1449,13 @@ class MainWindow(QMainWindow):
f"Updated tags for “{track.name}”", 5000) f"Updated tags for “{track.name}”", 5000)
elif dialog.cancel_all: elif dialog.cancel_all:
self._identify_queue.clear() 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() self.statusBar().clearMessage()
return return
self._identify_next(api_key) self._identify_next()
# ---- relocating a moved/renamed file ---- # ---- relocating a moved/renamed file ----
@@ -1418,8 +1598,11 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
# Quitting mid-transfer abandons a partial file on the device (it # Quitting mid-transfer abandons a partial file on the device (it
# self-heals next sync, but silently) — make it a deliberate choice. # self-heals next sync, but silently) — make it a deliberate choice.
if self._busy_worker() is not None and self._busy_worker() is not ( # A link import has nothing half-written on a device to warn about:
self._export_worker): # 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 = QMessageBox(self)
box.setWindowTitle("Sync in progress") box.setWindowTitle("Sync in progress")
box.setText( box.setText(
@@ -1435,6 +1618,9 @@ class MainWindow(QMainWindow):
if box.clickedButton() is not quit_button: if box.clickedButton() is not quit_button:
event.ignore() event.ignore()
return return
if self._url_worker is not None:
self._url_worker.cancel()
self._url_worker.cleanup()
self._inhibitor.release() self._inhibitor.release()
self._sync_inhibitor.release() self._sync_inhibitor.release()
# Before the player: this stops the device and kills the HTTP server it # Before the player: this stops the device and kills the HTTP server it
+11 -7
View File
@@ -88,7 +88,7 @@ class SidebarPanel(QWidget):
class SidebarArt(QWidget): class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the """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): def __init__(self, player, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -96,7 +96,7 @@ class SidebarArt(QWidget):
self._pixmap: QPixmap | None = None self._pixmap: QPixmap | None = None
self._track = None self._track = None
self._art_window = ArtWindow() 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) player.track_changed.connect(self.set_track)
def set_track(self, track): def set_track(self, track):
@@ -128,11 +128,15 @@ class SidebarArt(QWidget):
self.setFixedHeight(self.width()) self.setFixedHeight(self.width())
super().resizeEvent(event) super().resizeEvent(event)
def mouseDoubleClickEvent(self, event): def mouseReleaseEvent(self, event):
if self._pixmap is not None: # A click is a release that lands on the art. Opening on press would
self._art_window.set_artwork(self._pixmap, self._title()) # also fire when a drag merely starts here.
self._art_window.show() if (event.button() == Qt.MouseButton.LeftButton
self._art_window.raise_() 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): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
+63
View File
@@ -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()
+6 -1
View File
@@ -108,8 +108,13 @@ class PlayJournal:
entries = read_json(path) entries = read_json(path)
except (ValueError, OSError): except (ValueError, OSError):
continue # a half-synced journal must never abort startup 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() 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: if path.stem == self.machine:
self._own = entries self._own = entries
else: else:
+271
View File
@@ -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})
+6 -4
View File
@@ -330,12 +330,14 @@ def _write_plays(data_dir: Path, ids: list):
"""One machine's play journal, in the per-machine-totals shape.""" """One machine's play journal, in the per-machine-totals shape."""
plays = data_dir / "plays" plays = data_dir / "plays"
plays.mkdir(parents=True, exist_ok=True) plays.mkdir(parents=True, exist_ok=True)
entries = {str(tid): {"play_count": random.randint(1, 12), entries = {str(tid): {"plays": random.randint(1, 12),
"play_date_utc": _iso(random.randint(0, 200))} "last_played": _iso(random.randint(0, 200))}
for tid in random.sample(ids, k=min(8, len(ids)))} 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( (plays / "devbox.json").write_text(
json.dumps({"machine": "devbox", "tracks": entries}, indent=2), json.dumps(entries, indent=2), encoding="utf-8")
encoding="utf-8")
def _write_prefs(data_dir: Path, library: Library): def _write_prefs(data_dir: Path, library: Library):
+42
View File
@@ -1,5 +1,47 @@
## Done ## 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 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 Round 47 was verified by loading trav's real 21,531-track library. Nothing was
+474
View File
@@ -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()