feat(import): playlist files, JSON, ListenBrainz and Last.fm as import sources

Implements qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md,
whose §11 the owner ratified 2026-07-31. PlaylistSource is an enum, not a trait
(ADR-007): the set is closed, there is no plugin story, and every arm resolves
to the same ImportPlaylist the URL scrapers already produce — so the matcher,
the 2000-track split, the sink and the summary never learn a second kind of
source exists.

import_prepared_playlist takes the playlist BY VALUE and
import_public_playlist becomes a shim. That kills the double scrape the Tauri
re-fetch-on-execute left behind, and it is not optional for the new sources:
the rfd bytes of a picked file are gone by execute time and the dialog cannot
silently reopen. It also closes a real race — open() resets the session, so a
modal reopened between the Import click and the task's read handed a detached
task None.

FILES read the track list ONLY. The paths inside are never opened, copied or
added to the Local Library, and the modal says so in a persistent row rather
than a tooltip. Content sniffing beats the extension, with an HLS guard because
.m3u8 is also the stream-manifest extension, and a utf8-then-Windows-1252
fallback because .m3u and .pls predate UTF-8 and "Björk" has to stay matchable.

JSON locates the track list BY SHAPE. Depth is a DoS bound, not a locator: the
array sits at 0, 1, 2 or 3 levels depending on the export, and a hard two-level
rule drops an ordinary GraphQL envelope. Container demotion keeps a discography
from importing album names as track titles. Walls at 16 MiB, depth 8, 2M nodes
and 10k tracks.

SERVICES are public reads with no authentication; a connected account only
prefills the field. No qbz-integrations edge — its ListenBrainz client returns
tracks with no duration and drops the playlist title, both verified in its
source, and a direct GET /1/playlist/<mbid> reads all three in one request.

A Last.fm STATION IS A RADIO, and ?page=N is not pagination. Measured against a
live profile: pages 1 and 2 overlapped and batch sizes varied (20, 20, 36). The
first cut fetched 20 pages, never hit its empty-page exit, and returned 469 rows
holding 198 distinct tracks. It now dedupes as it draws, stops after two dry
draws, and stops at MAX_STATION_TRACKS = 200 — the number shown in "Found N
tracks" has to be one we can deliver, and a station is dynamic enough that a
bigger single import buys little (owner ruling: no false expectations of
"traeré casi 500" when we bring half). A failed draw no longer fails an import
that already has rows; Last.fm rate-limits a burst of these.

The summary reports THREE DISJOINT NUMBERS that sum to the total. skipped is
now counted from the matches that found nothing, not inferred as
total - matched, which folded duplicates into failures: that same import told
the user 271 tracks were skipped when 255 of them had matched perfectly well
and been deduplicated before the add.

23 new strings across the seven translated locales; en is the sparse fallback
catalogue and needs none.

cc
This commit is contained in:
vicrodh committed 2026-08-19 23:55:56 -06:00
1 parent c11bdf0654
commit 7e233104f5
27 files changed
+4212 -34

No files matched your search

