playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+190
View File
@@ -946,6 +946,196 @@ pub async fn playlist_import_youtube(
Ok(draft)
}
/// Import a YouTube PLAYLIST — one row per video, off the playlist page's
/// own metadata (title, channel, length). No tools, no audio, no listening:
/// this is the same honest metadata-import posture as the DJ-set door, for
/// links where each video IS one track.
#[tauri::command]
pub async fn playlist_import_youtube_playlist(
lib: State<'_, LibraryState>,
url: String,
) -> Result<ImportDraft, String> {
let fetched = crate::youtube_playlist::fetch(&url).await?;
let parsed = crate::youtube_playlist::to_parsed(&fetched);
let source = if fetched.author.is_empty() {
fetched.title.clone()
} else {
format!("{} — {}", fetched.author, fetched.title)
};
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = "youtube-playlist".into();
draft.name = Some(fetched.title.clone());
// The first video's thumbnail is the playlist's default face — the same
// choice YouTube itself makes.
if let Some(first) = fetched.rows.first() {
draft.cover_url = Some(format!(
"https://i.ytimg.com/vi/{}/maxresdefault.jpg",
first.video_id
));
}
draft.warnings.insert(
0,
format!(
"{} video{} from the playlist.",
fetched.rows.len(),
if fetched.rows.len() == 1 { "" } else { "s" }
),
);
draft.warnings.extend(fetched.warnings);
Ok(draft)
}
/// Import a playlist from any supported LINK — YouTube, Spotify, Apple
/// Music, TIDAL, Deezer — one door, dispatched by host. Each source is
/// metadata-only and anonymous; rows ride the same resolve → review →
/// commit pipeline as every other import.
#[tauri::command]
pub async fn playlist_import_link(
lib: State<'_, LibraryState>,
url: String,
) -> Result<ImportDraft, String> {
// YouTube keeps its own module (lockup pages, continuations, title
// cleaning); everything else is a provider fetch.
if crate::youtube_playlist::playlist_id_from_url(&url).is_some() {
return playlist_import_youtube_playlist(lib, url).await;
}
let fetched = crate::provider_playlist::fetch(&url).await?;
let parsed = crate::provider_playlist::to_parsed(&fetched);
let source = if fetched.author.is_empty() {
fetched.title.clone()
} else {
format!("{} — {}", fetched.author, fetched.title)
};
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = fetched.provider.into();
draft.name = Some(fetched.title.clone());
draft.cover_url = fetched.cover_url.clone();
draft.warnings.insert(
0,
format!(
"{} track{} from the playlist.",
fetched.rows.len(),
if fetched.rows.len() == 1 { "" } else { "s" }
),
);
draft.warnings.extend(fetched.warnings);
Ok(draft)
}
/// Track links one paste will look up. Each costs a page fetch, so this is a
/// politeness ceiling as much as a sanity one.
const MAX_PASTED_LINKS: usize = 2000;
/// Import a pasted block of TRACK links — the way past a service's own
/// playlist ceiling. Spotify's public playlist page stops at 100 tracks, but
/// every track has its own public page, and Spotify's app hands over all the
/// links at once (select all, copy). Each line is looked up on its own; a
/// line that is not a link is read as ordinary "Artist - Title" text, so a
/// mixed paste works too.
#[tauri::command]
pub async fn playlist_import_track_links(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
text: String,
name: Option<String>,
) -> Result<ImportDraft, String> {
let lines = crate::provider_playlist::pasted_lines(&text);
if lines.is_empty() {
return Err("Nothing to import in that paste.".into());
}
let link_count = lines
.iter()
.filter(|l| matches!(l, crate::provider_playlist::PastedLine::Link(_)))
.count();
if link_count > MAX_PASTED_LINKS {
return Err(format!(
"That paste holds {link_count} track links — more than the {MAX_PASTED_LINKS} one import takes."
));
}
// The lookups take a page each, so the modal gets a live count instead of
// a frozen button.
let emitter = app.clone();
let outcome =
crate::provider_playlist::resolve_pasted(lines, move |done, total, waiting| {
let _ = emitter.emit(
"playlist:link-progress",
serde_json::json!({ "done": done, "total": total, "waiting": waiting }),
);
})
.await?;
let total = outcome.links;
let unread = outcome.rows.iter().filter(|r| r.is_none()).count();
let entries: Vec<_> = outcome
.rows
.into_iter()
.flatten()
.enumerate()
.map(|(i, r)| playlist_formats::ParsedEntry {
ordinal: i as u32,
title: Some(r.title.clone()),
artist: (!r.artist.is_empty()).then(|| r.artist.clone()),
album: r.album.clone(),
duration_secs: r.duration_secs,
location: None,
start_ms: None,
raw: format!("{} - {}", r.artist, r.title),
})
.collect();
if entries.is_empty() {
return Err("None of those links could be read.".into());
}
let parsed = playlist_formats::ParsedPlaylist {
format: "links".into(),
title: name.clone(),
description: None,
entries,
warnings: Vec::new(),
};
let source = name.clone().unwrap_or_else(|| "Pasted track links".into());
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = "links".into();
draft.name = name;
draft.warnings.insert(
0,
format!(
"{} track{} read from {total} pasted link{}.",
draft.entries.len(),
if draft.entries.len() == 1 { "" } else { "s" },
if total == 1 { "" } else { "s" }
),
);
// A link the service refused us on is a DIFFERENT failure from a link
// that names nothing, and only one of them is worth retrying.
let missing = unread.saturating_sub(outcome.throttled_out);
if outcome.throttled_out > 0 {
draft.warnings.insert(
1,
format!(
"{} link{} went unread because the service throttled the lookups. \
Paste those again in a few minutes and they will come through.",
outcome.throttled_out,
if outcome.throttled_out == 1 { "" } else { "s" }
),
);
} else if outcome.was_throttled {
draft.warnings.insert(
1,
"The service throttled the lookups partway; the import slowed down and \
finished anyway."
.into(),
);
}
if missing > 0 {
draft.warnings.push(format!(
"{missing} line{} named nothing readable and {} left out.",
if missing == 1 { "" } else { "s" },
if missing == 1 { "was" } else { "were" }
));
}
Ok(draft)
}
/// Commit a reviewed draft as a new playlist, atomically.
#[tauri::command]
pub async fn playlist_import_commit(