Pulls in the read-side extractor surfaces Straw needs at app open
(search bar) + on detail screens (channel + playlist).
src/youtube/linkhandler/
* mod.rs — ACCEPTED_HOSTS allowlist (youtube.com /
youtube-nocookie.com / youtu.be / m.youtube.com /
music.youtube.com); 27 Invidious mirror hosts
intentionally dropped (SPEC §6.6).
* stream.rs — extract_video_id() handles /watch?v= / youtu.be/ /
/embed/ / /shorts/ / /v/ / /live/ / attribution_link;
strict 11-char [A-Za-z0-9_-] validation.
* channel.rs — ChannelIdentifier enum (DirectId / Handle / Custom /
LegacyUser). Resolution to UC… id lands in
youtube/channel.rs.
* playlist.rs — extracts ?list=<PLid> from /playlist and /watch URLs.
* search.rs — SearchFilter enum + params() opaque base64 strings +
uses_music_endpoint() routing flag.
src/youtube/search_extractor.rs
* search(query, filter) → SearchInfo { query, corrected_query,
videos, continuation_token }
* Walks twoColumnSearchResultsRenderer → sectionListRenderer →
itemSectionRenderer → videoRenderer (+ shelfRenderer recursion).
* Parses YT duration strings, view-count abbreviations ('1.5M views'),
publishedTimeText, ownerBadges verified flag, badge LIVE flag.
* Music-search filters route to WEB_REMIX — flagged as not-yet-impl.
src/youtube/suggestion_extractor.rs
* suggestions(query) → Vec<String> via the suggestqueries-clients6
endpoint; handles both XSSI-prefixed and bare JSON responses.
src/youtube/channel.rs
* resolve_handle_to_channel_id() via /youtubei/v1/navigation/resolve_url
* channel_info(ChannelIdentifier) → ChannelInfo {
name, description, avatars, banners, subscriber_count, verified,
recent_videos, videos_continuation
}
* Parses both c4TabbedHeaderRenderer (most common) and the newer
pageHeaderRenderer flavor.
* subscriber_count parser handles K/M/B suffixes.
src/youtube/playlist_extractor.rs
* playlist_info(playlist_id) → PlaylistInfo with first-page video
list + continuation_token. Browses with browseId='VL<id>'.
* Walks playlistMetadataRenderer + playlistSidebarRenderer + the
playlistVideoListRenderer.contents[] for video items.
Tests: 121 lib unit pass (+44 since Phase 5). All previous phase smoke
tests still green.
What's left:
* Phase 6 kiosks (Trending etc) — minor, deferred
* Phase 7 — UniFFI surface swap into Straw (Straw repo work)
* Phase 8 — delete rustypipe (Straw repo work)
81 lines
2.4 KiB
Rust
81 lines
2.4 KiB
Rust
// YoutubePlaylistLinkHandlerFactory — accepts:
|
|
// * https://www.youtube.com/playlist?list=<PLid>
|
|
// * https://www.youtube.com/watch?v=...&list=<PLid>
|
|
// * https://music.youtube.com/playlist?list=<PLid>
|
|
//
|
|
// YT playlist IDs prefix:
|
|
// * PL user-curated playlists
|
|
// * RD mix / radio
|
|
// * OLAK5uy_ album / single
|
|
// * LL liked-videos (private — won't extract anonymously)
|
|
// * WL watch-later (private)
|
|
// * UU uploads (auto-generated per channel)
|
|
|
|
use url::Url;
|
|
|
|
use crate::youtube::linkhandler::{host_is_youtube, LinkError};
|
|
|
|
pub fn extract_playlist_id(url_str: &str) -> Result<String, LinkError> {
|
|
let url = Url::parse(url_str)
|
|
.map_err(|e| LinkError::InvalidUrl(format!("{url_str}: {e}")))?;
|
|
let host = url
|
|
.host_str()
|
|
.ok_or_else(|| LinkError::InvalidUrl("no host".into()))?;
|
|
if !host_is_youtube(host) {
|
|
return Err(LinkError::UnsupportedHost(host.into()));
|
|
}
|
|
url.query_pairs()
|
|
.find(|(k, _)| k == "list")
|
|
.map(|(_, v)| v.into_owned())
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| LinkError::MissingId(url_str.into()))
|
|
}
|
|
|
|
pub fn playlist_url(playlist_id: &str) -> String {
|
|
format!("https://www.youtube.com/playlist?list={playlist_id}")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn standalone_playlist() {
|
|
let id = extract_playlist_id(
|
|
"https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(id, "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj");
|
|
}
|
|
|
|
#[test]
|
|
fn watch_with_list() {
|
|
let id = extract_playlist_id(
|
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLxxx",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(id, "PLxxx");
|
|
}
|
|
|
|
#[test]
|
|
fn music_subdomain() {
|
|
let id = extract_playlist_id(
|
|
"https://music.youtube.com/playlist?list=OLAK5uy_kFooBar",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(id, "OLAK5uy_kFooBar");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_no_list_param() {
|
|
let err = extract_playlist_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
.unwrap_err();
|
|
assert!(matches!(err, LinkError::MissingId(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_youtube_host() {
|
|
let err = extract_playlist_id("https://invidious.io/playlist?list=PLxxx").unwrap_err();
|
|
assert!(matches!(err, LinkError::UnsupportedHost(_)));
|
|
}
|
|
}
|