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:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user