Phase 6 — Search + Channel + Playlist + LinkHandler

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)
This commit is contained in:
Sulkta 2026-05-24 17:16:14 -07:00
parent 1533157485
commit 0a2d79c743
10 changed files with 1663 additions and 0 deletions

View file

@ -0,0 +1,70 @@
// LinkHandler factories — URL parsing + URL building for YouTube
// resource categories. Mirrors NPE
// services/youtube/linkHandler/Youtube*LinkHandlerFactory.java.
//
// PORT SCOPE (per SPEC §6.6): we keep youtube.com / youtube-nocookie.com
// / youtu.be / m.youtube.com / music.youtube.com. The 27-host Invidious
// mirror list in NPE is dropped — Sulkta isn't an Invidious mirror.
pub mod channel;
pub mod playlist;
pub mod search;
pub mod stream;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LinkError {
#[error("invalid url: {0}")]
InvalidUrl(String),
#[error("unsupported host: {0}")]
UnsupportedHost(String),
#[error("missing id in url: {0}")]
MissingId(String),
#[error("malformed id: {0}")]
MalformedId(String),
}
/// The acceptable hosts for first-party YT links. Audit Track D §6.
pub const ACCEPTED_HOSTS: &[&str] = &[
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
"www.youtube-nocookie.com",
];
pub fn host_is_youtube(host: &str) -> bool {
let h = host.to_ascii_lowercase();
let h = h.strip_prefix("www.").unwrap_or(&h);
ACCEPTED_HOSTS
.iter()
.any(|allowed| {
let allowed = allowed.strip_prefix("www.").unwrap_or(allowed);
allowed == h
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_first_party_hosts() {
assert!(host_is_youtube("www.youtube.com"));
assert!(host_is_youtube("youtube.com"));
assert!(host_is_youtube("m.youtube.com"));
assert!(host_is_youtube("music.youtube.com"));
assert!(host_is_youtube("youtu.be"));
assert!(host_is_youtube("WWW.YouTube.COM")); // case-insensitive
}
#[test]
fn rejects_invidious_and_random() {
assert!(!host_is_youtube("invidious.io"));
assert!(!host_is_youtube("yewtu.be"));
assert!(!host_is_youtube("piped.video"));
assert!(!host_is_youtube("evil.com"));
}
}