+2
View File
@@ -6574,11 +6574,13 @@ dependencies = [
name = "qbz-playlist-import"
version = "0.1.0"
dependencies = [
"encoding_rs",
"futures-util",
"log",
"qbz-models",
"qbz-qobuz",
"reqwest",
"roxmltree 0.20.0",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -5298,3 +5298,77 @@ msgstr "Ein Klick auf eine Sektion in der Seitenleiste oder Titelleiste öffnet
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "Standard ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "Streaming-URL"
msgid "Playlist file"
msgstr "Playlist-Datei"
msgid "JSON file"
msgstr "JSON-Datei"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "Datei wählen…"
msgid "No file selected"
msgstr "Keine Datei ausgewählt"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "Es wird nur die Titelliste gelesen — die referenzierten Audiodateien werden nie geöffnet, kopiert oder zur lokalen Bibliothek hinzugefügt. Jeder Eintrag wird im Qobuz-Katalog gesucht."
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "JSON wird nach bestem Bemühen gelesen — prüfe die Titelanzahl vor dem Import."
msgid "Could not read the file."
msgstr "Datei konnte nicht gelesen werden."
msgid "No tracks found in the file."
msgstr "Keine Titel in der Datei gefunden."
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "Dateiformat nicht erkannt — nutze XSPF, PLS, M3U/M3U8 oder JSON."
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "Das sieht nach einem Livestream-Manifest aus, nicht nach einer Playlist."
msgid "That file is too large to import."
msgstr "Diese Datei ist zu groß für den Import."
msgid "Could not recognize a track list in this JSON."
msgstr "In diesem JSON wurde keine Titelliste erkannt."
msgid "Username or profile URL"
msgstr "Benutzername oder Profil-URL"
msgid "Username or playlist URL"
msgstr "Benutzername oder Playlist-URL"
msgid "Play your library"
msgstr "Deine Bibliothek abspielen"
msgid "Play your mix"
msgstr "Deinen Mix abspielen"
msgid "Play your recommendations"
msgstr "Deine Empfehlungen abspielen"
msgid "Station"
msgstr "Sender"
msgid "No playlists found for {}."
msgstr "Keine Playlists für {} gefunden."
msgid "Enter a public username."
msgstr "Gib einen öffentlichen Benutzernamen ein."
msgid "Duplicates: {}"
msgstr "Duplikate: {}"
@@ -5304,3 +5304,77 @@ msgstr "Al hacer clic en una sección de la barra lateral o del título, se abre
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "Predeterminado ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "URL de streaming"
msgid "Playlist file"
msgstr "Archivo de lista"
msgid "JSON file"
msgstr "Archivo JSON"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "Elegir archivo…"
msgid "No file selected"
msgstr "Ningún archivo seleccionado"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "Solo se lee la lista de pistas: los archivos de audio referenciados nunca se abren, se copian ni se agregan a tu biblioteca local. Cada entrada se busca en el catálogo de Qobuz."
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "El JSON se lee lo mejor posible: revisa el número de pistas antes de importar."
msgid "Could not read the file."
msgstr "No se pudo leer el archivo."
msgid "No tracks found in the file."
msgstr "No se encontraron pistas en el archivo."
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "Formato de archivo no reconocido: usa XSPF, PLS, M3U/M3U8 o JSON."
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "Esto parece un manifiesto de transmisión en vivo, no una lista."
msgid "That file is too large to import."
msgstr "Ese archivo es demasiado grande para importarlo."
msgid "Could not recognize a track list in this JSON."
msgstr "No se pudo reconocer una lista de pistas en este JSON."
msgid "Username or profile URL"
msgstr "Usuario o URL de perfil"
msgid "Username or playlist URL"
msgstr "Usuario o URL de la lista"
msgid "Play your library"
msgstr "Reproducir tu biblioteca"
msgid "Play your mix"
msgstr "Reproducir tu mezcla"
msgid "Play your recommendations"
msgstr "Reproducir tus recomendaciones"
msgid "Station"
msgstr "Estación"
msgid "No playlists found for {}."
msgstr "No se encontraron listas para {}."
msgid "Enter a public username."
msgstr "Escribe un usuario público."
msgid "Duplicates: {}"
msgstr "Duplicados: {}"
@@ -5296,3 +5296,77 @@ msgstr "Cliquer sur une section dans la barre latérale ou la barre de titre l'o
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "Par défaut ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "URL de streaming"
msgid "Playlist file"
msgstr "Fichier de playlist"
msgid "JSON file"
msgstr "Fichier JSON"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "Choisir un fichier…"
msgid "No file selected"
msgstr "Aucun fichier sélectionné"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "Seule la liste des titres est lue — les fichiers audio référencés ne sont jamais ouverts, copiés ni ajoutés à votre bibliothèque locale. Chaque entrée est recherchée dans le catalogue Qobuz."
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "Le JSON est lu au mieux — vérifiez le nombre de titres avant d'importer."
msgid "Could not read the file."
msgstr "Impossible de lire le fichier."
msgid "No tracks found in the file."
msgstr "Aucun titre trouvé dans le fichier."
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "Format de fichier non reconnu — utilisez XSPF, PLS, M3U/M3U8 ou JSON."
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "Cela ressemble à un manifeste de flux en direct, pas à une playlist."
msgid "That file is too large to import."
msgstr "Ce fichier est trop volumineux pour être importé."
msgid "Could not recognize a track list in this JSON."
msgstr "Impossible de reconnaître une liste de titres dans ce JSON."
msgid "Username or profile URL"
msgstr "Nom d'utilisateur ou URL de profil"
msgid "Username or playlist URL"
msgstr "Nom d'utilisateur ou URL de playlist"
msgid "Play your library"
msgstr "Lire votre bibliothèque"
msgid "Play your mix"
msgstr "Lire votre mix"
msgid "Play your recommendations"
msgstr "Lire vos recommandations"
msgid "Station"
msgstr "Station"
msgid "No playlists found for {}."
msgstr "Aucune playlist trouvée pour {}."
msgid "Enter a public username."
msgstr "Saisissez un nom d'utilisateur public."
msgid "Duplicates: {}"
msgstr "Doublons : {}"
@@ -5281,3 +5281,77 @@ msgstr "サイドバーやタイトルバーのセクションをクリックす
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "デフォルト ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "ストリーミング URL"
msgid "Playlist file"
msgstr "プレイリストファイル"
msgid "JSON file"
msgstr "JSON ファイル"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "ファイルを選択…"
msgid "No file selected"
msgstr "ファイルが選択されていません"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "読み込むのはトラック一覧だけです。参照されている音声ファイルを開いたり、コピーしたり、ローカルライブラリに追加することはありません。各項目は Qobuz のカタログと照合されます。"
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "JSON はベストエフォートで読み込まれます。インポート前にトラック数を確認してください。"
msgid "Could not read the file."
msgstr "ファイルを読み込めませんでした。"
msgid "No tracks found in the file."
msgstr "ファイルにトラックが見つかりませんでした。"
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "ファイル形式を認識できません。XSPF、PLS、M3U/M3U8、JSON を使用してください。"
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "これはプレイリストではなくライブ配信のマニフェストのようです。"
msgid "That file is too large to import."
msgstr "このファイルは大きすぎてインポートできません。"
msgid "Could not recognize a track list in this JSON."
msgstr "この JSON からトラック一覧を認識できませんでした。"
msgid "Username or profile URL"
msgstr "ユーザー名またはプロフィール URL"
msgid "Username or playlist URL"
msgstr "ユーザー名またはプレイリスト URL"
msgid "Play your library"
msgstr "ライブラリを再生"
msgid "Play your mix"
msgstr "ミックスを再生"
msgid "Play your recommendations"
msgstr "おすすめを再生"
msgid "Station"
msgstr "ステーション"
msgid "No playlists found for {}."
msgstr "{} のプレイリストが見つかりませんでした。"
msgid "Enter a public username."
msgstr "公開ユーザー名を入力してください。"
msgid "Duplicates: {}"
msgstr "重複: {}"
@@ -5286,3 +5286,77 @@ msgstr "Klikken op een sectie in de zijbalk of titelbalk opent deze op het eerst
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "Standaard ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "Streaming-URL"
msgid "Playlist file"
msgstr "Afspeellijstbestand"
msgid "JSON file"
msgstr "JSON-bestand"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "Bestand kiezen…"
msgid "No file selected"
msgstr "Geen bestand geselecteerd"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "Alleen de tracklijst wordt gelezen — de audiobestanden waarnaar wordt verwezen worden nooit geopend, gekopieerd of aan je lokale bibliotheek toegevoegd. Elk item wordt in de Qobuz-catalogus opgezocht."
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "JSON wordt naar beste kunnen gelezen — controleer het aantal tracks voordat je importeert."
msgid "Could not read the file."
msgstr "Kon het bestand niet lezen."
msgid "No tracks found in the file."
msgstr "Geen tracks gevonden in het bestand."
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "Bestandsformaat niet herkend — gebruik XSPF, PLS, M3U/M3U8 of JSON."
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "Dit lijkt een livestream-manifest te zijn, geen afspeellijst."
msgid "That file is too large to import."
msgstr "Dat bestand is te groot om te importeren."
msgid "Could not recognize a track list in this JSON."
msgstr "Kon in deze JSON geen tracklijst herkennen."
msgid "Username or profile URL"
msgstr "Gebruikersnaam of profiel-URL"
msgid "Username or playlist URL"
msgstr "Gebruikersnaam of afspeellijst-URL"
msgid "Play your library"
msgstr "Je bibliotheek afspelen"
msgid "Play your mix"
msgstr "Je mix afspelen"
msgid "Play your recommendations"
msgstr "Je aanbevelingen afspelen"
msgid "Station"
msgstr "Station"
msgid "No playlists found for {}."
msgstr "Geen afspeellijsten gevonden voor {}."
msgid "Enter a public username."
msgstr "Voer een openbare gebruikersnaam in."
msgid "Duplicates: {}"
msgstr "Duplicaten: {}"
@@ -5296,3 +5296,77 @@ msgstr "Clicar em uma seção na barra lateral ou na barra de título abre-a na
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "Padrão ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "URL de streaming"
msgid "Playlist file"
msgstr "Ficheiro de playlist"
msgid "JSON file"
msgstr "Ficheiro JSON"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "Escolher ficheiro…"
msgid "No file selected"
msgstr "Nenhum ficheiro selecionado"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "Apenas a lista de faixas é lida — os ficheiros de áudio referenciados nunca são abertos, copiados nem adicionados à sua biblioteca local. Cada entrada é procurada no catálogo Qobuz."
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "O JSON é lido da melhor forma possível — verifique o número de faixas antes de importar."
msgid "Could not read the file."
msgstr "Não foi possível ler o ficheiro."
msgid "No tracks found in the file."
msgstr "Não foram encontradas faixas no ficheiro."
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "Formato de ficheiro não reconhecido — use XSPF, PLS, M3U/M3U8 ou JSON."
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "Isto parece um manifesto de transmissão em direto, não uma playlist."
msgid "That file is too large to import."
msgstr "Esse ficheiro é demasiado grande para importar."
msgid "Could not recognize a track list in this JSON."
msgstr "Não foi possível reconhecer uma lista de faixas neste JSON."
msgid "Username or profile URL"
msgstr "Utilizador ou URL de perfil"
msgid "Username or playlist URL"
msgstr "Utilizador ou URL da playlist"
msgid "Play your library"
msgstr "Reproduzir a sua biblioteca"
msgid "Play your mix"
msgstr "Reproduzir a sua mistura"
msgid "Play your recommendations"
msgstr "Reproduzir as suas recomendações"
msgid "Station"
msgstr "Estação"
msgid "No playlists found for {}."
msgstr "Não foram encontradas playlists para {}."
msgid "Enter a public username."
msgstr "Introduza um utilizador público."
msgid "Duplicates: {}"
msgstr "Duplicados: {}"
@@ -5315,3 +5315,77 @@ msgstr "Клик по разделу в боковой панели или за
# Discover configurator: per-rail default item count (2026-08-19)
msgid "Default ({})"
msgstr "По умолчанию ({})"
# Playlist importer expansion (2.0.3): file / JSON / ListenBrainz / Last.fm sources
msgid "Streaming URL"
msgstr "URL стриминга"
msgid "Playlist file"
msgstr "Файл плейлиста"
msgid "JSON file"
msgstr "Файл JSON"
msgid "ListenBrainz"
msgstr "ListenBrainz"
msgid "Last.fm"
msgstr "Last.fm"
msgid "Choose file…"
msgstr "Выбрать файл…"
msgid "No file selected"
msgstr "Файл не выбран"
msgid "Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog."
msgstr "Читается только список треков — указанные аудиофайлы никогда не открываются, не копируются и не добавляются в локальную библиотеку. Каждая запись сопоставляется с каталогом Qobuz."
msgid "JSON is read best-effort — check the track count below before importing."
msgstr "JSON читается по мере возможности — проверьте количество треков перед импортом."
msgid "Could not read the file."
msgstr "Не удалось прочитать файл."
msgid "No tracks found in the file."
msgstr "В файле не найдено треков."
msgid "Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON."
msgstr "Формат файла не распознан — используйте XSPF, PLS, M3U/M3U8 или JSON."
msgid "This looks like a live-stream manifest, not a playlist."
msgstr "Похоже, это манифест прямой трансляции, а не плейлист."
msgid "That file is too large to import."
msgstr "Этот файл слишком велик для импорта."
msgid "Could not recognize a track list in this JSON."
msgstr "Не удалось распознать список треков в этом JSON."
msgid "Username or profile URL"
msgstr "Имя пользователя или URL профиля"
msgid "Username or playlist URL"
msgstr "Имя пользователя или URL плейлиста"
msgid "Play your library"
msgstr "Слушать вашу библиотеку"
msgid "Play your mix"
msgstr "Слушать ваш микс"
msgid "Play your recommendations"
msgstr "Слушать ваши рекомендации"
msgid "Station"
msgstr "Станция"
msgid "No playlists found for {}."
msgstr "Плейлисты для {} не найдены."
msgid "Enter a public username."
msgstr "Введите публичное имя пользователя."
msgid "Duplicates: {}"
msgstr "Дубликаты: {}"
+11
View File
@@ -23,6 +23,17 @@ thiserror = { workspace = true }
# Logging
log = { workspace = true }
# Playlist-FILE sources (2.0.3 expansion). Both were already resolved in the
# workspace lock, so neither adds a download or a new compilation unit to the
# tree — only an edge from this crate.
# roxmltree : XSPF. A read-only DOM walked by local element name is ~30 lines;
# quick-xml's event state machine only pays off for streaming,
# which the 16 MiB byte wall forecloses.
# encoding_rs: the utf8-then-Windows-1252 fallback for legacy .m3u / .pls,
# which is what keeps "Björk" and "Sigur Rós" matchable.
roxmltree = "0.20"
encoding_rs = "0.8"
# Async runtime + concurrency
tokio = { workspace = true }
futures-util = { workspace = true }
+25
View File
@@ -17,4 +17,29 @@ pub enum PlaylistImportError {
Parse(String),
#[error("Qobuz error: {0}")]
Qobuz(String),
// --- The 2.0.3 expansion's semantic variants (design §11.9) ------------
//
// These exist so the CONTROLLER can `match` and localize. The alternative
// the design weighed and rejected was reusing `Parse(String)` with a
// prefix and matching on the prefix — fragile in exactly the way a user
// never sees until their language is not English.
/// The bytes are not any playlist format this build reads.
#[error("Unrecognized playlist format")]
UnrecognizedFormat,
/// An `.m3u8` that is an HLS STREAM manifest, not a playlist. Its own
/// variant because the user's mistake is specific and so is the fix.
#[error("HLS stream manifest, not a playlist")]
HlsManifest,
/// Parsed fine and yielded nothing usable.
#[error("Playlist has no tracks")]
EmptyPlaylist,
/// Over `MAX_IMPORT_BYTES`. Refused BEFORE the parse, and on the app side
/// before the READ (design §7.3) — a 2 GB file must never reach RAM.
#[error("File too large to import")]
FileTooLarge,
/// Valid JSON with no track-shaped array in it. Fails loud rather than
/// half-importing whatever array it found first.
#[error("No track list recognized in this JSON")]
JsonShapeUnrecognized,
}
+104 -1
View File
@@ -18,6 +18,9 @@ pub async fn preview_public_playlist(url: &str) -> Result<ImportPlaylist, Playli
fetch_playlist(provider).await
}
/// COMPAT SHIM — the pre-expansion signature, byte-for-byte. Every external
/// and test caller keeps working; it is now just "resolve the URL, then import
/// what came back".
pub async fn import_public_playlist(
url: &str,
client: &QobuzClient,
@@ -26,7 +29,30 @@ pub async fn import_public_playlist(
progress: Arc<dyn ImportProgressSink>,
) -> Result<ImportSummary, PlaylistImportError> {
let playlist = preview_public_playlist(url).await?;
import_prepared_playlist(playlist, client, name_override, is_public, progress).await
}
/// The IMPORT half, SOURCE-AGNOSTIC: match against the Qobuz catalog, create
/// the playlist (splitting past 2000 tracks) and add the matches.
///
/// It takes an already-resolved [`ImportPlaylist`] rather than a URL, which is
/// what lets a file, a JSON blob or a service feed the same pipeline. The body
/// below is the pre-expansion `import_public_playlist` unchanged from the
/// matching phase down.
///
/// TAKING THE PLAYLIST BY VALUE IS THE POINT. The Tauri original re-fetched
/// from the URL at execute time, and this port inherited that: it scraped the
/// same page twice per import. Worse, the pattern cannot express the new
/// sources at all — the `rfd` bytes of a picked file are gone by then and the
/// dialog cannot silently reopen. The caller snapshots the previewed playlist
/// and hands it over; the double-scrape dies with it.
pub async fn import_prepared_playlist(
playlist: ImportPlaylist,
client: &QobuzClient,
name_override: Option<&str>,
is_public: bool,
progress: Arc<dyn ImportProgressSink>,
) -> Result<ImportSummary, PlaylistImportError> {
// Phase: matching
progress.emit(ImportEvent::Phase(ImportPhase::Matching));
let matches = match_tracks(client, &playlist.tracks, Arc::clone(&progress)).await?;
@@ -43,7 +69,21 @@ pub async fn import_public_playlist(
let matched_count = matched_track_ids.len() as u32;
let total_tracks = playlist.tracks.len() as u32;
let skipped_tracks = total_tracks.saturating_sub(matched_count);
// THREE DISJOINT NUMBERS, and they sum to `total_tracks`.
//
// `skipped` is counted from the matches themselves — rows that found
// nothing — instead of being inferred as `total - matched`. The inferred
// form folded duplicates into failures, and on a source that repeats
// (a Last.fm radio draw, a JSON export with the same track twice) that made
// the summary actively wrong: 469 rows, 453 matches, 198 distinct, and the
// user was told 271 tracks had been skipped.
let skipped_tracks = matches
.iter()
.filter(|m| m.qobuz_track_id.is_none())
.count() as u32;
let duplicate_tracks = total_tracks
.saturating_sub(matched_count)
.saturating_sub(skipped_tracks);
let mut qobuz_playlist_ids = Vec::new();
@@ -129,8 +169,71 @@ pub async fn import_public_playlist(
total_tracks,
matched_tracks: matched_count,
skipped_tracks,
duplicate_tracks,
qobuz_playlist_ids,
parts_created,
matches,
})
}
#[cfg(test)]
mod tests {
use crate::models::{ImportTrack, TrackMatch};
fn m(id: Option<u64>) -> TrackMatch {
TrackMatch {
source: ImportTrack {
title: "t".into(),
artist: "a".into(),
album: None,
duration_ms: None,
isrc: None,
provider_id: None,
provider_url: None,
},
qobuz_track_id: id,
qobuz_title: None,
qobuz_artist: None,
score: 1.0,
}
}
/// The arithmetic `import_prepared_playlist` performs on its match list,
/// isolated from the network. THE INVARIANT: matched + skipped + duplicates
/// == total, and each means exactly one thing.
///
/// The shape is the real Last.fm radio import that exposed the bug — many
/// rows, most matching, heavy repetition — scaled down.
#[test]
fn the_three_counts_are_disjoint_and_sum_to_the_total() {
// 6 rows: ids 1,1,2,2,2 and one miss.
let matches = vec![
m(Some(1)),
m(Some(1)),
m(Some(2)),
m(Some(2)),
m(Some(2)),
m(None),
];
let total = matches.len() as u32;
let mut seen = std::collections::HashSet::new();
let unique = matches
.iter()
.filter_map(|e| e.qobuz_track_id)
.filter(|id| seen.insert(*id))
.count() as u32;
let skipped = matches
.iter()
.filter(|e| e.qobuz_track_id.is_none())
.count() as u32;
let duplicates = total.saturating_sub(unique).saturating_sub(skipped);
assert_eq!(unique, 2, "two distinct tracks reach the playlist");
assert_eq!(skipped, 1, "one row matched nothing");
assert_eq!(duplicates, 3, "three rows repeated an already-added track");
assert_eq!(unique + skipped + duplicates, total);
// The OLD formula would have called all four non-unique rows skipped.
assert_ne!(total - unique, skipped);
}
}
+22 -2
View File
@@ -20,7 +20,22 @@
//! - Tidal: fetches a fresh proxy token per playlist fetch (no caching/expiry
//! handling). TODO: cache the token until expiry.
//! - Scrapers send no browser User-Agent (reqwest default) — TODO if any
//! provider starts gating on UA.
//! provider starts gating on UA. (The Last.fm source in `sources::service`
//! sets one PER REQUEST, because its CDN 403s an empty UA; the shared client
//! is deliberately left as it was.)
//!
//! # Sources beyond streaming URLs (2.0.3)
//!
//! [`PlaylistSource`] widens the front door to playlist FILES (XSPF / PLS /
//! M3U / M3U8), best-effort JSON, and two public-read services (ListenBrainz,
//! Last.fm). Every one of them resolves to the same [`models::ImportPlaylist`],
//! so nothing downstream — the Qobuz matcher, the 2000-track split, the
//! progress sink, the summary — learns that a second kind of source exists.
//! Design: `qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md`.
//!
//! FILES ARE READ FOR THEIR TRACK LIST ONLY. The paths inside a playlist file
//! are never opened, copied, or added to the Local Library; each entry is
//! matched against the Qobuz catalog like any other import.
pub mod errors;
pub mod importer;
@@ -28,11 +43,16 @@ pub mod match_qobuz;
pub mod models;
pub mod providers;
pub mod sink;
pub mod sources;
mod http;
pub use errors::PlaylistImportError;
pub use importer::{import_public_playlist, preview_public_playlist};
pub use importer::{import_prepared_playlist, import_public_playlist, preview_public_playlist};
// The 2.0.3 expansion: three new source classes, all resolving to the same
// `ImportPlaylist` the URL scrapers already produce, so the match -> create ->
// add pipeline behind `import_prepared_playlist` is untouched.
pub use sources::{FileFormat, LastFmStation, PlaylistSource, MAX_IMPORT_BYTES};
// The Qobuz matcher's public surface. `match_tracks` stays the importer's own
// entry point; `rank_candidates` is the ranked-list form the "find available
// version" replacement flow needs (match_qobuz.rs).
+38 -1
View File
@@ -2,21 +2,46 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// Where a playlist came FROM, as a CLASS — never a format.
///
/// The four streaming variants are the original set. The four added by the
/// 2.0.3 expansion are per-class on purpose (design §4.3): `provider_display_name`
/// returns `&'static str` and cannot hold an owned string, so the IDENTITY of a
/// source — the filename, the ListenBrainz playlist title, the Last.fm
/// username — belongs in `ImportPlaylist.name` / `.provider_id`, which are
/// free-form. "Imported from Playlist file" plus the filename in `.name` is the
/// right split; `M3u`/`Pls`/`Xspf` variants would bloat two exhaustive matches
/// for no UX gain.
///
/// JSON stays separate from File because the user is told a different thing
/// about it — it is read best-effort and the track count is the gate.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ImportProvider {
Spotify,
AppleMusic,
Tidal,
Deezer,
/// XSPF / PLS / M3U / M3U8.
File,
/// Best-effort JSON.
Json,
ListenBrainz,
LastFm,
}
impl ImportProvider {
/// The wire/log form. `importer.rs`'s `format!("Imported from {}", …)`
/// default description reads this, so a new variant needs nothing there.
pub fn as_str(&self) -> &'static str {
match self {
ImportProvider::Spotify => "spotify",
ImportProvider::AppleMusic => "apple_music",
ImportProvider::Tidal => "tidal",
ImportProvider::Deezer => "deezer",
ImportProvider::File => "file",
ImportProvider::Json => "json",
ImportProvider::ListenBrainz => "listenbrainz",
ImportProvider::LastFm => "lastfm",
}
}
}
@@ -55,8 +80,20 @@ pub struct ImportSummary {
pub provider: ImportProvider,
pub playlist_name: String,
pub total_tracks: u32,
/// DISTINCT Qobuz tracks actually added — the playlist's real length.
pub matched_tracks: u32,
/// Source rows that found no Qobuz track at all.
///
/// It used to be `total - matched`, which silently counted DUPLICATES as
/// skipped: a source row that matched a track another row had already
/// matched is deduplicated before the add, so it is neither in
/// `matched_tracks` nor a failure. One real Last.fm import reported
/// "Skipped: 271" when 255 of those had matched perfectly well and only 16
/// had missed. Now the three numbers are disjoint and sum to
/// `total_tracks`.
pub skipped_tracks: u32,
/// Source rows that matched a track ALREADY contributed by an earlier row.
pub duplicate_tracks: u32,
pub qobuz_playlist_ids: Vec<u64>,
pub parts_created: u32,
pub matches: Vec<TrackMatch>,
@@ -0,0 +1,243 @@
//! Bytes -> text, and the two string chores every text format shares.
use crate::models::ImportTrack;
/// Strip a UTF-8 / UTF-16 BOM.
pub(crate) fn strip_bom(bytes: &[u8]) -> &[u8] {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
&bytes[3..]
} else if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
&bytes[2..]
} else {
bytes
}
}
/// Decode a legacy playlist body.
///
/// Strict UTF-8 first. On failure, WINDOWS-1252 — not because it is likely to
/// be right in the abstract, but because `.m3u` and `.pls` predate UTF-8 by a
/// decade and every Windows player that ever wrote one wrote 1252. Without the
/// fallback "Björk" and "Sigur Rós" arrive as replacement characters and stop
/// matching, which is a silent quality loss, not a crash.
///
/// UTF-16 is deliberately NOT attempted: the BOM is stripped above and a
/// UTF-16 body without one is indistinguishable from binary. Such a file falls
/// out at format detection as unrecognized, which is the honest answer.
pub(crate) fn decode_text(bytes: &[u8]) -> String {
let body = strip_bom(bytes);
match std::str::from_utf8(body) {
Ok(s) => s.to_string(),
Err(_) => {
let (cow, _, _) = encoding_rs::WINDOWS_1252.decode(body);
cow.into_owned()
}
}
}
/// Split lines on `\n`, `\r\n` AND a lone `\r` (classic Mac exports), trimming
/// each. `str::lines` does not handle the lone `\r`.
pub(crate) fn split_lines(text: &str) -> Vec<&str> {
text.split(['\n', '\r'])
.map(str::trim)
.filter(|l| !l.is_empty())
.collect()
}
/// The `#EXTINF` / `TitleN` "Artist - Title" convention.
///
/// LOSSY, AND THE LOSS IS WORTH NAMING: split on the FIRST " - ", so
/// "Bob Dylan - Ballad of a Thin Man" is right and "Godspeed You! Black
/// Emperor - Dead Flag Blues - Part 2" keeps the tail in the title, which is
/// the better of the two mistakes available. A row with no " - " becomes
/// title-only with an empty artist — and the matcher tops out at 0.6 for a
/// title alone, below its 0.65 threshold, so such a row almost always lands in
/// `skipped_tracks`. That is the honest outcome of a format that never carried
/// the artist separately; it is reported, not hidden.
pub(crate) fn split_artist_title(raw: &str) -> (String, String) {
let raw = raw.trim();
match raw.split_once(" - ") {
Some((artist, title)) => (artist.trim().to_string(), title.trim().to_string()),
None => (String::new(), raw.to_string()),
}
}
/// The filename without its directory or extension — the last-resort title,
/// and the playlist name when the format carries none.
pub(crate) fn file_stem(path: &str) -> String {
let name = path
.rsplit(['/', '\\'])
.next()
.unwrap_or(path);
match name.rsplit_once('.') {
// A leading dot is not an extension separator (".hidden").
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
_ => name.to_string(),
}
}
/// A URL-decoded, directory-stripped stem for a `<location>` / file path.
pub(crate) fn location_stem(location: &str) -> String {
let cleaned = location
.trim()
.trim_start_matches("file://")
.split(['?', '#'])
.next()
.unwrap_or(location);
file_stem(&percent_decode(cleaned))
}
/// Minimal percent-decoding. The crate has no `percent-encoding` dependency and
/// this is the only place that needs it: XSPF `<location>` is a URI, so
/// "Sigur%20R%C3%B3s.flac" has to come back as text before the stem is taken.
/// Invalid escapes are left verbatim rather than dropped.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// A track with a derived title, or `None` when even the fallbacks are empty.
/// The ONE skip rule shared by the three file parsers.
pub(crate) fn track_or_skip(
title: String,
artist: String,
album: Option<String>,
duration_ms: Option<u64>,
isrc: Option<String>,
) -> Option<ImportTrack> {
let title = title.trim().to_string();
if title.is_empty() {
return None;
}
Some(ImportTrack {
title,
artist: artist.trim().to_string(),
album: album.map(|a| a.trim().to_string()).filter(|a| !a.is_empty()),
duration_ms,
isrc,
provider_id: None,
provider_url: None,
})
}
/// Uppercase + strip every non-alphanumeric, then accept only the 12-character
/// ISRC shape.
///
/// NORMALIZATION IS REQUIRED, not cosmetic: `match_qobuz.rs` compares with
/// `eq_ignore_ascii_case` and NO hyphen stripping, while Qobuz stores the bare
/// 12-character form. A tagged "US-KO1-16-00123" would therefore never fire the
/// score-1.0 fast path — the single highest-value match this whole pipeline has.
pub(crate) fn normalize_isrc(raw: &str) -> Option<String> {
let cleaned: String = raw
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect();
if is_isrc_shaped(&cleaned) {
Some(cleaned)
} else {
None
}
}
/// `CCXXXYYNNNNN` — 2 letters, 3 alphanumerics, 7 digits.
pub(crate) fn is_isrc_shaped(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 12
&& b[0..2].iter().all(|c| c.is_ascii_alphabetic())
&& b[2..5].iter().all(|c| c.is_ascii_alphanumeric())
&& b[5..12].iter().all(|c| c.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bom_is_stripped_and_utf8_survives() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice("Björk".as_bytes());
assert_eq!(decode_text(&bytes), "Björk");
}
#[test]
fn windows_1252_accents_are_recovered() {
// "Sigur Rós" in Windows-1252: ó is 0xF3, which is invalid UTF-8 alone.
let bytes = b"Sigur R\xF3s";
assert!(std::str::from_utf8(bytes).is_err());
assert_eq!(decode_text(bytes), "Sigur Rós");
}
#[test]
fn lines_split_on_lone_cr() {
assert_eq!(split_lines("a\rb\r\nc\nd"), vec!["a", "b", "c", "d"]);
}
#[test]
fn artist_title_splits_on_the_first_dash_only() {
assert_eq!(
split_artist_title("Bob Dylan - Ballad of a Thin Man"),
("Bob Dylan".to_string(), "Ballad of a Thin Man".to_string())
);
// The tail stays with the title.
assert_eq!(
split_artist_title("GY!BE - Dead Flag Blues - Part 2"),
("GY!BE".to_string(), "Dead Flag Blues - Part 2".to_string())
);
// No separator: title only, empty artist.
assert_eq!(
split_artist_title("Untitled"),
(String::new(), "Untitled".to_string())
);
// A hyphen without spaces is NOT the separator.
assert_eq!(
split_artist_title("Jay-Z"),
(String::new(), "Jay-Z".to_string())
);
}
#[test]
fn stems_drop_directory_and_extension() {
assert_eq!(file_stem("/music/Road Trip.m3u"), "Road Trip");
assert_eq!(file_stem("C:\\lists\\Party.pls"), "Party");
assert_eq!(file_stem("plain"), "plain");
assert_eq!(file_stem(".hidden"), ".hidden");
}
#[test]
fn location_stem_percent_decodes() {
assert_eq!(
location_stem("file:///music/Sigur%20R%C3%B3s%20-%20Hoppipolla.flac"),
"Sigur Rós - Hoppipolla"
);
// A bad escape survives verbatim rather than eating the string.
assert_eq!(location_stem("/m/100%.flac"), "100%");
}
#[test]
fn isrc_normalizes_to_the_bare_form_qobuz_stores() {
assert_eq!(
normalize_isrc("US-KO1-16-00123").as_deref(),
Some("USKO11600123")
);
assert_eq!(normalize_isrc("usko11600123").as_deref(), Some("USKO11600123"));
// A MusicBrainz recording id is NOT an ISRC and must never pass — it
// would misfire the score-1.0 short circuit.
assert_eq!(normalize_isrc("b1a9c0e9-1b47-4f3d-9a1e-000000000000"), None);
assert_eq!(normalize_isrc("too-short"), None);
}
}
@@ -0,0 +1,219 @@
//! M3U / M3U8.
//!
//! A line grammar, hand-rolled — no dependency. Every `m3u` crate on crates.io
//! is either HLS-focused or unmaintained, and the whole format is: comment
//! lines starting `#`, everything else a path or URL.
//!
//! Directives read: `#EXTM3U` (header), `#EXTINF:<secs>,<Artist - Title>`,
//! `#EXTALB:<album>`, `#EXTART:<artist>`, `#PLAYLIST:<name>`.
use super::decode::{file_stem, location_stem, split_artist_title, split_lines, track_or_skip};
use crate::errors::PlaylistImportError;
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
/// The tags that make an `.m3u8` an HLS STREAM manifest rather than a playlist.
///
/// `.m3u8` is the extension for both, and they are not remotely the same file.
/// Without this guard a user who drops a stream manifest in gets a playlist of
/// segment filenames — garbage that then fails to match, with nothing telling
/// them why.
const HLS_MARKERS: &[&str] = &[
"#EXT-X-STREAM-INF",
"#EXT-X-TARGETDURATION",
"#EXT-X-VERSION",
"#EXT-X-MEDIA",
"#EXT-X-PLAYLIST-TYPE",
];
pub(crate) fn looks_like_hls(text: &str) -> bool {
let head: String = text.chars().take(4096).collect::<String>().to_uppercase();
HLS_MARKERS.iter().any(|m| head.contains(m))
}
pub(crate) fn parse(text: &str, filename: &str) -> Result<ImportPlaylist, PlaylistImportError> {
if looks_like_hls(text) {
return Err(PlaylistImportError::HlsManifest);
}
let mut name = file_stem(filename);
let mut tracks: Vec<ImportTrack> = Vec::new();
// Pending directive state, consumed by the next non-comment line.
let mut pending_title = String::new();
let mut pending_artist = String::new();
let mut pending_album: Option<String> = None;
let mut pending_secs: Option<u64> = None;
for line in split_lines(text) {
if let Some(rest) = strip_tag(line, "#EXTINF:") {
// "<seconds>,<text>" — seconds may be -1 (unknown) or fractional.
let (secs, text) = match rest.split_once(',') {
Some((s, t)) => (s.trim(), t.trim()),
None => ("", rest.trim()),
};
pending_secs = parse_secs(secs);
let (artist, title) = split_artist_title(text);
// ONLY overwrite when the split actually found an artist. A
// "#EXTART:Sigur Ros" followed by an #EXTINF with no " - " is a
// real shape, and assigning the empty half unconditionally threw
// the artist away — the row then went out title-only, which the
// matcher tops out at 0.6 for and drops.
if !artist.is_empty() {
pending_artist = artist;
}
pending_title = title;
continue;
}
if let Some(rest) = strip_tag(line, "#EXTALB:") {
pending_album = Some(rest.trim().to_string()).filter(|s| !s.is_empty());
continue;
}
if let Some(rest) = strip_tag(line, "#EXTART:") {
// Only fills a gap — an "Artist - Title" #EXTINF already won.
if pending_artist.is_empty() {
pending_artist = rest.trim().to_string();
}
continue;
}
if let Some(rest) = strip_tag(line, "#PLAYLIST:") {
let n = rest.trim();
if !n.is_empty() {
name = n.to_string();
}
continue;
}
if line.starts_with('#') {
// Any other directive, including a bare #EXTM3U.
continue;
}
// A media line. Its OWN stem is the title fallback — a bare-path M3U
// carries nothing else.
let title = if pending_title.is_empty() {
location_stem(line)
} else {
std::mem::take(&mut pending_title)
};
let artist = std::mem::take(&mut pending_artist);
let album = pending_album.take();
let duration_ms = pending_secs.take().map(|s| s * 1000);
if let Some(t) = track_or_skip(title, artist, album, duration_ms, None) {
tracks.push(t);
}
pending_title.clear();
}
Ok(ImportPlaylist {
provider: ImportProvider::File,
provider_id: filename.to_string(),
name,
description: None,
tracks,
})
}
fn strip_tag<'a>(line: &'a str, tag: &str) -> Option<&'a str> {
if line.len() >= tag.len() && line[..tag.len()].eq_ignore_ascii_case(tag) {
Some(&line[tag.len()..])
} else {
None
}
}
/// `#EXTINF` seconds. `-1` means unknown, and a fractional value is legal.
fn parse_secs(raw: &str) -> Option<u64> {
let v: f64 = raw.trim().parse().ok()?;
if v <= 0.0 {
return None;
}
Some(v.round() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extinf_rows_carry_artist_title_and_duration() {
let body = "#EXTM3U\n\
#EXTINF:245,Bob Dylan - Ballad of a Thin Man\n\
/music/dylan/thin_man.flac\n\
#EXTINF:-1,Unknown Length Song\n\
/music/x.flac\n";
let p = parse(body, "/lists/Road Trip.m3u").unwrap();
assert_eq!(p.name, "Road Trip");
assert_eq!(p.tracks.len(), 2);
assert_eq!(p.tracks[0].artist, "Bob Dylan");
assert_eq!(p.tracks[0].title, "Ballad of a Thin Man");
// SECONDS x 1000 — the M3U field is seconds, unlike XSPF's.
assert_eq!(p.tracks[0].duration_ms, Some(245_000));
// -1 is "unknown", not a duration.
assert_eq!(p.tracks[1].duration_ms, None);
assert_eq!(p.tracks[1].artist, "");
assert_eq!(p.tracks[1].title, "Unknown Length Song");
}
#[test]
fn bare_paths_fall_back_to_the_file_stem() {
let body = "/music/Sigur%20R%C3%B3s%20-%20Hoppipolla.flac\nC:\\m\\Track Two.mp3\n";
let p = parse(body, "list.m3u").unwrap();
assert_eq!(p.tracks.len(), 2);
// The stem is the whole title; the " - " is NOT split here, because
// a path stem is not the "Artist - Title" convention.
assert_eq!(p.tracks[0].title, "Sigur Rós - Hoppipolla");
assert_eq!(p.tracks[1].title, "Track Two");
}
#[test]
fn extalb_extart_and_playlist_directives_are_read() {
let body = "#EXTM3U\n\
#PLAYLIST:Summer 2026\n\
#EXTALB:Agaetis Byrjun\n\
#EXTART:Sigur Ros\n\
#EXTINF:300,Svefn-g-englar\n\
a.flac\n";
let p = parse(body, "whatever.m3u").unwrap();
assert_eq!(p.name, "Summer 2026");
assert_eq!(p.tracks[0].album.as_deref(), Some("Agaetis Byrjun"));
// No " - " in the EXTINF text, so #EXTART fills the artist.
assert_eq!(p.tracks[0].artist, "Sigur Ros");
assert_eq!(p.tracks[0].title, "Svefn-g-englar");
}
#[test]
fn an_extinf_artist_beats_extart() {
let body = "#EXTART:Wrong\n#EXTINF:100,Right - Song\na.flac\n";
let p = parse(body, "x.m3u").unwrap();
assert_eq!(p.tracks[0].artist, "Right");
}
#[test]
fn crlf_and_lone_cr_both_parse() {
let body = "#EXTM3U\r\n#EXTINF:10,A - B\r\na.flac\r#EXTINF:20,C - D\rb.flac\r";
let p = parse(body, "x.m3u").unwrap();
assert_eq!(p.tracks.len(), 2);
assert_eq!(p.tracks[1].title, "D");
}
#[test]
fn an_hls_manifest_is_refused_not_parsed() {
let body = "#EXTM3U\n\
#EXT-X-VERSION:3\n\
#EXT-X-TARGETDURATION:10\n\
#EXTINF:9.9,\n\
seg0.ts\n";
assert!(matches!(
parse(body, "stream.m3u8"),
Err(PlaylistImportError::HlsManifest)
));
}
#[test]
fn a_master_playlist_is_refused_too() {
let body = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1280000\nhigh.m3u8\n";
assert!(matches!(
parse(body, "master.m3u8"),
Err(PlaylistImportError::HlsManifest)
));
}
}
@@ -0,0 +1,159 @@
//! Playlist FILES — XSPF / PLS / M3U / M3U8.
//!
//! Filesystem-free by design (ADR-006): the app reads bytes through `rfd` and
//! passes them with the filename. The crate never opens a path, which is what
//! keeps it testable with `&[u8]` literals and correct inside a Flatpak, where
//! the portal — not the app — owns file access.
//!
//! # ONLY THE TRACK LIST IS READ
//!
//! The paths inside these files are never opened, never copied and never added
//! to the Local Library. Each entry becomes a title/artist/album/duration
//! probe and is matched against the Qobuz catalog like any other import. This
//! is a product promise, not an implementation detail — the modal says it in
//! so many words, and no code here may quietly grow a file read.
pub(crate) mod decode;
mod m3u;
mod pls;
mod xspf;
use super::{guard_size, FileFormat};
use crate::errors::PlaylistImportError;
use crate::models::ImportPlaylist;
/// Parse a playlist file.
///
/// Empty results are an ERROR, not an empty playlist: a file that produced no
/// tracks means the user picked the wrong thing, or the format is subtly not
/// what it claimed, and "imported 0 tracks" is a worse answer than saying so.
pub fn parse(
format: FileFormat,
bytes: &[u8],
filename: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
guard_size(bytes)?;
let text = decode::decode_text(bytes);
let playlist = match format {
FileFormat::M3u => m3u::parse(&text, filename)?,
FileFormat::Pls => pls::parse(&text, filename)?,
FileFormat::Xspf => xspf::parse(&text, filename)?,
};
if playlist.tracks.is_empty() {
return Err(PlaylistImportError::EmptyPlaylist);
}
Ok(playlist)
}
/// Decide the format from the bytes, with the extension as a hint only.
///
/// THE SNIFF WINS. Extensions are renamed, mailed, downloaded through services
/// that rewrite them and typed by hand; the first few bytes of an XML document
/// are not. The extension breaks ties the content cannot — specifically, a
/// bare-path M3U with no `#EXTM3U` header is indistinguishable from a text file
/// on content alone, so there the extension is the whole signal.
pub fn detect_format(bytes: &[u8], filename: &str) -> Result<FileFormat, PlaylistImportError> {
guard_size(bytes)?;
let text = decode::decode_text(bytes);
let head: String = text.chars().take(4096).collect();
let trimmed = head.trim_start();
let lower = trimmed.to_ascii_lowercase();
// XML first: unambiguous, and cheap to be sure about.
if trimmed.starts_with('<') && lower.contains("<playlist") {
return Ok(FileFormat::Xspf);
}
// PLS: the header, or (headerless, which is common) a FileN key.
if lower.starts_with("[playlist]") || has_pls_key(&lower) {
return Ok(FileFormat::Pls);
}
// Extended M3U. The HLS guard runs in the PARSER, not here: detection's
// job is "which parser", and refusing a stream manifest is a parse-time
// answer the caller can localize.
if lower.starts_with("#extm3u") {
return Ok(FileFormat::M3u);
}
// Basic M3U — content-free, so the extension is the only evidence.
let ext = filename
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"m3u" | "m3u8" => Ok(FileFormat::M3u),
"pls" => Ok(FileFormat::Pls),
"xspf" => Ok(FileFormat::Xspf),
_ => Err(PlaylistImportError::UnrecognizedFormat),
}
}
/// A `FileN=` line anywhere in the head, for the headerless PLS case.
fn has_pls_key(lower_head: &str) -> bool {
lower_head.split(['\n', '\r']).any(|line| {
let line = line.trim_start();
if !line.starts_with("file") {
return false;
}
let rest = &line[4..];
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
!digits.is_empty() && rest[digits.len()..].trim_start().starts_with('=')
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xml_is_sniffed_as_xspf_whatever_the_extension_says() {
let body = br#"<?xml version="1.0"?><playlist><trackList/></playlist>"#;
assert_eq!(detect_format(body, "mislabeled.m3u").unwrap(), FileFormat::Xspf);
}
#[test]
fn a_pls_header_or_a_bare_file_key_is_enough() {
assert_eq!(detect_format(b"[playlist]\nFile1=a\n", "x.txt").unwrap(), FileFormat::Pls);
assert_eq!(detect_format(b"File1=a.flac\nTitle1=A\n", "x.txt").unwrap(), FileFormat::Pls);
}
#[test]
fn extm3u_is_sniffed_and_a_bare_list_falls_back_to_the_extension() {
assert_eq!(detect_format(b"#EXTM3U\na.flac\n", "x.txt").unwrap(), FileFormat::M3u);
assert_eq!(detect_format(b"/music/a.flac\n", "list.m3u8").unwrap(), FileFormat::M3u);
}
#[test]
fn an_unknown_extension_with_unknown_content_is_refused() {
assert!(matches!(
detect_format(b"just some prose\n", "notes.txt"),
Err(PlaylistImportError::UnrecognizedFormat)
));
}
#[test]
fn a_bom_does_not_hide_the_format() {
let mut body = vec![0xEF, 0xBB, 0xBF];
body.extend_from_slice(b"[playlist]\nFile1=a\n");
assert_eq!(detect_format(&body, "x.pls").unwrap(), FileFormat::Pls);
}
#[test]
fn oversize_is_refused_before_anything_else() {
let big = vec![b'a'; super::super::MAX_IMPORT_BYTES + 1];
assert!(matches!(
detect_format(&big, "x.m3u"),
Err(PlaylistImportError::FileTooLarge)
));
assert!(matches!(
parse(FileFormat::M3u, &big, "x.m3u"),
Err(PlaylistImportError::FileTooLarge)
));
}
#[test]
fn a_file_that_yields_no_tracks_is_an_error_not_an_empty_playlist() {
assert!(matches!(
parse(FileFormat::M3u, b"#EXTM3U\n", "x.m3u"),
Err(PlaylistImportError::EmptyPlaylist)
));
}
}
@@ -0,0 +1,162 @@
//! PLS — an INI whose keys carry the row index as a suffix.
//!
//! ```ini
//! [playlist]
//! File1=/music/a.flac
//! Title1=Bob Dylan - Ballad of a Thin Man
//! Length1=245
//! NumberOfEntries=1
//! ```
//!
//! `NumberOfEntries` is DELIBERATELY IGNORED as an iteration bound: real files
//! disagree with their own header often enough (hand edits, truncated writes)
//! that trusting it drops rows that are right there. The rows discovered are
//! the rows imported.
use std::collections::BTreeMap;
use super::decode::{file_stem, location_stem, split_artist_title, split_lines, track_or_skip};
use crate::errors::PlaylistImportError;
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
#[derive(Default)]
struct Row {
file: Option<String>,
title: Option<String>,
length: Option<i64>,
}
pub(crate) fn parse(text: &str, filename: &str) -> Result<ImportPlaylist, PlaylistImportError> {
// BTreeMap so the rows come out in index order regardless of file order —
// a hand-edited PLS can easily interleave them.
let mut rows: BTreeMap<u32, Row> = BTreeMap::new();
for line in split_lines(text) {
if line.starts_with('[') || line.starts_with(';') || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
let Some((prefix, idx)) = split_indexed_key(key) else {
continue;
};
let row = rows.entry(idx).or_default();
// LAST WINS on a duplicate key — the INI convention, and the only
// reading under which a hand-appended correction takes effect.
match prefix.as_str() {
"file" => row.file = Some(value.to_string()),
"title" => row.title = Some(value.to_string()),
"length" => row.length = value.parse::<i64>().ok(),
_ => {}
}
}
let mut tracks: Vec<ImportTrack> = Vec::new();
for row in rows.into_values() {
let (artist, title) = match row.title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => split_artist_title(t),
// No TitleN: the FileN stem is all there is.
None => (
String::new(),
row.file.as_deref().map(location_stem).unwrap_or_default(),
),
};
// `-1` (and 0) mean unknown. Seconds -> ms.
let duration_ms = row
.length
.filter(|l| *l > 0)
.map(|l| l as u64 * 1000);
if let Some(t) = track_or_skip(title, artist, None, duration_ms, None) {
tracks.push(t);
}
}
Ok(ImportPlaylist {
provider: ImportProvider::File,
provider_id: filename.to_string(),
name: file_stem(filename),
description: None,
tracks,
})
}
/// `"Title12"` -> `("title", 12)`. Case-insensitive, because PLS writers
/// disagree about it (`FILE1`, `File1`, `file1` all occur).
fn split_indexed_key(key: &str) -> Option<(String, u32)> {
let digits_start = key.len() - key.chars().rev().take_while(|c| c.is_ascii_digit()).count();
if digits_start == key.len() || digits_start == 0 {
return None;
}
let idx: u32 = key[digits_start..].parse().ok()?;
Some((key[..digits_start].trim().to_ascii_lowercase(), idx))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "[playlist]\n\
File1=/music/dylan/thin_man.flac\n\
Title1=Bob Dylan - Ballad of a Thin Man\n\
Length1=245\n\
File2=/music/x/Second%20Song.flac\n\
Length2=-1\n\
NumberOfEntries=2\n\
Version=2\n";
#[test]
fn numbered_keys_become_rows_in_index_order() {
let p = parse(SAMPLE, "/lists/Party.pls").unwrap();
assert_eq!(p.name, "Party");
assert_eq!(p.tracks.len(), 2);
assert_eq!(p.tracks[0].artist, "Bob Dylan");
assert_eq!(p.tracks[0].title, "Ballad of a Thin Man");
assert_eq!(p.tracks[0].duration_ms, Some(245_000));
// No TitleN -> the FileN stem, percent-decoded.
assert_eq!(p.tracks[1].title, "Second Song");
assert_eq!(p.tracks[1].duration_ms, None);
}
#[test]
fn rows_out_of_order_still_come_out_in_index_order() {
let body = "[playlist]\nTitle2=B - Two\nFile2=b.flac\nTitle1=A - One\nFile1=a.flac\n";
let p = parse(body, "x.pls").unwrap();
assert_eq!(p.tracks[0].title, "One");
assert_eq!(p.tracks[1].title, "Two");
}
#[test]
fn keys_are_case_insensitive_and_duplicates_take_the_last() {
let body = "[PLAYLIST]\nFILE1=a.flac\ntitle1=First - Wrong\nTitle1=First - Right\n";
let p = parse(body, "x.pls").unwrap();
assert_eq!(p.tracks[0].title, "Right");
}
#[test]
fn a_missing_header_still_parses() {
// Half the PLS files in the wild have no [playlist] line.
let body = "File1=a.flac\nTitle1=A - B\n";
let p = parse(body, "x.pls").unwrap();
assert_eq!(p.tracks.len(), 1);
}
#[test]
fn number_of_entries_does_not_bound_the_rows() {
// The header lies; the rows win.
let body = "[playlist]\nNumberOfEntries=1\nFile1=a.flac\nTitle1=A - 1\n\
File2=b.flac\nTitle2=B - 2\nFile3=c.flac\nTitle3=C - 3\n";
let p = parse(body, "x.pls").unwrap();
assert_eq!(p.tracks.len(), 3);
}
#[test]
fn a_row_with_neither_title_nor_file_is_dropped() {
let body = "[playlist]\nLength1=100\nFile2=b.flac\nTitle2=B - Two\n";
let p = parse(body, "x.pls").unwrap();
assert_eq!(p.tracks.len(), 1);
assert_eq!(p.tracks[0].title, "Two");
}
}
@@ -0,0 +1,212 @@
//! XSPF — the XML playlist format.
//!
//! `roxmltree`, not `quick-xml`: XSPF files are tiny (the byte wall forecloses
//! the streaming case that would justify an event state machine), and a
//! read-only DOM walked by LOCAL element name is thirty lines instead of a
//! parser with modes. Both crates were already in the lock, so neither costs a
//! new download.
//!
//! Namespace-lenient by construction: every lookup is `tag_name().name()`,
//! which is the local part. Files written with `xmlns="http://xspf.org/ns/0/"`
//! and files written without it walk identically — and both exist in the wild.
use super::decode::{is_isrc_shaped, location_stem, track_or_skip};
use crate::errors::PlaylistImportError;
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
pub(crate) fn parse(text: &str, filename: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let doc = roxmltree::Document::parse(text)
.map_err(|e| PlaylistImportError::Parse(format!("XSPF: {e}")))?;
let root = doc.root_element();
let name = child_text(root, "title")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| super::decode::file_stem(filename));
let description = child_text(root, "annotation").filter(|s| !s.is_empty());
let mut tracks: Vec<ImportTrack> = Vec::new();
// `<trackList>` may be nested under the root or (malformed but common)
// appear at any depth; descendants covers both without a special case.
for track in root
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "track")
{
let location = child_text(track, "location").unwrap_or_default();
let title = child_text(track, "title")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| location_stem(&location));
// `<creator>` is a CLEAN artist field — never run the "Artist - Title"
// splitter on it. XSPF is the one file format that carries the two
// separately, which is exactly why its rows match well.
let artist = child_text(track, "creator").unwrap_or_default();
let album = child_text(track, "album");
// *** <duration> IS ALREADY MILLISECONDS. No x1000. ***
// This is the single most common porting error in this format: M3U and
// PLS are seconds and XSPF is not, so a copied line multiplies a
// correct value by a thousand and every duration bonus turns into a
// duration penalty.
let duration_ms = child_text(track, "duration")
.and_then(|d| d.trim().parse::<u64>().ok())
.filter(|d| *d > 0);
let isrc = find_isrc(track);
if let Some(t) = track_or_skip(title, artist, album, duration_ms, isrc) {
tracks.push(t);
}
}
Ok(ImportPlaylist {
provider: ImportProvider::File,
provider_id: filename.to_string(),
name,
description,
tracks,
})
}
fn child_text<'a>(node: roxmltree::Node<'a, 'a>, name: &str) -> Option<String> {
node.children()
.find(|c| c.is_element() && c.tag_name().name() == name)
.and_then(|c| c.text())
.map(|t| t.trim().to_string())
}
/// Sweep the track's subtree for an ISRC.
///
/// XSPF has no ISRC element; taggers put it inside `<extension>` under wildly
/// different vendor element names, so the only portable rule is "any text node
/// under this track that IS an ISRC". Shape-checked, never guessed.
///
/// `<identifier>` is EXCLUDED ON PURPOSE: it is where MusicBrainz recording ids
/// live, and a MBID landing in `isrc` would fire the matcher's score-1.0
/// short-circuit against a completely unrelated Qobuz track. The shape check
/// already rejects a UUID, and the exclusion means we never even ask.
fn find_isrc(track: roxmltree::Node) -> Option<String> {
for n in track.descendants() {
if !n.is_element() {
continue;
}
if n.tag_name().name() == "identifier" {
continue;
}
// An `isrc`-named element wins outright when its text is shaped right.
let text = n.text().map(str::trim).unwrap_or("");
if text.is_empty() {
continue;
}
let cleaned: String = text
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect();
if is_isrc_shaped(&cleaned) {
return Some(cleaned);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const NAMESPACED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<playlist version="1" xmlns="http://xspf.org/ns/0/">
<title>Road Trip</title>
<annotation>Songs for the drive</annotation>
<trackList>
<track>
<location>file:///music/Sigur%20R%C3%B3s.flac</location>
<title>Hoppipolla</title>
<creator>Sigur Ros</creator>
<album>Takk...</album>
<duration>268000</duration>
</track>
</trackList>
</playlist>"#;
#[test]
fn namespaced_documents_walk_by_local_name() {
let p = parse(NAMESPACED, "ignored.xspf").unwrap();
assert_eq!(p.name, "Road Trip");
assert_eq!(p.description.as_deref(), Some("Songs for the drive"));
assert_eq!(p.tracks.len(), 1);
assert_eq!(p.tracks[0].title, "Hoppipolla");
// <creator> is a clean artist — NOT run through the dash splitter.
assert_eq!(p.tracks[0].artist, "Sigur Ros");
assert_eq!(p.tracks[0].album.as_deref(), Some("Takk..."));
}
#[test]
fn duration_is_milliseconds_and_is_not_multiplied() {
let p = parse(NAMESPACED, "x.xspf").unwrap();
// 268000 ms = 4:28. If this ever reads 268_000_000 the x1000 crept in.
assert_eq!(p.tracks[0].duration_ms, Some(268_000));
}
#[test]
fn a_document_without_the_namespace_parses_identically() {
let body = r#"<playlist version="1"><trackList><track>
<title>Song</title><creator>Band</creator><duration>1000</duration>
</track></trackList></playlist>"#;
let p = parse(body, "no-ns.xspf").unwrap();
assert_eq!(p.tracks[0].title, "Song");
assert_eq!(p.tracks[0].duration_ms, Some(1000));
}
#[test]
fn a_missing_title_falls_back_to_the_location_stem() {
let body = r#"<playlist><trackList><track>
<location>file:///m/Track%20Nine.flac</location>
</track></trackList></playlist>"#;
let p = parse(body, "x.xspf").unwrap();
assert_eq!(p.tracks[0].title, "Track Nine");
}
#[test]
fn a_missing_playlist_title_falls_back_to_the_filename() {
let body = r#"<playlist><trackList><track><title>A</title></track></trackList></playlist>"#;
let p = parse(body, "/lists/My Mix.xspf").unwrap();
assert_eq!(p.name, "My Mix");
}
#[test]
fn an_isrc_in_an_extension_is_found_and_normalized() {
// A vendor extension with its namespace properly DECLARED — an
// undeclared prefix is invalid XML and roxmltree is right to refuse it.
let body = r#"<playlist xmlns:v="urn:x-vendor"><trackList><track>
<title>A</title>
<extension application="urn:x"><v:isrc>us-ko1-16-00123</v:isrc></extension>
</track></trackList></playlist>"#;
let p = parse(body, "x.xspf").unwrap();
assert_eq!(p.tracks[0].isrc.as_deref(), Some("USKO11600123"));
}
#[test]
fn a_plain_isrc_element_in_an_extension_is_found_too() {
let body = r#"<playlist><trackList><track>
<title>A</title>
<extension application="urn:x"><isrc>USKO11600123</isrc></extension>
</track></trackList></playlist>"#;
let p = parse(body, "x.xspf").unwrap();
assert_eq!(p.tracks[0].isrc.as_deref(), Some("USKO11600123"));
}
#[test]
fn an_identifier_mbid_never_becomes_an_isrc() {
let body = r#"<playlist><trackList><track>
<title>A</title>
<identifier>https://musicbrainz.org/recording/b1a9c0e9-1b47-4f3d-9a1e-000000000000</identifier>
</track></trackList></playlist>"#;
let p = parse(body, "x.xspf").unwrap();
assert_eq!(p.tracks[0].isrc, None);
}
#[test]
fn malformed_xml_is_a_parse_error_not_a_panic() {
assert!(matches!(
parse("<playlist><track>", "x.xspf"),
Err(PlaylistImportError::Parse(_))
));
}
}
@@ -0,0 +1,638 @@
//! Best-effort JSON.
//!
//! "Somebody exported their library as JSON and wants their playlist." There is
//! no schema to target, so this locates the track list BY SHAPE and harvests
//! fields by synonym, under hard caps that make a pathological file impossible
//! to weaponise.
//!
//! # Why shape and not depth
//!
//! The obvious rule — "the first array within two levels of the root" — is
//! wrong on real exports, and it is wrong in the direction that loses data.
//! The track array's depth is simply not stable: a flat `[...]` is 0,
//! `{tracks:[...]}` is 1, `{playlist:{tracks:[...]}}` is 2 and
//! `{data:{playlist:{items:[...]}}}` — an ordinary GraphQL envelope — is 3. A
//! hard two-level cap silently drops the last one. "First array" also has no
//! answer for a sibling `artists:[...]` or `images:[...]` that happens to come
//! earlier in document order.
//!
//! So depth is demoted from LOCATOR to DoS BOUND, and the locator becomes "the
//! largest array of track-shaped objects anywhere inside the guard".
//!
//! # The one false positive worth naming
//!
//! An array of ALBUM objects usually carries both a title and an artist, which
//! is exactly the both-fields test that admits a track. A discography dump
//! would import album names as track titles. Two things stop it:
//!
//! 1. **Container demotion.** An element that itself contains a nested
//! track-shaped array is a CONTAINER (an album with its tracks, a section
//! with its items), and an array of containers is disqualified. The real
//! track list, nested one level down, wins instead.
//! 2. **The preview count.** The user sees "Found N tracks" before Import. On a
//! discography that number is the album count, which is visibly wrong.
//!
//! With both, the shape locator is a superset of the depth rule on well-formed
//! exports. It is not unconditionally more precise, and this comment is the
//! place that says so.
use serde_json::Value;
use super::guard_size;
use crate::errors::PlaylistImportError;
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
/// Depth guard for the structural walk. A BOUND, not a locator — nothing about
/// correctness depends on it, only the walk's cost.
const MAX_DEPTH: usize = 8;
/// Node-visit budget. Bounds adversarial WIDTH the way the byte wall bounds
/// size: a 16 MiB file of two-byte array elements is still millions of nodes.
/// On exhaustion the walk stops descending and keeps the best candidate found
/// so far — graceful degradation, never an error.
const MAX_NODES: usize = 2_000_000;
/// Output cap. The pipeline already splits at 2000 per playlist, so this is at
/// most five parts.
const MAX_TRACKS: usize = 10_000;
/// How far inside one element a field may hide. TWO object-descents, which is
/// exactly `item.track.album.name` — the deepest real field in any streaming
/// export. This one IS a locator and it is tight on purpose.
const ELEM_FIELD_DEPTH: usize = 2;
const TITLE_KEYS: &[&str] = &["title", "name", "track", "song", "trackname", "songname"];
const ARTIST_KEYS: &[&str] = &[
"artist",
"artists",
"creator",
"performer",
"performers",
"albumartist",
"artistname",
"by",
];
const ALBUM_KEYS: &[&str] = &["album", "release", "albumname", "albumtitle", "collection"];
const DURATION_KEYS: &[&str] = &[
"duration",
"durationms",
"length",
"time",
"runtime",
"durationseconds",
];
/// Object-valued keys that WRAP the real track. `track` and `song` are also
/// title synonyms — the collision is resolved by VALUE TYPE, never by name: a
/// scalar `track:"Song"` is a title, an object `track:{...}` is a wrapper.
const WRAPPER_KEYS: &[&str] = &["track", "item", "node", "song", "metadata"];
const PLAYLIST_NAME_KEYS: &[&str] = &["name", "title", "playlistname"];
/// Lowercase and drop `_`/`-`/spaces, so `duration_ms`, `durationMs` and
/// `Duration MS` are one key.
fn norm_key(k: &str) -> String {
k.chars()
.filter(|c| *c != '_' && *c != '-' && *c != ' ')
.flat_map(|c| c.to_lowercase())
.collect()
}
pub fn parse(bytes: &[u8], filename: &str) -> Result<ImportPlaylist, PlaylistImportError> {
guard_size(bytes)?;
// serde_json's own 128-deep recursion limit pre-empts a stack attack during
// the parse itself, before our walk guard ever runs.
let root: Value = serde_json::from_slice(bytes)
.map_err(|e| PlaylistImportError::Parse(format!("JSON: {e}")))?;
let mut budget = MAX_NODES;
let mut best: Option<Candidate> = None;
locate(&root, 0, &mut budget, &mut best, None);
let Some(best) = best else {
return Err(PlaylistImportError::JsonShapeUnrecognized);
};
let arr = best
.array
.as_array()
.expect("candidate is always an array");
let mut tracks: Vec<ImportTrack> = Vec::with_capacity(arr.len().min(MAX_TRACKS));
for elem in arr.iter() {
if tracks.len() >= MAX_TRACKS {
log::warn!(
"[qbz-playlist-import] JSON: truncated at {MAX_TRACKS} tracks ({} in the file)",
arr.len()
);
break;
}
if let Some(t) = extract_track(elem) {
tracks.push(t);
}
}
if tracks.is_empty() {
return Err(PlaylistImportError::EmptyPlaylist);
}
// The playlist name comes from the ROOT object or the array's PARENT —
// never from inside the array, so a track title can never become the
// playlist name.
let name = best
.parent_name
.or_else(|| scalar_name(&root))
.unwrap_or_else(|| super::file::decode::file_stem(filename));
Ok(ImportPlaylist {
provider: ImportProvider::Json,
provider_id: filename.to_string(),
name,
description: None,
tracks,
})
}
// ---------------------------------------------------------------------------
// (a) Structural pass — locate by shape
// ---------------------------------------------------------------------------
struct Candidate<'a> {
array: &'a Value,
shaped: usize,
depth: usize,
parent_name: Option<String>,
}
/// Depth-first walk. At every array, score it; keep the best.
///
/// "Best" is the greatest count of track-shaped elements; ties break to the
/// SHALLOWER array, then to document order (the first one seen wins, because a
/// later equal candidate never replaces it below).
fn locate<'a>(
node: &'a Value,
depth: usize,
budget: &mut usize,
best: &mut Option<Candidate<'a>>,
parent_name: Option<&str>,
) {
if depth > MAX_DEPTH || *budget == 0 {
return;
}
*budget -= 1;
match node {
Value::Array(items) => {
let shaped = items.iter().filter(|e| is_track_shaped(e)).count();
// A MAJORITY must be track-shaped: one stray object inside an
// `images` array cannot promote it.
let qualifies = shaped >= 1 && shaped * 2 >= items.len();
// Container demotion — see the module header. An array whose
// elements each hold their own track-shaped array is a list of
// albums/sections, not of tracks.
let container_like = items.iter().take(32).any(has_nested_track_array);
if qualifies && !container_like {
let better = match best {
None => true,
Some(b) => shaped > b.shaped || (shaped == b.shaped && depth < b.depth),
};
if better {
*best = Some(Candidate {
array: node,
shaped,
depth,
parent_name: parent_name.map(str::to_string),
});
}
}
// Descend regardless: the real list may be INSIDE a container
// array, and that is precisely the case demotion exists to reach.
for item in items {
locate(item, depth + 1, budget, best, parent_name);
if *budget == 0 {
return;
}
}
}
Value::Object(map) => {
// This object's own name/title labels any array it holds.
let here = scalar_name(node);
for (_k, v) in map.iter() {
locate(
v,
depth + 1,
budget,
best,
here.as_deref().or(parent_name),
);
if *budget == 0 {
return;
}
}
}
_ => {}
}
}
/// Does this element hold a nested array that is itself track-shaped?
/// Checked ONE level down only — that is where an album keeps its `tracks`.
fn has_nested_track_array(elem: &Value) -> bool {
let Value::Object(map) = elem else {
return false;
};
map.values().any(|v| match v {
Value::Array(inner) => {
let shaped = inner.iter().take(16).filter(|e| is_track_shaped(e)).count();
shaped >= 1 && shaped * 2 >= inner.len().min(16)
}
_ => false,
})
}
/// THE SAME BOUNDED PROBE EXTRACTION USES. Detection and extraction must not
/// drift: an element that passes here and yields nothing there would inflate
/// the candidate score against a real list.
fn is_track_shaped(elem: &Value) -> bool {
let target = unwrap_element(elem);
let Value::Object(_) = target else {
return false;
};
harvest_str(target, TITLE_KEYS, ELEM_FIELD_DEPTH).is_some()
&& harvest_artist(target, ELEM_FIELD_DEPTH).is_some()
}
// ---------------------------------------------------------------------------
// (b) Field extraction — synonym harvest
// ---------------------------------------------------------------------------
/// Unwrap ONE object-valued wrapper key (`{track:{...}}` -> the inner object).
/// A SCALAR under the same key is a title, not a wrapper, so it never unwraps.
fn unwrap_element(elem: &Value) -> &Value {
let Value::Object(map) = elem else {
return elem;
};
for (k, v) in map.iter() {
if v.is_object() && WRAPPER_KEYS.contains(&norm_key(k).as_str()) {
return v;
}
}
elem
}
fn extract_track(elem: &Value) -> Option<ImportTrack> {
let t = unwrap_element(elem);
let title = harvest_str(t, TITLE_KEYS, ELEM_FIELD_DEPTH)?;
let title = title.trim().to_string();
// KEEP RULE: title required, artist optional.
//
// The matcher's arithmetic is why. A title-less row maxes at artist 0.3 +
// album 0.1 = 0.4, below the 0.65 threshold — mathematically unmatchable,
// so dropping it loses nothing and keeps the preview count honest. A
// title-ONLY row maxes at 0.6 and clears only on an exact normalized hit
// plus the duration bonus; it is kept, and if it misses it surfaces in
// `skipped_tracks` where the user can see it.
if title.is_empty() {
return None;
}
Some(ImportTrack {
title,
artist: harvest_artist(t, ELEM_FIELD_DEPTH).unwrap_or_default(),
album: harvest_str(t, ALBUM_KEYS, ELEM_FIELD_DEPTH).filter(|s| !s.trim().is_empty()),
duration_ms: harvest_duration(t, ELEM_FIELD_DEPTH),
isrc: harvest_str(t, &["isrc"], ELEM_FIELD_DEPTH)
.and_then(|s| super::file::decode::normalize_isrc(&s)),
provider_id: None,
provider_url: None,
})
}
/// First non-empty scalar under any of `keys`, within `depth` object descents.
/// Direct fields win over nested ones (breadth before depth).
fn harvest_str(node: &Value, keys: &[&str], depth: usize) -> Option<String> {
let Value::Object(map) = node else {
return None;
};
for (k, v) in map.iter() {
if !keys.contains(&norm_key(k).as_str()) {
continue;
}
if let Some(s) = as_non_empty_str(v) {
return Some(s);
}
// The synonym matched but its value is an OBJECT — `album:{name:"Al"}`,
// which is the Spotify/Apple shape and by far the commonest way an
// album arrives. Read the name out of it rather than walking past a
// key that already told us it is the right one.
if v.is_object() {
if let Some(s) = harvest_str(v, &["name", "title"], 1) {
return Some(s);
}
}
}
if depth == 0 {
return None;
}
for (_k, v) in map.iter() {
if v.is_object() {
if let Some(s) = harvest_str(v, keys, depth - 1) {
return Some(s);
}
}
}
None
}
fn as_non_empty_str(v: &Value) -> Option<String> {
match v {
Value::String(s) if !s.trim().is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
/// Artists come in four shapes: `"A"`, `["A","B"]`, `[{name:"A"}]`, `{name:"A"}`.
fn harvest_artist(node: &Value, depth: usize) -> Option<String> {
let Value::Object(map) = node else {
return None;
};
for (k, v) in map.iter() {
if !ARTIST_KEYS.contains(&norm_key(k).as_str()) {
continue;
}
if let Some(s) = artist_value(v) {
return Some(s);
}
}
if depth == 0 {
return None;
}
for (_k, v) in map.iter() {
if v.is_object() {
if let Some(s) = harvest_artist(v, depth - 1) {
return Some(s);
}
}
}
None
}
fn artist_value(v: &Value) -> Option<String> {
match v {
Value::String(s) if !s.trim().is_empty() => Some(s.clone()),
Value::Object(_) => harvest_str(v, &["name", "artistname", "title"], 1),
Value::Array(items) => {
let names: Vec<String> = items
.iter()
.filter_map(|i| match i {
Value::String(s) if !s.trim().is_empty() => Some(s.clone()),
Value::Object(_) => harvest_str(i, &["name", "artistname", "title"], 1),
_ => None,
})
.collect();
if names.is_empty() {
None
} else {
Some(names.join(", "))
}
}
_ => None,
}
}
/// Duration in ms.
///
/// TRUST THE KEY NAME FIRST. `duration_ms` is milliseconds and `runtime` is
/// seconds, and both are unambiguous. Only a genuinely unit-less key
/// (`duration`, `time`) falls back to the magnitude rule.
///
/// The magnitude rule's known blind spots, stated rather than hidden: a track
/// longer than 10 000 s expressed in seconds (a DJ mix, an audiobook chapter)
/// reads as ~10 s of ms, and a sub-10 s value in ms reads as seconds. The
/// damage is bounded — duration is a ±0.05 bonus in the matcher and never a
/// gate — but a wrongly-"close" value can hand out a spurious bonus, which is
/// why the key name wins whenever there is one.
fn harvest_duration(node: &Value, depth: usize) -> Option<u64> {
let Value::Object(map) = node else {
return None;
};
for (k, v) in map.iter() {
let nk = norm_key(k);
if !DURATION_KEYS.contains(&nk.as_str()) {
continue;
}
let Some(n) = as_f64(v) else { continue };
if n <= 0.0 {
continue;
}
let ms = match nk.as_str() {
"durationms" => n,
"length" | "runtime" | "durationseconds" => n * 1000.0,
// Ambiguous: `duration`, `time`.
_ => {
if n >= 10_000.0 {
n
} else {
n * 1000.0
}
}
};
return Some(ms.round() as u64);
}
if depth == 0 {
return None;
}
for (_k, v) in map.iter() {
if v.is_object() {
if let Some(d) = harvest_duration(v, depth - 1) {
return Some(d);
}
}
}
None
}
fn as_f64(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
}
}
/// A `name`/`title`/`playlistName` scalar directly on this object.
fn scalar_name(node: &Value) -> Option<String> {
let Value::Object(map) = node else {
return None;
};
for (k, v) in map.iter() {
if PLAYLIST_NAME_KEYS.contains(&norm_key(k).as_str()) {
if let Value::String(s) = v {
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn p(json: &str) -> Result<ImportPlaylist, PlaylistImportError> {
parse(json.as_bytes(), "export.json")
}
#[test]
fn a_flat_array_of_tracks() {
let doc = r#"[{"title":"A","artist":"X"},{"title":"B","artist":"Y"}]"#;
let r = p(doc).unwrap();
assert_eq!(r.tracks.len(), 2);
assert_eq!(r.tracks[0].title, "A");
assert_eq!(r.name, "export"); // the filename stem
}
#[test]
fn a_named_wrapper_object() {
let doc = r#"{"name":"My Mix","tracks":[{"name":"A","artist":"X"}]}"#;
let r = p(doc).unwrap();
assert_eq!(r.name, "My Mix");
assert_eq!(r.tracks.len(), 1);
}
#[test]
fn the_spotify_shape_unwraps_its_track_key() {
let doc = r#"{"name":"Liked","tracks":{"items":[
{"added_at":"2026","track":{"name":"A","artists":[{"name":"X"},{"name":"Y"}],
"album":{"name":"Al"},"duration_ms":211000}}
]}}"#;
let r = p(doc).unwrap();
assert_eq!(r.tracks.len(), 1);
assert_eq!(r.tracks[0].title, "A");
assert_eq!(r.tracks[0].artist, "X, Y");
assert_eq!(r.tracks[0].album.as_deref(), Some("Al"));
assert_eq!(r.tracks[0].duration_ms, Some(211_000));
}
#[test]
fn a_deep_graphql_envelope_is_found_by_shape() {
// Depth 3 from the root — the case a hard two-level cap would drop.
let doc = r#"{"data":{"playlist":{"title":"Deep","items":[
{"title":"A","artist":"X"},{"title":"B","artist":"Y"}]}}}"#;
let r = p(doc).unwrap();
assert_eq!(r.tracks.len(), 2);
assert_eq!(r.name, "Deep");
}
#[test]
fn a_sibling_artists_array_does_not_win() {
// `artists` has a title-like `name` but no artist-like key -> rejected.
let doc = r#"{"artists":[{"name":"X"},{"name":"Y"},{"name":"Z"}],
"tracks":[{"title":"A","artist":"X"}]}"#;
let r = p(doc).unwrap();
assert_eq!(r.tracks.len(), 1);
assert_eq!(r.tracks[0].title, "A");
}
#[test]
fn an_album_array_is_demoted_and_the_real_tracks_win() {
// The discography shape: each element has BOTH a title and an artist,
// which is exactly what would fool a naive both-fields test.
let doc = r#"{"albums":[
{"title":"Album One","artist":"X","tracks":[
{"title":"T1","artist":"X"},{"title":"T2","artist":"X"}]},
{"title":"Album Two","artist":"X","tracks":[
{"title":"T3","artist":"X"}]}
]}"#;
let r = p(doc).unwrap();
let titles: Vec<&str> = r.tracks.iter().map(|t| t.title.as_str()).collect();
// The album NAMES must not be the import.
assert!(!titles.contains(&"Album One"));
assert!(titles.contains(&"T1"));
}
#[test]
fn a_scalar_track_key_is_a_title_and_an_object_one_is_a_wrapper() {
let scalar = r#"[{"track":"Song Name","artist":"X"}]"#;
assert_eq!(p(scalar).unwrap().tracks[0].title, "Song Name");
let object = r#"[{"track":{"title":"Inner","artist":"X"}}]"#;
assert_eq!(p(object).unwrap().tracks[0].title, "Inner");
}
#[test]
fn duration_trusts_the_key_name_before_the_magnitude() {
// Explicit ms.
let ms = r#"[{"title":"A","artist":"X","duration_ms":180000}]"#;
assert_eq!(p(ms).unwrap().tracks[0].duration_ms, Some(180_000));
// Explicitly seconds, even at a magnitude the fallback would call ms.
let secs = r#"[{"title":"A","artist":"X","runtime":12000}]"#;
assert_eq!(p(secs).unwrap().tracks[0].duration_ms, Some(12_000_000));
// Ambiguous + small -> seconds (the Deezer shape).
let amb_s = r#"[{"title":"A","artist":"X","duration":200}]"#;
assert_eq!(p(amb_s).unwrap().tracks[0].duration_ms, Some(200_000));
// Ambiguous + large -> already ms.
let amb_ms = r#"[{"title":"A","artist":"X","duration":200000}]"#;
assert_eq!(p(amb_ms).unwrap().tracks[0].duration_ms, Some(200_000));
// Numeric strings parse.
let str_n = r#"[{"title":"A","artist":"X","duration":"200"}]"#;
assert_eq!(p(str_n).unwrap().tracks[0].duration_ms, Some(200_000));
}
#[test]
fn isrc_is_normalized_to_the_bare_form() {
let doc = r#"[{"title":"A","artist":"X","isrc":"US-KO1-16-00123"}]"#;
assert_eq!(p(doc).unwrap().tracks[0].isrc.as_deref(), Some("USKO11600123"));
// Junk in an isrc field is dropped, not passed through — it would
// otherwise misfire the matcher's score-1.0 short circuit.
let junk = r#"[{"title":"A","artist":"X","isrc":"not-an-isrc"}]"#;
assert_eq!(p(junk).unwrap().tracks[0].isrc, None);
}
#[test]
fn a_title_less_row_is_dropped_and_a_title_only_row_is_kept() {
// Both rows must be present for the array to qualify, so give the
// second one an artist and no title.
let doc = r#"{"tracks":[{"title":"Kept","artist":"X"},{"artist":"Y"}]}"#;
let r = p(doc).unwrap();
assert_eq!(r.tracks.len(), 1);
assert_eq!(r.tracks[0].title, "Kept");
}
#[test]
fn no_track_shaped_array_fails_loud() {
let doc = r#"{"settings":{"volume":80},"paths":["/a","/b"]}"#;
assert!(matches!(p(doc), Err(PlaylistImportError::JsonShapeUnrecognized)));
}
#[test]
fn invalid_json_is_a_parse_error() {
assert!(matches!(p("{nope"), Err(PlaylistImportError::Parse(_))));
}
#[test]
fn oversize_is_refused_before_the_parse() {
let big = vec![b'a'; super::super::MAX_IMPORT_BYTES + 1];
assert!(matches!(
parse(&big, "x.json"),
Err(PlaylistImportError::FileTooLarge)
));
}
#[test]
fn the_output_is_capped() {
let mut items = String::from("[");
for i in 0..(MAX_TRACKS + 50) {
if i > 0 {
items.push(',');
}
items.push_str(&format!(r#"{{"title":"T{i}","artist":"X"}}"#));
}
items.push(']');
let r = p(&items).unwrap();
assert_eq!(r.tracks.len(), MAX_TRACKS);
}
#[test]
fn the_playlist_name_never_comes_from_inside_the_array() {
// Every element has a `name`; the playlist name must not be "A".
let doc = r#"[{"name":"A","artist":"X"},{"name":"B","artist":"Y"}]"#;
let r = p(doc).unwrap();
assert_eq!(r.name, "export");
}
}
@@ -0,0 +1,180 @@
//! Where a playlist can come FROM.
//!
//! Design: `qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md`.
//!
//! # An enum, not a trait (ADR-007)
//!
//! The set of source classes is CLOSED and known at compile time. There is no
//! plugin story, no registry, nothing loaded at runtime — so a trait plus
//! dynamic dispatch would buy an extension point nobody can use and cost an
//! indirection plus a `Box<dyn>` on every path. Adding a source here is a new
//! variant and one arm of [`PlaylistSource::resolve`], and the compiler names
//! every place that has to grow.
//!
//! # Everything lands on ONE type
//!
//! Every arm returns [`ImportPlaylist`], which is exactly what the existing
//! URL scrapers already produce. Downstream — the Qobuz matcher, the 2000-track
//! split, the create-and-add loop, the progress sink, the summary — never
//! learns that a second kind of source exists. That is the whole reason the
//! expansion is additive: `importer::import_prepared_playlist` takes the
//! resolved playlist and does not care who resolved it.
//!
//! # The byte wall is here, not per-parser
//!
//! [`MAX_IMPORT_BYTES`] is checked before any parse. The APP checks it too,
//! before it reads the file at all (a 2 GB pick must never reach RAM); this is
//! defense in depth for every other caller, and it is O(1) on a length.
pub mod file;
pub mod json;
pub mod service;
use crate::errors::PlaylistImportError;
use crate::models::ImportPlaylist;
/// Refuse anything larger, before allocating or parsing.
///
/// 16 MiB. The largest realistic export — 10 000 tracks at ~1.5 KB of verbose
/// metadata — is about 15 MB; a 500-track export is under 1 MB. So this is
/// comfortably above legitimate use and roughly three orders of magnitude below
/// anything that could pressure the box. It is ONE constant: dropping it to
/// 8 MiB costs no code.
pub const MAX_IMPORT_BYTES: usize = 16 * 1024 * 1024;
/// Refuse a body over the wall. Shared by every byte-taking source.
pub(crate) fn guard_size(bytes: &[u8]) -> Result<(), PlaylistImportError> {
if bytes.len() > MAX_IMPORT_BYTES {
return Err(PlaylistImportError::FileTooLarge);
}
Ok(())
}
/// The playlist-file formats this build reads. `M3u8` is not a variant: an
/// `.m3u8` is an M3U that promises to be UTF-8, and the decoder already tries
/// UTF-8 first — a second variant would fork the parser for an encoding hint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
M3u,
Pls,
Xspf,
}
/// The three Last.fm player stations reachable without authentication.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LastFmStation {
Library,
Mix,
Recommended,
}
impl LastFmStation {
/// The path segment under `/player/station/user/<user>/`.
pub fn slug(self) -> &'static str {
match self {
LastFmStation::Library => "library",
LastFmStation::Mix => "mix",
LastFmStation::Recommended => "recommended",
}
}
/// Index order of the station picker (0 Library / 1 Mix / 2 Recommended).
pub fn from_index(i: i32) -> LastFmStation {
match i {
1 => LastFmStation::Mix,
2 => LastFmStation::Recommended,
_ => LastFmStation::Library,
}
}
}
/// One resolvable playlist source.
///
/// `File` and `Json` carry BYTES, not a path, and that is deliberate: the crate
/// stays filesystem-free (ADR-006 — it runs headless, in tests, and on a
/// sandboxed Flatpak where the app owns the portal). The app reads through
/// `rfd` and hands the bytes over with the filename, which is used for the
/// playlist name and as a format hint.
#[derive(Debug, Clone)]
pub enum PlaylistSource {
/// A streaming-provider URL — the pre-expansion path, untouched.
Url(String),
File {
format: FileFormat,
bytes: Vec<u8>,
filename: String,
},
Json {
bytes: Vec<u8>,
filename: String,
},
/// A ListenBrainz playlist by MBID. The token is optional — public reads
/// work without one; with one the user's own private playlists resolve and
/// the rate limit is higher.
ListenBrainz {
mbid: String,
token: Option<String>,
},
LastFmStation {
user: String,
station: LastFmStation,
},
LastFmPlaylist {
user: String,
playlist_id: String,
},
}
impl PlaylistSource {
pub async fn resolve(&self) -> Result<ImportPlaylist, PlaylistImportError> {
match self {
// The File / Json arms are pure synchronous work in an async fn.
// They are cheap (a bounded parse over at most 16 MiB) and the
// caller is already on a tokio task, so there is nothing to move
// to a blocking pool.
PlaylistSource::Url(u) => crate::importer::preview_public_playlist(u).await,
PlaylistSource::File {
format,
bytes,
filename,
} => file::parse(*format, bytes, filename),
PlaylistSource::Json { bytes, filename } => json::parse(bytes, filename),
PlaylistSource::ListenBrainz { mbid, token } => {
service::listenbrainz::fetch(mbid, token.as_deref()).await
}
PlaylistSource::LastFmStation { user, station } => {
service::lastfm::fetch_station(user, *station).await
}
PlaylistSource::LastFmPlaylist { user, playlist_id } => {
service::lastfm::fetch_playlist(user, playlist_id).await
}
}
}
/// A short label for the log line, before the playlist is resolved.
pub fn label(&self) -> String {
match self {
PlaylistSource::Url(u) => u.trim().to_string(),
PlaylistSource::File { filename, .. } | PlaylistSource::Json { filename, .. } => {
filename.clone()
}
PlaylistSource::ListenBrainz { mbid, .. } => mbid.clone(),
PlaylistSource::LastFmStation { user, station } => {
format!("{user}/{}", station.slug())
}
PlaylistSource::LastFmPlaylist { user, playlist_id } => {
format!("{user}/playlists/{playlist_id}")
}
}
}
/// Whether resolving this source needs the network. File and JSON do not,
/// which is what lets a preview succeed offline (the IMPORT half still
/// needs a session, and is gated separately).
pub fn needs_network(&self) -> bool {
!matches!(
self,
PlaylistSource::File { .. } | PlaylistSource::Json { .. }
)
}
}
@@ -0,0 +1,491 @@
//! Last.fm — the three player stations, and a user playlist page.
//!
//! SCRAPER CLASS, and treated as such: undocumented endpoints and, for one of
//! the two paths, page HTML. Every parse is defensive — a shape change yields
//! fewer rows or an empty list, never a panic and never a hard failure that
//! blames the user. Same discipline as `providers/apple.rs`.
//!
//! No authentication and no Last.fm API key. Both paths are public reads, which
//! is why this module does NOT reuse `qbz-integrations`'s scrobble client: that
//! one exists to WRITE with the user's credentials, and importing a public
//! playlist must not need them.
//!
//! | input | what it means |
//! |---|---|
//! | `last.fm/user/<u>/playlists/<id>` | that playlist, imported directly |
//! | `last.fm/user/<u>` or a bare `<u>` | pick one of three stations |
//!
//! The stations are JSON (`/player/station/user/<u>/<slug>?ajax=1`); the
//! specific playlist is NOT — the ajax path 404s for a playlist id, so its
//! tracklist comes out of the page's `chartlist` table.
use serde_json::Value;
use super::super::LastFmStation;
use super::BROWSER_USER_AGENT;
use crate::errors::PlaylistImportError;
use crate::http::http;
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
const BASE: &str = "https://www.last.fm";
/// Hard request cap for a station. It is a BACKSTOP, not the normal exit — see
/// `fetch_station`, which stops as soon as the pool stops yielding new tracks.
const MAX_PAGES: usize = 20;
/// Consecutive all-duplicate draws that mean the pool is exhausted. Two, not
/// one: a radio can repeat a whole batch by chance.
const MAX_DRY_DRAWS: usize = 2;
/// HOW MANY TRACKS A STATION IS ALLOWED TO PROMISE.
///
/// This is the number the user sees in "Found N tracks" before they press
/// Import, so it has to be a number we can actually deliver. A station is a
/// radio: the pool is effectively unbounded and every extra draw returns mostly
/// repeats, so "keep pulling until it runs dry" quietly turns into "pull 20
/// times and announce 469 tracks" for a playlist that lands at 198.
///
/// 200 is the honest bound for a DYNAMIC source — the station is different
/// every day, so the value of a bigger single import is low and the cost (draw
/// after draw of duplicates, Last.fm rate-limiting the burst, hundreds of
/// wasted Qobuz searches) is real. Owner ruling 2026-08-19: "no debemos dar
/// falsas expectativas de 'traere casi 500 tracks' cuando solo podemos traer la
/// mitad."
const MAX_STATION_TRACKS: usize = 200;
/// What a pasted string means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LastFmTarget {
Profile { user: String },
Playlist { user: String, id: String },
}
/// Cheap, synchronous, safe per keystroke.
///
/// PLAYLIST IS CHECKED FIRST. A playlist URL contains a profile URL as a
/// prefix, so testing the profile shape first would classify every playlist as
/// a profile and silently show the station picker instead of importing.
pub fn detect(input: &str) -> Option<LastFmTarget> {
let s = input.trim();
if s.is_empty() {
return None;
}
if s.contains("last.fm") {
let after_user = s.split("/user/").nth(1)?;
let mut parts = after_user.split(['?', '#']).next().unwrap_or("").split('/');
let user = parts.next().unwrap_or("").trim();
if !is_username(user) {
return None;
}
if parts.next() == Some("playlists") {
if let Some(id) = parts.next().map(str::trim).filter(|i| !i.is_empty()) {
return Some(LastFmTarget::Playlist {
user: user.to_string(),
id: id.to_string(),
});
}
}
return Some(LastFmTarget::Profile {
user: user.to_string(),
});
}
if is_username(s) {
return Some(LastFmTarget::Profile {
user: s.to_string(),
});
}
None
}
fn is_username(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// One of the three stations.
///
/// # `?page=N` IS NOT PAGINATION, and treating it as such was a real bug
///
/// A station is a RADIO. Every request is a fresh draw from the same
/// recommendation pool, not the next slice of an ordered list — measured
/// against a live profile on 2026-08-19: pages 1 and 2 returned overlapping
/// tracks, and the batch sizes varied (20, 20, 36) instead of being a stable
/// page length.
///
/// So the original loop — "fetch 20 pages, stop at the first empty one" — never
/// hit its exit condition, always burned all 20 requests (Last.fm starts
/// refusing after about four in quick succession), and returned a pile of
/// duplicates. One real import produced 469 rows holding 198 distinct tracks;
/// the other 271 were reported to the user as "skipped", which is not what
/// happened to them.
///
/// The fix is to stop counting requests and start counting NEW tracks:
/// deduplicate as we draw, stop at [`MAX_STATION_TRACKS`], and give up early
/// after [`MAX_DRY_DRAWS`] consecutive draws that add nothing. [`MAX_PAGES`]
/// stays as a backstop on the request count.
///
/// The count this returns IS the count the user is shown and IS the number of
/// rows the import will try to add. Those three used to be different numbers.
pub async fn fetch_station(
user: &str,
station: LastFmStation,
) -> Result<ImportPlaylist, PlaylistImportError> {
let mut tracks: Vec<ImportTrack> = Vec::new();
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
let mut dry = 0usize;
let mut draws = 0usize;
for page in 1..=MAX_PAGES {
let url = format!(
"{BASE}/player/station/user/{user}/{}?ajax=1&page={page}",
station.slug()
);
// A FAILED DRAW IS NOT A FAILED IMPORT once we have rows. Last.fm rate-
// limits a burst of these, and propagating that would throw away
// everything already collected — which is exactly what `?` used to do.
let body = match get_text(&url).await {
Ok(b) => b,
Err(e) if !tracks.is_empty() => {
log::warn!("[qbz-playlist-import] last.fm station draw {page} failed: {e}");
break;
}
Err(e) => return Err(e),
};
draws += 1;
let doc: Value = match serde_json::from_str(&body) {
Ok(v) => v,
// A shape change (or a rate-limit HTML body) must not fail an
// import that already has rows.
Err(e) => {
log::warn!("[qbz-playlist-import] last.fm station draw {page}: {e}");
break;
}
};
let drawn: Vec<ImportTrack> = doc["playlist"]
.as_array()
.into_iter()
.flatten()
.filter_map(map_station_entry)
.collect();
if drawn.is_empty() {
break;
}
let before = tracks.len();
for t in drawn {
if tracks.len() >= MAX_STATION_TRACKS {
break;
}
let key = (t.artist.to_lowercase(), t.title.to_lowercase());
if seen.insert(key) {
tracks.push(t);
}
}
if tracks.len() >= MAX_STATION_TRACKS {
log::info!(
"[qbz-playlist-import] last.fm station reached its {MAX_STATION_TRACKS}-track cap"
);
break;
}
if tracks.len() == before {
dry += 1;
if dry >= MAX_DRY_DRAWS {
break;
}
} else {
dry = 0;
}
}
log::info!(
"[qbz-playlist-import] last.fm station {}/{}: {} distinct tracks in {draws} draw(s)",
user,
station.slug(),
tracks.len()
);
if tracks.is_empty() {
return Err(PlaylistImportError::EmptyPlaylist);
}
Ok(ImportPlaylist {
provider: ImportProvider::LastFm,
provider_id: format!("{user}/{}", station.slug()),
name: format!("{user} — {}", station_label(station)),
description: None,
tracks,
})
}
fn station_label(station: LastFmStation) -> &'static str {
// Not localized: it is part of a NAME the playlist is created under, and a
// playlist should not be renamed by changing the app's language.
match station {
LastFmStation::Library => "Library",
LastFmStation::Mix => "Mix",
LastFmStation::Recommended => "Recommendations",
}
}
fn map_station_entry(e: &Value) -> Option<ImportTrack> {
let title = e["_name"]
.as_str()
.or_else(|| e["name"].as_str())
.unwrap_or("")
.trim()
.to_string();
if title.is_empty() {
return None;
}
let artist = e["artists"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x["_name"].as_str().or_else(|| x["name"].as_str()))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let path = e["url"].as_str().unwrap_or("");
Some(ImportTrack {
title,
artist,
album: e["primary_album"]["name"]
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// The station feed's `duration` is SECONDS.
duration_ms: e["duration"].as_u64().filter(|d| *d > 0).map(|d| d * 1000),
isrc: None,
provider_id: path
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.map(str::to_string),
provider_url: (!path.is_empty()).then(|| format!("{BASE}{path}")),
})
}
/// A specific user playlist, scraped from its page.
pub async fn fetch_playlist(
user: &str,
playlist_id: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let mut tracks: Vec<ImportTrack> = Vec::new();
let mut name = format!("{user} — Playlist");
for page in 1..=MAX_PAGES {
let url = format!("{BASE}/user/{user}/playlists/{playlist_id}?page={page}");
let html = get_text(&url).await?;
if page == 1 {
if let Some(t) = scrape_title(&html) {
name = t;
}
}
let page_tracks = scrape_chartlist(&html);
if page_tracks.is_empty() {
break;
}
tracks.extend(page_tracks);
}
if tracks.is_empty() {
return Err(PlaylistImportError::EmptyPlaylist);
}
Ok(ImportPlaylist {
provider: ImportProvider::LastFm,
provider_id: format!("{user}/{playlist_id}"),
name,
description: None,
tracks,
})
}
/// The rows of a Last.fm `chartlist` table.
///
/// It reads the `data-track-name` / `data-artist-name` ATTRIBUTES rather than
/// the visible cell text, and that is not a style choice: the visible text is
/// truncated with an ellipsis at the column width, so scraping it would import
/// "Everything In Its Right Pla…" and lose the match. The attributes carry the
/// full strings.
fn scrape_chartlist(html: &str) -> Vec<ImportTrack> {
let mut out = Vec::new();
let mut cursor = 0usize;
while let Some(rel) = html[cursor..].find("data-track-name=\"") {
let start = cursor + rel;
let Some(title) = attr_value(html, start, "data-track-name=\"") else {
cursor = start + 17;
continue;
};
// The artist attribute lives on the same element; look ahead a bounded
// window rather than scanning the rest of the document.
let window_end = (start + 2048).min(html.len());
let artist = html[start..window_end]
.find("data-artist-name=\"")
.and_then(|r| attr_value(html, start + r, "data-artist-name=\""))
.unwrap_or_default();
if !title.trim().is_empty() {
out.push(ImportTrack {
title: decode_entities(title.trim()),
artist: decode_entities(artist.trim()),
album: None,
duration_ms: None,
isrc: None,
provider_id: None,
provider_url: None,
});
}
cursor = start + 17;
}
out
}
fn attr_value(html: &str, at: usize, attr: &str) -> Option<String> {
let rest = &html[at + attr.len()..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn scrape_title(html: &str) -> Option<String> {
let start = html.find("<title>")? + 7;
let end = html[start..].find("</title>")? + start;
let raw = decode_entities(html[start..end].trim());
// Last.fm titles read "<Playlist> | Last.fm".
let cleaned = raw.split(" | ").next().unwrap_or(&raw).trim().to_string();
(!cleaned.is_empty()).then_some(cleaned)
}
/// The five XML entities plus `&#39;`. A full HTML entity table is not worth a
/// dependency for track titles.
fn decode_entities(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
async fn get_text(url: &str) -> Result<String, PlaylistImportError> {
let resp = http()
.get(url)
// The crate's shared client sends no User-Agent and Last.fm's CDN 403s
// that. Per-request, so nothing else on the shared client changes.
.header(reqwest::header::USER_AGENT, BROWSER_USER_AGENT)
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(PlaylistImportError::Http(format!(
"Last.fm returned {}",
resp.status()
)));
}
resp.text()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_playlist_url_is_never_read_as_a_profile() {
assert_eq!(
detect("https://www.last.fm/user/rob/playlists/12345678"),
Some(LastFmTarget::Playlist {
user: "rob".into(),
id: "12345678".into()
})
);
assert_eq!(
detect("https://www.last.fm/user/rob"),
Some(LastFmTarget::Profile { user: "rob".into() })
);
assert_eq!(
detect("https://www.last.fm/user/rob/library"),
Some(LastFmTarget::Profile { user: "rob".into() })
);
assert_eq!(
detect(" rob-2 "),
Some(LastFmTarget::Profile { user: "rob-2".into() })
);
assert_eq!(detect(""), None);
assert_eq!(detect("https://example.com/user/rob"), None);
}
#[test]
fn a_station_entry_maps_and_its_duration_is_seconds() {
let e: Value = serde_json::from_str(
r#"{"_name":"Song","artists":[{"_name":"A"},{"_name":"B"}],
"primary_album":{"name":"Al"},"duration":214,
"url":"/music/A/_/Song"}"#,
)
.unwrap();
let t = map_station_entry(&e).unwrap();
assert_eq!(t.title, "Song");
assert_eq!(t.artist, "A, B");
assert_eq!(t.album.as_deref(), Some("Al"));
// 214 SECONDS -> ms.
assert_eq!(t.duration_ms, Some(214_000));
assert_eq!(t.provider_url.as_deref(), Some("https://www.last.fm/music/A/_/Song"));
}
#[test]
fn a_station_entry_without_a_name_is_dropped() {
let e: Value = serde_json::from_str(r#"{"artists":[{"_name":"A"}]}"#).unwrap();
assert!(map_station_entry(&e).is_none());
}
#[test]
fn chartlist_rows_come_from_the_attributes_not_the_truncated_text() {
let html = r#"
<tr class="chartlist-row">
<td class="chartlist-name">Everything In Its Right Pla…</td>
<button data-track-name="Everything In Its Right Place"
data-artist-name="Radiohead">Play</button>
</tr>
<tr class="chartlist-row">
<button data-track-name="Sigur R&amp;#39;s" data-artist-name="Sigur R&amp;s">Play</button>
</tr>"#;
let rows = scrape_chartlist(html);
assert_eq!(rows.len(), 2);
// The FULL title, not the ellipsized cell text.
assert_eq!(rows[0].title, "Everything In Its Right Place");
assert_eq!(rows[0].artist, "Radiohead");
// Entities are decoded.
assert_eq!(rows[1].artist, "Sigur R&s");
}
/// The draw loop's bookkeeping, isolated from the network: a radio hands
/// back overlapping batches, and what comes out must be distinct and
/// bounded by the cap the user is promised.
#[test]
fn overlapping_draws_dedupe_and_stop_at_the_cap() {
let mut seen: std::collections::HashSet<(String, String)> =
std::collections::HashSet::new();
let mut kept = 0usize;
// 40 draws of 30 entries each, cycling through a 250-track pool —
// the shape a station actually has.
for draw in 0..40 {
for i in 0..30 {
if kept >= MAX_STATION_TRACKS {
break;
}
let idx = (draw * 7 + i) % 250;
if seen.insert(("band".into(), format!("song{idx}"))) {
kept += 1;
}
}
}
assert_eq!(kept, MAX_STATION_TRACKS, "the cap is what bounds the result");
assert_eq!(seen.len(), kept, "nothing repeats in the output");
}
#[test]
fn an_html_page_with_no_chartlist_yields_no_rows_rather_than_failing() {
assert!(scrape_chartlist("<html><body>nothing here</body></html>").is_empty());
}
#[test]
fn the_page_title_is_cleaned_of_the_site_suffix() {
assert_eq!(
scrape_title("<html><head><title>Road Trip | Last.fm</title></head>").as_deref(),
Some("Road Trip")
);
}
}
@@ -0,0 +1,266 @@
//! ListenBrainz — a playlist by MBID, and the "created for you" list.
//!
//! Two public endpoints, no auth required:
//!
//! | what | endpoint |
//! |---|---|
//! | one playlist | `GET /1/playlist/<mbid>` |
//! | "created for you" | `GET /1/user/<user>/playlists/createdfor?count=N` |
//!
//! Both return JSPF. The interesting detail is that JSPF `duration` IS
//! MILLISECONDS — same as XSPF, which JSPF is the JSON serialization of — so it
//! goes through unmultiplied.
//!
//! MBID -> ISRC enrichment is an explicit NON-GOAL. It would unlock the
//! matcher's score-1.0 fast path, but it costs one MusicBrainz request per
//! track against a 1 req/s rate limit and a new dependency. Backlog, not here.
use serde_json::Value;
use super::LB_USER_AGENT;
use crate::errors::PlaylistImportError;
use crate::http::http;
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
const API: &str = "https://api.listenbrainz.org";
/// How many "created for you" entries to offer. The endpoint's own default is
/// 25; the list is a dropdown, not a page.
const CREATED_FOR_COUNT: usize = 25;
/// What a pasted string means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LbTarget {
/// A playlist MBID (pasted bare or lifted out of a listenbrainz.org URL).
Mbid(String),
/// A username — the "created for you" list is fetched for it.
User(String),
}
/// One entry of the "created for you" dropdown.
#[derive(Debug, Clone)]
pub struct LbPlaylistOption {
pub mbid: String,
pub title: String,
}
/// Cheap, synchronous, and safe to run on every keystroke.
///
/// MBID FIRST: a URL is checked before a bare username, because
/// `listenbrainz.org/playlist/<mbid>` also contains something that looks like a
/// path segment a loose username rule would grab.
pub fn detect(input: &str) -> Option<LbTarget> {
let s = input.trim();
if s.is_empty() {
return None;
}
if s.contains("listenbrainz.org") {
// .../playlist/<mbid> (trailing slash and query tolerated)
if let Some(rest) = s.split("/playlist/").nth(1) {
let mbid = rest
.split(['/', '?', '#'])
.next()
.unwrap_or("")
.trim()
.to_string();
if is_mbid(&mbid) {
return Some(LbTarget::Mbid(mbid));
}
}
// .../user/<name>
if let Some(rest) = s.split("/user/").nth(1) {
let user = rest.split(['/', '?', '#']).next().unwrap_or("").trim();
if is_username(user) {
return Some(LbTarget::User(user.to_string()));
}
}
return None;
}
if is_mbid(s) {
return Some(LbTarget::Mbid(s.to_string()));
}
if is_username(s) {
return Some(LbTarget::User(s.to_string()));
}
None
}
/// 8-4-4-4-12 hex.
fn is_mbid(s: &str) -> bool {
let parts: Vec<&str> = s.split('-').collect();
parts.len() == 5
&& [8usize, 4, 4, 4, 12]
.iter()
.zip(&parts)
.all(|(n, p)| p.len() == *n && p.chars().all(|c| c.is_ascii_hexdigit()))
}
/// Conservative: no spaces, no slashes, no dots — enough to reject a stray URL
/// or a sentence without pretending to know the service's real rules.
fn is_username(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// The "created for you" dropdown's contents.
pub async fn list_created_for(
user: &str,
token: Option<&str>,
) -> Result<Vec<LbPlaylistOption>, PlaylistImportError> {
let url = format!("{API}/1/user/{user}/playlists/createdfor?count={CREATED_FOR_COUNT}");
let doc = get_json(&url, token).await?;
let mut out = Vec::new();
for entry in doc["playlists"].as_array().into_iter().flatten() {
let pl = &entry["playlist"];
let Some(id) = pl["identifier"].as_str() else {
continue;
};
// `identifier` is a URI: .../playlist/<mbid>
let mbid = id.rsplit('/').next().unwrap_or("").to_string();
if !is_mbid(&mbid) {
continue;
}
out.push(LbPlaylistOption {
mbid,
title: pl["title"].as_str().unwrap_or("Playlist").to_string(),
});
}
Ok(out)
}
/// One playlist, by MBID.
pub async fn fetch(mbid: &str, token: Option<&str>) -> Result<ImportPlaylist, PlaylistImportError> {
let doc = get_json(&format!("{API}/1/playlist/{mbid}"), token).await?;
let pl = &doc["playlist"];
let tracks: Vec<ImportTrack> = pl["track"]
.as_array()
.into_iter()
.flatten()
.filter_map(map_track)
.collect();
if tracks.is_empty() {
return Err(PlaylistImportError::EmptyPlaylist);
}
Ok(ImportPlaylist {
provider: ImportProvider::ListenBrainz,
provider_id: mbid.to_string(),
name: pl["title"].as_str().unwrap_or("ListenBrainz playlist").to_string(),
description: pl["annotation"]
.as_str()
.map(str::to_string)
.filter(|s| !s.trim().is_empty()),
tracks,
})
}
/// JSPF track -> `ImportTrack`. Defensive: a shape change drops the row
/// instead of failing the import.
fn map_track(t: &Value) -> Option<ImportTrack> {
let title = t["title"].as_str().unwrap_or("").trim().to_string();
if title.is_empty() {
return None;
}
// `identifier` is the recording MBID as a URI; it goes to `provider_id` and
// NEVER to `isrc` — JSPF carries no ISRC, and an MBID in that field would
// misfire the matcher's exact-ISRC short circuit.
let recording_mbid = t["identifier"]
.as_str()
.or_else(|| t["identifier"][0].as_str())
.and_then(|s| s.rsplit('/').next())
.filter(|s| is_mbid(s))
.map(str::to_string);
Some(ImportTrack {
title,
artist: t["creator"].as_str().unwrap_or("").trim().to_string(),
album: t["album"]
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// JSPF duration is MILLISECONDS (it is XSPF in JSON) — no x1000.
duration_ms: t["duration"].as_u64().filter(|d| *d > 0),
isrc: None,
provider_url: recording_mbid
.as_deref()
.map(|m| format!("https://listenbrainz.org/track/{m}")),
provider_id: recording_mbid,
})
}
async fn get_json(url: &str, token: Option<&str>) -> Result<Value, PlaylistImportError> {
let mut req = http().get(url).header(reqwest::header::USER_AGENT, LB_USER_AGENT);
if let Some(t) = token.map(str::trim).filter(|t| !t.is_empty()) {
req = req.header(reqwest::header::AUTHORIZATION, format!("Token {t}"));
}
let resp = req
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(PlaylistImportError::Http(format!(
"ListenBrainz returned {}",
resp.status()
)));
}
resp.json::<Value>()
.await
.map_err(|e| PlaylistImportError::Parse(format!("ListenBrainz: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
const MBID: &str = "1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d";
#[test]
fn detect_reads_urls_mbids_and_usernames() {
assert_eq!(
detect(&format!("https://listenbrainz.org/playlist/{MBID}")),
Some(LbTarget::Mbid(MBID.into()))
);
// Trailing slash and query survive.
assert_eq!(
detect(&format!("https://listenbrainz.org/playlist/{MBID}/?x=1")),
Some(LbTarget::Mbid(MBID.into()))
);
assert_eq!(
detect("https://listenbrainz.org/user/rob"),
Some(LbTarget::User("rob".into()))
);
assert_eq!(detect(MBID), Some(LbTarget::Mbid(MBID.into())));
assert_eq!(detect(" rob_2 "), Some(LbTarget::User("rob_2".into())));
assert_eq!(detect(""), None);
assert_eq!(detect("not a name"), None);
assert_eq!(detect("https://example.com/x"), None);
}
#[test]
fn jspf_duration_is_milliseconds() {
let t: Value = serde_json::from_str(
r#"{"title":"Song","creator":"Band","album":"Al","duration":214000}"#,
)
.unwrap();
let m = map_track(&t).unwrap();
assert_eq!(m.duration_ms, Some(214_000));
assert_eq!(m.artist, "Band");
assert_eq!(m.album.as_deref(), Some("Al"));
}
#[test]
fn the_recording_mbid_lands_in_provider_id_never_in_isrc() {
let t: Value = serde_json::from_str(&format!(
r#"{{"title":"Song","creator":"B","identifier":"https://musicbrainz.org/recording/{MBID}"}}"#
))
.unwrap();
let m = map_track(&t).unwrap();
assert_eq!(m.provider_id.as_deref(), Some(MBID));
assert_eq!(m.isrc, None);
}
#[test]
fn a_titleless_track_is_dropped_rather_than_failing_the_import() {
let t: Value = serde_json::from_str(r#"{"creator":"B"}"#).unwrap();
assert!(map_track(&t).is_none());
}
}
@@ -0,0 +1,44 @@
//! Public-read services: ListenBrainz and Last.fm.
//!
//! # No authentication, by design
//!
//! Both arms read PUBLIC data with a username or a URL. That matches what the
//! importer already is — you import a public Spotify playlist without logging
//! into Spotify — and it honours the standing "integrations are strictly
//! opt-in" rule: connecting an account in Settings only PREFILLS the field, it
//! is never required, and nothing here writes to either service.
//!
//! The one optional credential is the ListenBrainz token, sent when the user
//! has already connected one: it raises the rate limit and reaches their own
//! private playlists. Absent, public reads work exactly the same.
//!
//! # No `qbz-integrations` edge
//!
//! `qbz-integrations` has a ListenBrainz client already, and it is the wrong
//! tool here for two concrete reasons, both verified in its source rather than
//! assumed: `get_playlist_tracks` returns tracks with NO duration field, and it
//! DROPS the playlist title and annotation (those come from the separate list
//! call). A direct `GET /1/playlist/<mbid>` reads title, annotation and
//! per-track JSPF duration in one request, in about thirty lines.
//!
//! It also keeps `cargo test -p qbz-playlist-import` free of that crate's
//! bundled `rusqlite` and `discord-rich-presence`, which is the only real
//! build-cost difference — and it is confined to this crate's own test loop.
//! (The final binary already links `qbz-integrations`, so the edge would have
//! changed app build cost by zero. That argument is not being made.)
pub mod lastfm;
pub mod listenbrainz;
/// A browser-like User-Agent for the scraper-class calls.
///
/// The crate's shared client sends NO User-Agent, which is right for the
/// providers that already work. Last.fm's CDN 403s an empty-UA request, so
/// every call in `lastfm.rs` sets this per-request. ListenBrainz asks for a
/// descriptive one instead and gets [`LB_USER_AGENT`].
pub(crate) const BROWSER_USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/126.0.0.0 Safari/537.36";
/// ListenBrainz asks API clients to identify themselves.
pub(crate) const LB_USER_AGENT: &str = "QBZ/2.0 ( https://github.com/vicrodh/qbz )";
@@ -51,6 +51,20 @@ Item {
readonly property bool importCompleted: doc.importCompleted === true
readonly property var folderOptions: doc.folderOptions || []
// --- Source picker (2.0.3 expansion) ----------------------------------
// 0 URL · 1 Playlist file · 2 JSON · 3 ListenBrainz · 4 Last.fm. The order
// is `playlist_import_qt::source_kind`; the labels come from the document
// pre-localized, like every other string here.
readonly property int sourceIndex: doc.sourceIndex || 0
readonly property bool srcUrl: root.sourceIndex === 0
readonly property bool srcFile: root.sourceIndex === 1
readonly property bool srcJson: root.sourceIndex === 2
readonly property bool srcLb: root.sourceIndex === 3
readonly property bool srcLastfm: root.sourceIndex === 4
/// File and JSON share their whole block except the caveat line.
readonly property bool srcAnyFile: root.srcFile || root.srcJson
readonly property bool srcAnyService: root.srcLb || root.srcLastfm
// Bumped by every QbzPlaylistImport.open(). The reference remounts the
// whole Svelte component per open and Slint reproduces it by clearing 14
// properties; here Rust resets its document and this counter tells the two
@@ -59,6 +73,7 @@ Item {
onResetSeqChanged: {
urlInput.text = ""
nameInput.text = ""
serviceInput.text = ""
}
// Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a relative
@@ -264,6 +279,30 @@ Item {
title: root.doc.error || ""
}
// --- Source picker ----------------------------------------
// The one control that is always visible. Everything below it
// is one source's block; they are mutually exclusive and a
// Column skips invisible children, so exactly one appears and
// nothing leaves a gap.
Column {
width: card.contentWidth
spacing: 8
Text {
text: QbzSession.tr("Source", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.loading
options: root.doc.sourceOptions || []
currentIndex: root.sourceIndex
onSelected: function (i) { QbzPlaylistImport.sourceChanged(i) }
}
}
// --- URL input --------------------------------------------
// Every keystroke routes the provider detection through Rust
// (the reference does the same because Slint 1.16 strings have
@@ -273,6 +312,7 @@ Item {
Column {
width: card.contentWidth
spacing: 8
visible: root.srcUrl
Text {
text: QbzSession.tr("Playlist URL", QbzSession.trRev)
color: theme.textSecondary
@@ -342,6 +382,9 @@ Item {
Column {
width: card.contentWidth
spacing: 8
// URL ONLY. Four dimmed streaming logos over a file picker
// would say the file has to come from one of them.
visible: root.srcUrl
Text {
text: QbzSession.tr("ALLOWED SOURCES", QbzSession.trRev)
color: theme.textMuted
@@ -393,6 +436,194 @@ Item {
}
}
// --- File / JSON block ------------------------------------
// ONLY THE TRACK LIST IS READ. The disclaimer is not fine
// print: a user handing over an .m3u reasonably expects the
// referenced audio to be imported, and it never is. It is a
// persistent row next to the picker, not a tooltip.
Column {
width: card.contentWidth
spacing: 8
visible: root.srcAnyFile
Row {
spacing: 12
// Ghost button, the port's neutral secondary shape.
Rectangle {
width: pickLabel.implicitWidth + 28
height: 36
radius: theme.radiusSm
color: (pickArea.containsMouse && !root.loading)
? theme.surfaceHover : theme.surfaceElevated
opacity: root.loading ? 0.5 : 1.0
Text {
id: pickLabel
anchors.centerIn: parent
text: QbzSession.tr("Choose file…", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLink
}
MouseArea {
id: pickArea
anchors.fill: parent
hoverEnabled: true
enabled: !root.loading
cursorShape: root.loading ? Qt.ArrowCursor
: Qt.PointingHandCursor
onClicked: QbzPlaylistImport.pickFile()
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, card.contentWidth - pickLabel.implicitWidth - 40)
text: (root.doc.pickedFileName || "") !== ""
? root.doc.pickedFileName
: QbzSession.tr("No file selected", QbzSession.trRev)
color: (root.doc.pickedFileName || "") !== ""
? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideMiddle
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: QbzSession.tr("Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
// JSON carries one extra caveat: the parse is best-effort
// and the preview COUNT is the user's gate on it.
Text {
visible: root.srcJson
width: parent.width
wrapMode: Text.WordWrap
text: QbzSession.tr("JSON is read best-effort — check the track count below before importing.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// --- ListenBrainz / Last.fm block -------------------------
// One handle field for both, because they take the same thing:
// a public username or a URL. Nothing is authenticated; a
// connected account only PREFILLS the field.
Column {
width: card.contentWidth
spacing: 8
visible: root.srcAnyService
Text {
text: root.srcLb
? QbzSession.tr("Username or playlist URL", QbzSession.trRev)
: QbzSession.tr("Username or profile URL", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
// The same 43px box recipe as the URL field, for the same
// reason: QbzLineEdit commits on Enter/blur and this must
// recompute per keystroke.
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: serviceInput.activeFocus ? theme.accent : theme.borderSubtle
opacity: root.loading ? 0.5 : 1.0
TextInput {
id: serviceInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: !root.loading
onTextEdited: QbzPlaylistImport.serviceInputEdited(text)
onAccepted: {
if (!root.showPreview && root.doc.canFetch === true && !root.loading)
QbzPlaylistImport.fetch()
}
Binding {
target: serviceInput
property: "text"
value: root.doc.serviceUser || ""
when: !serviceInput.activeFocus
}
}
Text {
visible: serviceInput.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("Enter a public username.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
}
// Last.fm PROFILE -> pick one of three stations. A
// PLAYLIST url (lastfmMode 1) skips this entirely and
// imports what the URL names.
Column {
width: parent.width
spacing: 8
visible: root.srcLastfm && (root.doc.lastfmMode || 0) === 0
Text {
text: QbzSession.tr("Station", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.loading
options: root.doc.stationOptions || []
currentIndex: root.doc.stationIndex || 0
onSelected: function (i) { QbzPlaylistImport.setStationIndex(i) }
}
}
// ListenBrainz USERNAME -> its "created for you" list. A
// pasted /playlist/<mbid> resolves on its own, so the
// picker only appears once there is something to pick.
Column {
width: parent.width
spacing: 8
visible: root.srcLb && (root.doc.lbPlaylistOptions || []).length > 0
Text {
text: QbzSession.tr("Playlist", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.loading
options: root.doc.lbPlaylistOptions || []
currentIndex: root.doc.lbPlaylistIndex || 0
onSelected: function (i) { QbzPlaylistImport.setLbPlaylistIndex(i) }
}
}
Text {
visible: root.srcLb && root.doc.lbListLoading === true
text: QbzSession.tr("Loading...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// --- Customization panel (step B) -------------------------
// Rename + optional folder. Editing the URL reverts to step A
// (`showPreview` recomputes Rust-side on every keystroke).
@@ -658,6 +889,19 @@ Item {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Only when the SOURCE repeated a track. A
// duplicate is not a skip, and folding it into one
// is what made a 453-of-469 match read as 198.
Text {
width: parent.width
visible: (root.doc.summaryDuplicates || "") !== ""
height: visible ? 18 : 0
text: root.doc.summaryDuplicates || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: parent.width
visible: (root.doc.summaryParts || "") !== ""
+16
View File
@@ -143,6 +143,22 @@ pub fn scrobble_settings() -> ScrobblerSettings {
scrobble().get_settings().unwrap_or_default()
}
/// The playlist importer's PREFILL + optional credential (2.0.3 expansion).
///
/// Returns `(lastfm_username, listenbrainz_username, listenbrainz_token)`.
///
/// STRICTLY A CONVENIENCE. Both service sources read PUBLIC data with a bare
/// username, so nothing here is required — a user who has connected nothing
/// types a handle and it works. The token is the one credential that changes
/// behaviour when present (higher ListenBrainz rate limits, and the user's own
/// private playlists resolve), and it is sent only because it is already
/// stored; the importer never asks for it.
pub fn scrobbler_handles() -> (String, String, Option<String>) {
let cfg = scrobble_settings();
let token = Some(cfg.listenbrainz_token.clone()).filter(|t| !t.trim().is_empty());
(cfg.lastfm_username, cfg.listenbrainz_username, token)
}
pub fn show_recommendations() -> bool {
with_discover(|s| s.load().show_recommendations).unwrap_or(true)
}
@@ -79,6 +79,40 @@ pub mod qbz_playlist_import_bridge {
/// progress.
#[qinvokable]
fn execute(self: Pin<&mut QbzPlaylistImport>);
// --- Source picker (2.0.3 expansion) -----------------------------
//
// Everything below feeds ONE `PlaylistSource`, which resolves to the
// same `ImportPlaylist` the URL path always produced. The rename,
// folder, progress, log and summary halves of this modal never learn
// that a second kind of source exists.
/// The picker changed (0 URL · 1 Playlist file · 2 JSON ·
/// 3 ListenBrainz · 4 Last.fm). Everything the previous source
/// contributed is dropped, and a service source prefills its handle
/// from the connected account when there is one.
#[qinvokable]
fn source_changed(self: Pin<&mut QbzPlaylistImport>, index: i32);
/// "Choose file…" — the native picker for the File and JSON sources.
/// The size wall is enforced on the PATH before the read, so an
/// oversize pick never reaches RAM.
#[qinvokable]
fn pick_file(self: Pin<&mut QbzPlaylistImport>);
/// The ListenBrainz / Last.fm username-or-URL field, per keystroke.
/// Recomputes the fetch gate, detects Last.fm profile-vs-playlist, and
/// loads the ListenBrainz "created for you" list for a username.
#[qinvokable]
fn service_input_edited(self: Pin<&mut QbzPlaylistImport>, text: QString);
/// Last.fm station picker (0 Library · 1 Mix · 2 Recommendations).
#[qinvokable]
fn set_station_index(self: Pin<&mut QbzPlaylistImport>, index: i32);
/// ListenBrainz "created for you" picker.
#[qinvokable]
fn set_lb_playlist_index(self: Pin<&mut QbzPlaylistImport>, index: i32);
}
impl cxx_qt::Threading for QbzPlaylistImport {}
@@ -150,4 +184,24 @@ impl qbz_playlist_import_bridge::QbzPlaylistImport {
pub fn execute(self: Pin<&mut Self>) {
crate::playlist_import_qt::execute();
}
pub fn source_changed(self: Pin<&mut Self>, index: i32) {
crate::playlist_import_qt::on_source_changed(index);
}
pub fn pick_file(self: Pin<&mut Self>) {
crate::playlist_import_qt::pick_file();
}
pub fn service_input_edited(self: Pin<&mut Self>, text: QString) {
crate::playlist_import_qt::on_service_input_edited(&text.to_string());
}
pub fn set_station_index(self: Pin<&mut Self>, index: i32) {
crate::playlist_import_qt::set_station_index(index);
}
pub fn set_lb_playlist_index(self: Pin<&mut Self>, index: i32) {
crate::playlist_import_qt::set_lb_playlist_index(index);
}
}
+564 -30
View File
@@ -56,11 +56,24 @@ use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use serde::Serialize;
use qbz_playlist_import::sources::service::{lastfm, listenbrainz};
use qbz_playlist_import::{
detect_provider_key, ImportEvent, ImportPhase, ImportPlaylist, ImportProgressSink,
ImportProvider, ImportSummary, ProviderKey,
ImportProvider, ImportSummary, LastFmStation, PlaylistImportError, PlaylistSource, ProviderKey,
MAX_IMPORT_BYTES,
};
/// The source picker's indices. They are the ONE place the picker order is
/// defined; the QML reads its labels from `sourceOptions` and sends back an
/// index, so a reorder here is a reorder there and nowhere else.
mod source_kind {
pub const URL: i32 = 0;
pub const FILE: i32 = 1;
pub const JSON: i32 = 2;
pub const LISTENBRAINZ: i32 = 3;
pub const LASTFM: i32 = 4;
}
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
// ---------------------------------------------------------------------------
@@ -123,6 +136,37 @@ struct ImportDoc {
#[serde(rename = "currentTrack")]
current_track: String,
log: Vec<LogEntry>,
// --- Source picker (2.0.3 expansion) --------------------------------
/// 0 URL · 1 Playlist file · 2 JSON · 3 ListenBrainz · 4 Last.fm.
#[serde(rename = "sourceIndex")]
source_index: i32,
/// The five localized picker labels, formatted here like every other
/// string in this document.
#[serde(rename = "sourceOptions")]
source_options: Vec<String>,
/// `""` = nothing picked yet.
#[serde(rename = "pickedFileName")]
picked_file_name: String,
/// The ListenBrainz / Last.fm username-or-URL field.
#[serde(rename = "serviceUser")]
service_user: String,
/// Last.fm only: 0 = a profile (show the station picker), 1 = a specific
/// playlist (import it directly, no station choice).
#[serde(rename = "lastfmMode")]
lastfm_mode: i32,
#[serde(rename = "stationOptions")]
station_options: Vec<String>,
#[serde(rename = "stationIndex")]
station_index: i32,
/// ListenBrainz "created for you" titles, filled after a valid username.
#[serde(rename = "lbPlaylistOptions")]
lb_playlist_options: Vec<String>,
#[serde(rename = "lbPlaylistIndex")]
lb_playlist_index: i32,
/// True while the "created for you" list is being fetched.
#[serde(rename = "lbListLoading")]
lb_list_loading: bool,
/// Index 0 is always `No folder` (id `""`), then the visible folders.
#[serde(rename = "folderOptions")]
folder_options: Vec<String>,
@@ -137,6 +181,11 @@ struct ImportDoc {
summary_matched: String,
#[serde(rename = "summarySkipped")]
summary_skipped: String,
/// `""` unless the source repeated a track. Its own line rather than a
/// footnote on "skipped": a duplicate is not a failure and reading it as
/// one is what made a 453-of-469 match look like a 198-of-469 one.
#[serde(rename = "summaryDuplicates")]
summary_duplicates: String,
#[serde(rename = "summaryParts")]
summary_parts: String,
}
@@ -167,12 +216,23 @@ struct State {
status_line: String,
current_track: String,
log: Vec<LogEntry>,
source_index: i32,
source_options: Vec<String>,
picked_file_name: String,
service_user: String,
lastfm_mode: i32,
station_options: Vec<String>,
station_index: i32,
lb_playlist_options: Vec<String>,
lb_playlist_index: i32,
lb_list_loading: bool,
folder_options: Vec<String>,
folder_ids: Vec<String>,
folder_index: i32,
summary_playlist: String,
summary_matched: String,
summary_skipped: String,
summary_duplicates: String,
summary_parts: String,
// ---- session-only (the reference's `Session`) ----
@@ -187,6 +247,15 @@ struct State {
last_imported_url: String,
/// 5 %-milestone tracker for the matching log lines (-1 = none yet).
last_logged_percent: i32,
// ---- source-specific session state (2.0.3) ----
/// The picked file's bytes. They live HERE, not on disk and not re-read at
/// execute time: the `rfd` handle is gone by then and the dialog cannot
/// silently reopen. Bounded by `MAX_IMPORT_BYTES` before the read.
file_bytes: Option<Vec<u8>>,
file_name: String,
/// The MBIDs parallel to `lb_playlist_options`.
lb_playlist_mbids: Vec<String>,
}
impl State {
@@ -198,14 +267,40 @@ impl State {
folder_options: opts,
folder_ids: vec![String::new()],
last_logged_percent: -1,
source_options: source_labels(),
station_options: station_labels(),
..State::default()
};
}
/// The per-source reset a picker change performs: everything the OLD
/// source put in the session goes, so a stale file or username can never
/// be what a later Fetch reads.
fn clear_source_inputs(&mut self) {
self.picked_file_name.clear();
self.file_bytes = None;
self.file_name.clear();
self.service_user.clear();
self.lastfm_mode = 0;
self.station_index = 0;
self.lb_playlist_options.clear();
self.lb_playlist_mbids.clear();
self.lb_playlist_index = 0;
self.lb_list_loading = false;
self.url.clear();
self.preview = None;
self.preview_url.clear();
self.locked_provider = None;
self.active_provider.clear();
self.show_preview = false;
self.can_fetch = false;
}
fn clear_summary(&mut self) {
self.summary_playlist.clear();
self.summary_matched.clear();
self.summary_skipped.clear();
self.summary_duplicates.clear();
self.summary_parts.clear();
}
@@ -216,6 +311,26 @@ impl State {
}
}
/// The picker's five labels, in index order (`source_kind`).
fn source_labels() -> Vec<String> {
vec![
qbz_i18n::t("Streaming URL"),
qbz_i18n::t("Playlist file"),
qbz_i18n::t("JSON file"),
qbz_i18n::t("ListenBrainz"),
qbz_i18n::t("Last.fm"),
]
}
/// The three Last.fm station labels, in `LastFmStation::from_index` order.
fn station_labels() -> Vec<String> {
vec![
qbz_i18n::t("Play your library"),
qbz_i18n::t("Play your mix"),
qbz_i18n::t("Play your recommendations"),
]
}
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| Mutex::new(State::default()));
/// Import generation (§1.8): bumped on every [`open`] and [`execute`]. Sink
@@ -272,12 +387,23 @@ fn build_doc(st: &State) -> ImportDoc {
status_line: st.status_line.clone(),
current_track: st.current_track.clone(),
log: st.log.clone(),
source_index: st.source_index,
source_options: st.source_options.clone(),
picked_file_name: st.picked_file_name.clone(),
service_user: st.service_user.clone(),
lastfm_mode: st.lastfm_mode,
station_options: st.station_options.clone(),
station_index: st.station_index,
lb_playlist_options: st.lb_playlist_options.clone(),
lb_playlist_index: st.lb_playlist_index,
lb_list_loading: st.lb_list_loading,
folder_options: st.folder_options.clone(),
folder_ids: st.folder_ids.clone(),
folder_index: st.folder_index,
summary_playlist: st.summary_playlist.clone(),
summary_matched: st.summary_matched.clone(),
summary_skipped: st.summary_skipped.clone(),
summary_duplicates: st.summary_duplicates.clone(),
summary_parts: st.summary_parts.clone(),
}
}
@@ -409,10 +535,316 @@ fn is_open() -> bool {
/// Recompute the URL-derived fields on every keystroke (the reference's
/// derived `detectedProvider` / `activeProvider` / `isValid` / `showPreview`),
/// plus the post-completion fresh-import rearm path.
/// "Does the ACTIVE source have enough input to Fetch?"
///
/// One rule per source, and the offline nuance is real rather than uniform:
/// FILE and JSON parse locally, so their preview works with no network at all
/// — which is the whole point for someone importing an M3U on a plane. Every
/// other source is a network read and is gated like the URL always was. The
/// IMPORT half needs a session regardless and is gated separately, so a file
/// can preview offline and still refuse to import.
fn compute_can_fetch(st: &State) -> bool {
let offline = crate::offline_fwd::engine().is_offline();
match st.source_index {
source_kind::FILE | source_kind::JSON => st.file_bytes.is_some(),
source_kind::LISTENBRAINZ => {
!offline
&& match listenbrainz::detect(&st.service_user) {
// A username alone is not enough: one of its "created for
// you" playlists has to be chosen first.
Some(listenbrainz::LbTarget::User(_)) => !st.lb_playlist_mbids.is_empty(),
Some(listenbrainz::LbTarget::Mbid(_)) => true,
None => false,
}
}
source_kind::LASTFM => !offline && lastfm::detect(&st.service_user).is_some(),
// URL (and any unexpected index) keeps the original rule.
_ => detect_provider_key(&st.url).is_some() && !offline,
}
}
/// The source picker changed. Everything the previous source contributed is
/// dropped — see `clear_source_inputs`.
pub fn on_source_changed(index: i32) {
// Read the stored handles BEFORE taking the lock — the store is a blocking
// read and this runs on the Qt thread.
let (lastfm_user, lb_user, _) = crate::integrations_qt::scrobbler_handles();
let mut prefill_lb: Option<String> = None;
{
let mut st = STATE.lock().unwrap();
if st.source_index == index {
return;
}
st.source_index = index;
st.clear_source_inputs();
// PREFILL, never a requirement (integrations stay opt-in): a connected
// account fills the field, an empty one leaves it blank and the source
// still works with any public handle typed by hand.
match index {
source_kind::LASTFM => {
st.service_user = lastfm_user;
if lastfm::detect(&st.service_user).is_some() {
st.lastfm_mode = 0;
}
}
source_kind::LISTENBRAINZ => {
st.service_user = lb_user;
if matches!(
listenbrainz::detect(&st.service_user),
Some(listenbrainz::LbTarget::User(_))
) {
prefill_lb = Some(st.service_user.clone());
}
}
_ => {}
}
st.error.clear();
st.import_completed = false;
st.progress_visible = false;
st.progress = 0.0;
st.log.clear();
st.clear_progress_lines();
st.clear_summary();
st.can_fetch = compute_can_fetch(&st);
}
publish();
// A prefilled ListenBrainz username needs its "created for you" list, or
// the picker would be empty until the user typed a character.
if let Some(user) = prefill_lb {
load_lb_created_for(user);
}
}
/// The ListenBrainz / Last.fm handle field, on every keystroke.
///
/// ANY edit drops the preview: it is the service analogue of editing the URL
/// away from the one that was fetched, and without it the footer button would
/// still say "Import" for a playlist the field no longer names.
pub fn on_service_input_edited(text: &str) {
let lb_user = {
let mut st = STATE.lock().unwrap();
st.service_user = text.to_string();
st.preview = None;
st.show_preview = false;
st.error.clear();
if st.source_index == source_kind::LASTFM {
// Profile vs playlist drives the sub-UI: a playlist URL imports
// directly, a profile offers the three stations.
st.lastfm_mode = match lastfm::detect(text) {
Some(lastfm::LastFmTarget::Playlist { .. }) => 1,
_ => 0,
};
}
// A ListenBrainz USERNAME needs its "created for you" list fetched
// before anything can be picked. An MBID needs nothing.
let want_list = st.source_index == source_kind::LISTENBRAINZ
&& matches!(
listenbrainz::detect(text),
Some(listenbrainz::LbTarget::User(_))
);
if !want_list {
st.lb_playlist_options.clear();
st.lb_playlist_mbids.clear();
st.lb_playlist_index = 0;
st.lb_list_loading = false;
}
st.can_fetch = compute_can_fetch(&st);
if want_list {
match listenbrainz::detect(text) {
Some(listenbrainz::LbTarget::User(u)) => Some(u),
_ => None,
}
} else {
None
}
};
publish();
if let Some(user) = lb_user {
load_lb_created_for(user);
}
}
/// Fetch the "created for you" dropdown for a ListenBrainz username.
///
/// Fired per keystroke while the field holds a username, so it is guarded the
/// same way every other detached task here is: the generation it started under
/// must still be current, AND the field must still hold the username it was
/// started for. Without the second check a fast typist gets the list for a
/// prefix of what they typed.
fn load_lb_created_for(user: String) {
{
let mut st = STATE.lock().unwrap();
st.lb_list_loading = true;
}
publish();
let generation = current_generation();
crate::spawn(async move {
let token = crate::integrations_qt::scrobbler_handles().2;
let res = listenbrainz::list_created_for(&user, token.as_deref()).await;
if generation != current_generation() {
return;
}
{
let mut st = STATE.lock().unwrap();
let still_ours = matches!(
listenbrainz::detect(&st.service_user),
Some(listenbrainz::LbTarget::User(ref u)) if *u == user
);
if !still_ours {
return;
}
st.lb_list_loading = false;
match res {
Ok(list) => {
st.lb_playlist_options = list.iter().map(|p| p.title.clone()).collect();
st.lb_playlist_mbids = list.iter().map(|p| p.mbid.clone()).collect();
st.lb_playlist_index = 0;
if st.lb_playlist_options.is_empty() {
st.error = qbz_i18n::t_args("No playlists found for {}.", &[&user]);
}
}
Err(e) => {
log::warn!("[qbz-qt] listenbrainz createdfor {user} failed: {e}");
st.lb_playlist_options.clear();
st.lb_playlist_mbids.clear();
}
}
st.can_fetch = compute_can_fetch(&st);
}
publish();
});
}
/// Last.fm station picker.
pub fn set_station_index(index: i32) {
{
let mut st = STATE.lock().unwrap();
st.station_index = index.clamp(0, 2);
// A different station is a different playlist.
st.preview = None;
st.show_preview = false;
}
publish();
}
/// ListenBrainz "created for you" picker.
pub fn set_lb_playlist_index(index: i32) {
{
let mut st = STATE.lock().unwrap();
if index < 0 || index as usize >= st.lb_playlist_mbids.len() {
return;
}
st.lb_playlist_index = index;
st.preview = None;
st.show_preview = false;
}
publish();
}
/// "Choose file…" — the native picker for the File and JSON sources.
///
/// THE SIZE CHECK HAPPENS BEFORE THE READ. `metadata().len()` on the path,
/// then refuse; the crate re-checks the byte length as defense in depth, but by
/// then a 2 GB pick would already be in RAM. This is the one place the wall
/// actually protects the process.
pub fn pick_file() {
let (json_mode, generation) = {
let st = STATE.lock().unwrap();
(st.source_index == source_kind::JSON, current_generation())
};
crate::spawn(async move {
let dialog = rfd::AsyncFileDialog::new().set_title(&qbz_i18n::t("Choose file…"));
let dialog = if json_mode {
dialog.add_filter(&qbz_i18n::t("JSON file"), &["json"])
} else {
dialog.add_filter(
&qbz_i18n::t("Playlist file"),
&["m3u", "m3u8", "pls", "xspf"],
)
};
let Some(handle) = dialog.pick_file().await else {
return; // cancelled — no toast, no state change.
};
let path = handle.path().to_path_buf();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let read = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, PlaylistImportError> {
let len = std::fs::metadata(&path)
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.len();
if len as usize > MAX_IMPORT_BYTES {
return Err(PlaylistImportError::FileTooLarge);
}
std::fs::read(&path).map_err(|e| PlaylistImportError::Http(e.to_string()))
})
.await;
// A reopen while the dialog was up must not land a file on the fresh
// modal (the same guard `fetch` carries).
if generation != current_generation() {
return;
}
{
let mut st = STATE.lock().unwrap();
// Any newly-picked file invalidates the previous preview.
st.preview = None;
st.show_preview = false;
match read {
Ok(Ok(bytes)) => {
st.error.clear();
st.file_bytes = Some(bytes);
st.file_name = name.clone();
st.picked_file_name = name;
}
Ok(Err(e)) => {
st.file_bytes = None;
st.file_name.clear();
st.picked_file_name.clear();
st.error = localize_import_error(&e);
}
Err(e) => {
log::warn!("[qbz-qt] file read task panicked: {e}");
st.file_bytes = None;
st.picked_file_name.clear();
st.error = qbz_i18n::t("Could not read the file.");
}
}
st.can_fetch = compute_can_fetch(&st);
}
publish();
});
}
/// A `PlaylistImportError` as something a user can read, in their language.
///
/// The semantic variants exist FOR this: matching a discriminant is what makes
/// a localized message possible at all, where the old `Parse(String)` would
/// have forced prefix-matching on an English string.
fn localize_import_error(e: &PlaylistImportError) -> String {
match e {
PlaylistImportError::UnrecognizedFormat => {
qbz_i18n::t("Unrecognized file format — use XSPF, PLS, M3U/M3U8, or JSON.")
}
PlaylistImportError::HlsManifest => {
qbz_i18n::t("This looks like a live-stream manifest, not a playlist.")
}
PlaylistImportError::EmptyPlaylist => qbz_i18n::t("No tracks found in the file."),
PlaylistImportError::FileTooLarge => qbz_i18n::t("That file is too large to import."),
PlaylistImportError::JsonShapeUnrecognized => {
qbz_i18n::t("Could not recognize a track list in this JSON.")
}
other => other.to_string(),
}
}
pub fn on_url_edited(text: &str) {
let trimmed = text.trim().to_string();
let detected = detect_provider_key(text);
let offline = crate::offline_fwd::engine().is_offline();
{
let mut st = STATE.lock().unwrap();
st.url = text.to_string();
@@ -432,7 +864,7 @@ pub fn on_url_edited(text: &str) {
let active = st.locked_provider.or(detected);
st.active_provider = active.map(|p| p.as_str()).unwrap_or("").to_string();
st.can_fetch = detected.is_some() && !offline;
st.can_fetch = compute_can_fetch(&st);
st.show_preview = st.preview.is_some() && trimmed == st.preview_url;
}
publish();
@@ -465,64 +897,127 @@ pub fn set_folder_index(index: i32) {
// Step A — fetch the preview
// ---------------------------------------------------------------------------
/// Step A gate + reset (the reference's `begin_fetch`). Returns the URL to
/// fetch, or `None` when the gate fails.
fn begin_fetch() -> Option<String> {
let url = {
/// Step A gate + reset. Returns the SOURCE to resolve, or `None` when the gate
/// fails.
///
/// It builds a whole [`PlaylistSource`] rather than a URL string, and that is
/// what makes the expansion additive: the snapshot is taken here, on the Qt
/// thread, under the lock — so the detached task never reads `STATE` again and
/// a reopen mid-fetch cannot change what it is fetching.
fn begin_fetch() -> Option<PlaylistSource> {
let source = {
let mut st = STATE.lock().unwrap();
if st.loading || !st.can_fetch {
return None;
}
let url = st.url.clone();
let detected = detect_provider_key(&url)?;
let source = build_source(&st)?;
st.preview = None;
st.preview_url.clear();
st.locked_provider = Some(detected);
// The locked provider / logo row belongs to the URL source only; the
// others have no provider logo to light up.
if st.source_index == source_kind::URL {
let detected = detect_provider_key(&st.url)?;
st.locked_provider = Some(detected);
st.active_provider = detected.as_str().to_string();
} else {
st.locked_provider = None;
st.active_provider.clear();
}
st.loading = true;
st.error.clear();
st.show_preview = false;
st.import_completed = false;
st.active_provider = detected.as_str().to_string();
st.progress = 0.0;
st.clear_progress_lines();
st.clear_summary();
st.log.clear();
st.progress_visible = true;
url
source
};
push_log(qbz_i18n::t("Checking playlist link..."), "info");
Some(url)
Some(source)
}
/// Step A. The preview needs no session (the reference notes this) — only the
/// execute does.
/// The active source as a resolvable value. Pure over the state — the caller
/// already holds the lock.
fn build_source(st: &State) -> Option<PlaylistSource> {
match st.source_index {
source_kind::FILE => {
let bytes = st.file_bytes.clone()?;
// Detection runs HERE rather than at parse time so a wrong pick
// fails before the modal flips into step B.
let format = qbz_playlist_import::sources::file::detect_format(&bytes, &st.file_name)
.ok()?;
Some(PlaylistSource::File {
format,
bytes,
filename: st.file_name.clone(),
})
}
source_kind::JSON => Some(PlaylistSource::Json {
bytes: st.file_bytes.clone()?,
filename: st.file_name.clone(),
}),
source_kind::LISTENBRAINZ => {
let token = crate::integrations_qt::scrobbler_handles().2;
match listenbrainz::detect(&st.service_user)? {
listenbrainz::LbTarget::Mbid(mbid) => {
Some(PlaylistSource::ListenBrainz { mbid, token })
}
listenbrainz::LbTarget::User(_) => {
let mbid = st
.lb_playlist_mbids
.get(st.lb_playlist_index.max(0) as usize)?
.clone();
Some(PlaylistSource::ListenBrainz { mbid, token })
}
}
}
source_kind::LASTFM => match lastfm::detect(&st.service_user)? {
lastfm::LastFmTarget::Playlist { user, id } => Some(PlaylistSource::LastFmPlaylist {
user,
playlist_id: id,
}),
lastfm::LastFmTarget::Profile { user } => Some(PlaylistSource::LastFmStation {
user,
station: LastFmStation::from_index(st.station_index),
}),
},
_ => Some(PlaylistSource::Url(st.url.clone())),
}
}
/// Step A. The preview needs no session — only the execute does. And for FILE
/// and JSON it needs no network either, which is why `compute_can_fetch` lets
/// those two through offline.
pub fn fetch() {
let Some(url) = begin_fetch() else {
let Some(source) = begin_fetch() else {
return;
};
// A reopen mid-fetch bumps the generation; the stale preview result must
// not land on the fresh modal (§1.8).
let generation = current_generation();
crate::spawn(async move {
let res = qbz_playlist_import::preview_public_playlist(&url).await;
let label = source.label();
let res = source.resolve().await;
if generation != current_generation() {
return;
}
match res {
Ok(p) => apply_preview_ok(&url, p),
Err(e) => apply_preview_err(&e.to_string()),
Ok(p) => apply_preview_ok(&label, p),
Err(e) => apply_preview_err(&localize_import_error(&e)),
}
});
}
/// Preview fetch succeeded.
fn apply_preview_ok(url: &str, preview: ImportPlaylist) {
fn apply_preview_ok(label: &str, preview: ImportPlaylist) {
let count = preview.tracks.len();
let provider = provider_display_name(&preview.provider);
{
let mut st = STATE.lock().unwrap();
st.custom_name = preview.name.clone();
st.preview_url = url.trim().to_string();
st.preview_url = label.trim().to_string();
st.preview = Some(preview);
}
push_log(
@@ -532,9 +1027,22 @@ fn apply_preview_ok(url: &str, preview: ImportPlaylist) {
{
let mut st = STATE.lock().unwrap();
st.loading = false;
// The URL input is disabled during the fetch, so it still equals the
// fetched URL — step B (rename + Import) becomes visible.
st.show_preview = st.url.trim() == st.preview_url;
// SOURCE-AWARE, and this is the line that makes the whole expansion
// reachable. The URL rule is "the field still holds what was fetched",
// which is right for a URL and impossible for the others: their URL
// field is hidden and permanently `""`, so `"" == label` would be false
// forever and the footer button would say "Fetch" for the rest of time
// — Import unreachable for every new source.
//
// For a non-URL source the preview that just landed IS the current one:
// any edit to the file or the handle clears it (`pick_file`,
// `on_service_input_edited`), so there is nothing for an equality check
// to protect against.
st.show_preview = if st.source_index == source_kind::URL {
st.url.trim() == st.preview_url
} else {
true
};
}
publish();
}
@@ -555,7 +1063,16 @@ fn apply_preview_err(err: &str) {
/// Everything the execute task needs, snapshotted before it spawns.
struct ExecuteArgs {
url: String,
/// THE RESOLVED PLAYLIST, cloned out of the session under the lock.
///
/// It used to be the URL, and the task re-fetched from it. That was a
/// double scrape on every URL import, and for the new sources it is not
/// merely wasteful but impossible: the `rfd` bytes of a picked file are
/// gone, and the dialog cannot silently reopen. Snapshotting also closes a
/// real race — `open()` resets the session, so a modal reopened between the
/// Import click and the task's read would have handed a detached task
/// `None` and imported nothing.
playlist: ImportPlaylist,
name_override: Option<String>,
/// Local folder id chosen in the dropdown (`""` = no folder).
folder_id: String,
@@ -570,7 +1087,8 @@ fn begin_execute() -> Option<ExecuteArgs> {
if st.loading || st.import_completed {
return None;
}
let source_name = st.preview.as_ref()?.name.clone();
let playlist = st.preview.as_ref()?.clone();
let source_name = playlist.name.clone();
// The rename goes out only when it differs from the source name; an empty
// rename falls back to the source name (reference Appendix A).
let custom = st.custom_name.trim().to_string();
@@ -580,7 +1098,6 @@ fn begin_execute() -> Option<ExecuteArgs> {
None
};
st.last_logged_percent = -1;
let url = st.preview_url.clone();
let folder_id = st
.folder_ids
.get(st.folder_index.max(0) as usize)
@@ -590,7 +1107,7 @@ fn begin_execute() -> Option<ExecuteArgs> {
st.error.clear();
st.progress_visible = true;
Some(ExecuteArgs {
url,
playlist,
name_override,
folder_id,
generation: bump_generation(),
@@ -624,8 +1141,8 @@ pub fn execute() {
let sink: Arc<dyn ImportProgressSink> = Arc::new(QtSink {
generation: args.generation,
});
let res = qbz_playlist_import::import_public_playlist(
&args.url,
let res = qbz_playlist_import::import_prepared_playlist(
args.playlist,
&client,
args.name_override.as_deref(),
false, // is_public — the reference hardcodes false, no toggle
@@ -725,6 +1242,15 @@ fn apply_execute_ok(summary: &ImportSummary) {
);
st.summary_skipped =
qbz_i18n::t_args("Skipped: {}", &[&summary.skipped_tracks.to_string()]);
// Hidden when there were none, which is every well-formed source.
st.summary_duplicates = if summary.duplicate_tracks > 0 {
qbz_i18n::t_args(
"Duplicates: {}",
&[&summary.duplicate_tracks.to_string()],
)
} else {
String::new()
};
st.summary_parts = if summary.parts_created > 1 {
parts_line(summary.parts_created)
} else {
@@ -930,6 +1456,14 @@ fn provider_display_name(provider: &ImportProvider) -> &'static str {
ImportProvider::AppleMusic => "Apple Music",
ImportProvider::Tidal => "Tidal",
ImportProvider::Deezer => "Deezer",
// The four added by the 2.0.3 expansion. "Playlist file" and
// "JSON file" are deliberately the SAME strings as the source picker's
// labels, so "Found 42 tracks from JSON file." and the picker read as
// one vocabulary.
ImportProvider::File => "Playlist file",
ImportProvider::Json => "JSON file",
ImportProvider::ListenBrainz => "ListenBrainz",
ImportProvider::LastFm => "Last.fm",
}
}