pull/370/head
zijiren233 2 months ago
parent a5a39a89d1
commit 924312cb66
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -11,7 +11,7 @@ import { Aside, Steps } from '@astrojs/starlight/components';
1. **一个 Provider 一个模型边界。** DTO、URL 解析、签名、分页、登录、播放格式和错误映射放在该 Provider 模块内。
2. **按 Provider 实际能力建模。** 视频网站、直播平台、媒体服务器和 NAS 使用各自的 source、target、pagination 和 playback metadata。
3. **复用稳定核心契约。** `MediaProvider`、`DynamicFolder`、`PlaybackResult`、`SourceConfig`、`ProviderTarget`、凭据仓库和 transport action 是跨 Provider 共享边界。
3. **复用稳定核心契约。** `MediaProvider`、`DynamicPlaylistProvider`、`PlaybackResult`、`SourceConfig`、`ProviderTarget`、凭据仓库和 transport action 是跨 Provider 共享边界。
4. **typed config 贯穿发现与创建。** Parse、resolve、list、search 和 preview 返回可直接提交的 media 或 playlist source config。
5. **生成 URL 必须可解析。** Provider 生成的 stream、manifest、segment、subtitle、danmaku、thumbnail 和 cover URL 都需要对应 resolver 与测试。
6. **本地与远程行为一致。** 内嵌 client、remote provider gRPC 和公开 SyncTV HTTP/gRPC 暴露相同业务语义。
@ -41,7 +41,7 @@ import { Aside, Steps } from '@astrojs/starlight/components';
1. 定义 Provider 的实际能力和产品流程。
2. 实现独立上游 client、DTO 与 WireMock 契约测试。
3. 定义持久化 source config、target 和凭据模型。
4. 实现 Core `MediaProvider`,需要浏览时实现 `DynamicFolder`。
4. 实现 Core `MediaProvider`,需要动态播放列表能力时实现 `DynamicPlaylistProvider`。
5. 添加内部 remote-provider protobuf、server 和 client transport。
6. 添加公开 source config、Provider API 和 playback-provider protobuf。
7. 接入 adapter、management、HTTP、gRPC、OpenAPI 和 CLI。

@ -11,7 +11,7 @@ Use this guide when adding a Provider or extending an existing one. Each Provide
1. **One model boundary per Provider.** Keep DTOs, URL parsing, signing, pagination, login, playback formats, and error mapping in that Provider module.
2. **Model actual upstream capabilities.** Video sites, live platforms, media servers, and NAS systems keep their own source, target, pagination, and playback metadata.
3. **Reuse stable core contracts.** `MediaProvider`, `DynamicFolder`, `PlaybackResult`, `SourceConfig`, `ProviderTarget`, credential repositories, and transport actions are shared boundaries.
3. **Reuse stable core contracts.** `MediaProvider`, `DynamicPlaylistProvider`, `PlaybackResult`, `SourceConfig`, `ProviderTarget`, credential repositories, and transport actions are shared boundaries.
4. **Carry typed configs through discovery and creation.** Parse, resolve, list, search, and preview results return media or playlist source configs that clients can submit directly.
5. **Resolve every generated URL.** Stream, manifest, segment, subtitle, danmaku, thumbnail, and cover URLs need matching resolvers and tests.
6. **Keep local and remote behavior aligned.** Embedded clients, remote-provider gRPC, and public SyncTV HTTP/gRPC expose the same business semantics.
@ -41,7 +41,7 @@ Use this guide when adding a Provider or extending an existing one. Each Provide
1. Define the Provider's actual capabilities and product workflow.
2. Implement the private upstream client, DTOs, and WireMock contract tests.
3. Define persistent source configs, targets, and credential models.
4. Implement Core `MediaProvider`, plus `DynamicFolder` when browsing is supported.
4. Implement Core `MediaProvider`, plus `DynamicPlaylistProvider` when dynamic playlists are supported.
5. Add internal remote-provider protobuf, server, and client transport.
6. Add public source-config, Provider API, and playback-provider protobuf.
7. Wire adapter, management, HTTP, gRPC, OpenAPI, and CLI layers.

@ -282,9 +282,6 @@ pub fn playlist_source_config_from_proto(
shared,
}
}
Source::Trending(_) => {
synctv_core::models::YoutubePlaylistSourceConfig::Trending { shared }
}
Source::Subscriptions(_) => {
synctv_core::models::YoutubePlaylistSourceConfig::Subscriptions { shared }
}

@ -26,6 +26,24 @@ use super::{
usize_to_i64_api, usize_to_u32_api, usize_to_u64_api, AdminApiImpl, ApiError,
};
fn dynamic_pagination_from_proto(
pagination: Option<&synctv_proto::client::list_playlist_items_request::Pagination>,
) -> Result<DynamicPagination, ApiError> {
Ok(match pagination {
Some(synctv_proto::client::list_playlist_items_request::Pagination::Page(pagination)) => {
DynamicPagination::Page {
page: page_u32_to_usize(pagination.page)?,
}
}
Some(synctv_proto::client::list_playlist_items_request::Pagination::Cursor(pagination)) => {
DynamicPagination::Cursor {
cursor: Some(pagination.cursor.clone()).filter(|value| !value.is_empty()),
}
}
None => DynamicPagination::Page { page: 1 },
})
}
impl AdminApiImpl {
async fn media_to_proto_for_admin_with_loaded_cover(
&self,
@ -663,36 +681,9 @@ impl AdminApiImpl {
return Ok(response);
}
let page = match req.pagination.as_ref() {
Some(synctv_proto::client::list_playlist_items_request::Pagination::Page(page)) => {
page_u32_to_usize(page.page)?
}
_ => 1,
};
let page_size = crate::impls::proto_page_size_u32_usize(req.page_size, 50, 100)?;
let search = crate::impls::client::media::normalize_non_empty_filter(&req.search);
let pagination = if playlist.source_provider
== Some(synctv_core::models::SourceProvider::Cloudreve)
&& search.is_none()
{
match req.pagination.as_ref() {
Some(synctv_proto::client::list_playlist_items_request::Pagination::Page(
pagination,
)) => DynamicPagination::Page {
page: page_u32_to_usize(pagination.page)?,
},
Some(
synctv_proto::client::list_playlist_items_request::Pagination::Cursor(
pagination,
),
) => DynamicPagination::Cursor {
cursor: Some(pagination.cursor.clone()).filter(|value| !value.is_empty()),
},
None => DynamicPagination::Cursor { cursor: None },
}
} else {
DynamicPagination::Page { page }
};
let pagination = dynamic_pagination_from_proto(req.pagination.as_ref())?;
let result = self
.room_service
.media_service()
@ -1318,3 +1309,36 @@ impl AdminApiImpl {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use synctv_proto::client::{
list_playlist_items_request::Pagination, CursorPagination, PagePagination,
};
#[test]
fn dynamic_pagination_preserves_page_requests() {
let pagination =
dynamic_pagination_from_proto(Some(&Pagination::Page(PagePagination { page: 3 })))
.expect("page pagination should convert");
assert_eq!(pagination, DynamicPagination::Page { page: 3 });
}
#[test]
fn dynamic_pagination_preserves_cursor_requests() {
let pagination =
dynamic_pagination_from_proto(Some(&Pagination::Cursor(CursorPagination {
cursor: "next-token".to_string(),
})))
.expect("cursor pagination should convert");
assert_eq!(
pagination,
DynamicPagination::Cursor {
cursor: Some("next-token".to_string())
}
);
}
}

@ -1207,7 +1207,7 @@ pub(crate) fn playlist_source_config_to_proto(
}
synctv_core::models::PlaylistSourceConfig::Youtube(config) => {
use source_config_proto::youtube_playlist_source_config::{
Channel, LikedVideos, Playlist, Search, Source, Subscriptions, Trending, WatchLater,
Channel, LikedVideos, Playlist, Search, Source, Subscriptions, WatchLater,
};
let (shared, source) = match config {
synctv_core::models::YoutubePlaylistSourceConfig::Playlist {
@ -1238,9 +1238,6 @@ pub(crate) fn playlist_source_config_to_proto(
synctv_core::models::YoutubePlaylistSourceConfig::Search { query, shared } => {
(shared, Source::Search(Search { query }))
}
synctv_core::models::YoutubePlaylistSourceConfig::Trending { shared } => {
(shared, Source::Trending(Trending {}))
}
synctv_core::models::YoutubePlaylistSourceConfig::Subscriptions { shared } => {
(shared, Source::Subscriptions(Subscriptions {}))
}

@ -741,10 +741,6 @@ pub enum YoutubePlaylistSourceConfig {
#[serde(default)]
shared: bool,
},
Trending {
#[serde(default)]
shared: bool,
},
Subscriptions {
#[serde(default)]
shared: bool,

@ -452,7 +452,6 @@ impl YoutubeProvider {
YoutubePlaylistSourceConfig::Playlist { shared, .. }
| YoutubePlaylistSourceConfig::Channel { shared, .. }
| YoutubePlaylistSourceConfig::Search { shared, .. }
| YoutubePlaylistSourceConfig::Trending { shared }
| YoutubePlaylistSourceConfig::Subscriptions { shared }
| YoutubePlaylistSourceConfig::LikedVideos { shared }
| YoutubePlaylistSourceConfig::WatchLater { shared } => *shared,
@ -504,11 +503,6 @@ impl YoutubeProvider {
.search(query, cursor, visitor_data, cookie)
.await?
}
YoutubePlaylistSourceConfig::Trending { .. } => {
self.client
.feed("FEtrending", cursor, visitor_data, cookie)
.await?
}
YoutubePlaylistSourceConfig::Subscriptions { .. } => {
self.client
.feed("FEsubscriptions", cursor, visitor_data, cookie)

@ -300,7 +300,7 @@ fn playlist_source_config_to_proto(
}
synctv_core::models::PlaylistSourceConfig::Youtube(config) => {
use source_config_proto::youtube_playlist_source_config::{
Channel, LikedVideos, Playlist, Search, Source, Subscriptions, Trending, WatchLater,
Channel, LikedVideos, Playlist, Search, Source, Subscriptions, WatchLater,
};
let (shared, source) = match config {
synctv_core::models::YoutubePlaylistSourceConfig::Playlist {
@ -331,9 +331,6 @@ fn playlist_source_config_to_proto(
synctv_core::models::YoutubePlaylistSourceConfig::Search { query, shared } => {
(shared, Source::Search(Search { query }))
}
synctv_core::models::YoutubePlaylistSourceConfig::Trending { shared } => {
(shared, Source::Trending(Trending {}))
}
synctv_core::models::YoutubePlaylistSourceConfig::Subscriptions { shared } => {
(shared, Source::Subscriptions(Subscriptions {}))
}

@ -84,7 +84,7 @@ base64.workspace = true
hex.workspace = true
[features]
default = []
default = ["tls-aws-lc"]
tls-aws-lc = [
"synctv-common/tls-aws-lc",
"reqwest/rustls",

@ -276,7 +276,7 @@ impl YoutubeClient {
}
let query = cursor.filter(|value| !value.trim().is_empty()).map_or_else(
|| json!({"browseId": format!("VL{playlist_id}")}),
|cursor| json!({"continuation": cursor}),
continuation_query,
);
self.list_api("browse", query, visitor_data, cookie).await
}
@ -292,7 +292,7 @@ impl YoutubeClient {
let browse_id = normalize_channel_id(browse_id)?;
let query = cursor.filter(|value| !value.trim().is_empty()).map_or_else(
|| json!({"browseId": browse_id, "params": tab.params()}),
|cursor| json!({"continuation": cursor}),
continuation_query,
);
self.list_api("browse", query, visitor_data, cookie).await
}
@ -304,10 +304,9 @@ impl YoutubeClient {
visitor_data: Option<&str>,
cookie: Option<&str>,
) -> Result<YoutubeListPage, ProviderClientError> {
let query = cursor.filter(|value| !value.trim().is_empty()).map_or_else(
|| json!({"browseId": browse_id}),
|cursor| json!({"continuation": cursor}),
);
let query = cursor
.filter(|value| !value.trim().is_empty())
.map_or_else(|| json!({"browseId": browse_id}), continuation_query);
self.list_api("browse", query, visitor_data, cookie).await
}
@ -326,7 +325,7 @@ impl YoutubeClient {
}
let body = cursor.filter(|value| !value.trim().is_empty()).map_or_else(
|| json!({"query": query, "params": "EgIQAQ%3D%3D"}),
|cursor| json!({"continuation": cursor}),
continuation_query,
);
self.list_api("search", body, visitor_data, cookie).await
}
@ -367,6 +366,13 @@ impl YoutubeClient {
}
}
fn continuation_query(cursor: &str) -> serde_json::Value {
json!({
"continuation": percent_encoding::percent_decode_str(cursor.trim())
.decode_utf8_lossy()
})
}
fn with_youtube_cookie_auth(
request: reqwest::RequestBuilder,
cookie: Option<&str>,
@ -602,6 +608,16 @@ fn visit_renderers(value: &serde_json::Value, page: &mut YoutubeListPage) {
}
}
}
"lockupViewModel" => {
if let Some(item) = parse_lockup_view_model(value) {
push_unique_list_item(page, item);
}
}
"shortsLockupViewModel" => {
if let Some(item) = parse_shorts_lockup_view_model(value) {
push_unique_list_item(page, item);
}
}
"continuationCommand" => {
if page.next_cursor.is_none() {
page.next_cursor = value
@ -618,6 +634,157 @@ fn visit_renderers(value: &serde_json::Value, page: &mut YoutubeListPage) {
}
}
fn push_unique_list_item(page: &mut YoutubeListPage, item: YoutubeListItem) {
if !page
.items
.iter()
.any(|existing| existing.video_id == item.video_id)
{
page.items.push(item);
}
}
fn parse_lockup_view_model(value: &serde_json::Value) -> Option<YoutubeListItem> {
if value.get("contentType").and_then(serde_json::Value::as_str)
!= Some("LOCKUP_CONTENT_TYPE_VIDEO")
{
return None;
}
let video_id = value.get("contentId")?.as_str()?.to_string();
let metadata = value.pointer("/metadata/lockupMetadataViewModel")?;
let title = metadata.pointer("/title/content")?.as_str()?.to_string();
let metadata_rows = metadata
.pointer("/metadata/contentMetadataViewModel/metadataRows")
.and_then(serde_json::Value::as_array);
let metadata_parts = metadata_rows
.into_iter()
.flatten()
.flat_map(|row| {
row.get("metadataParts")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
})
.collect::<Vec<_>>();
let metadata_text = metadata_parts
.iter()
.filter_map(|part| {
part.pointer("/text/content")
.and_then(serde_json::Value::as_str)
})
.collect::<Vec<_>>();
let channel_part = metadata_parts.iter().find(|part| {
part.pointer("/text/commandRuns/0/onTap/innertubeCommand/browseEndpoint/browseId")
.is_some()
});
let channel_name = channel_part
.and_then(|part| part.pointer("/text/content"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let channel_id = channel_part
.and_then(|part| {
part.pointer("/text/commandRuns/0/onTap/innertubeCommand/browseEndpoint/browseId")
})
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let badge_text = value
.pointer("/contentImage/thumbnailViewModel/overlays")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.flat_map(|overlay| {
overlay
.pointer("/thumbnailBottomOverlayViewModel/badges")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
})
.filter_map(|badge| {
badge
.pointer("/thumbnailBadgeViewModel/text")
.and_then(serde_json::Value::as_str)
})
.collect::<Vec<_>>();
Some(YoutubeListItem {
video_id,
title,
channel_name,
channel_id,
duration_seconds: badge_text.iter().find_map(|text| parse_duration(text)),
view_count_text: metadata_text
.iter()
.find(|text| text.to_ascii_lowercase().contains("view"))
.copied()
.unwrap_or_default()
.to_string(),
published_time_text: metadata_text
.iter()
.find(|text| {
let text = text.to_ascii_lowercase();
text.contains("ago") || text.contains("streamed") || text.contains("premieres")
})
.copied()
.unwrap_or_default()
.to_string(),
thumbnail: image_sources_thumbnail(
value.pointer("/contentImage/thumbnailViewModel/image/sources"),
),
is_live: badge_text
.iter()
.any(|text| text.to_ascii_uppercase().contains("LIVE")),
is_short: false,
})
}
fn parse_shorts_lockup_view_model(value: &serde_json::Value) -> Option<YoutubeListItem> {
Some(YoutubeListItem {
video_id: value
.pointer("/onTap/innertubeCommand/reelWatchEndpoint/videoId")?
.as_str()?
.to_string(),
title: value
.pointer("/overlayMetadata/primaryText/content")?
.as_str()?
.to_string(),
channel_name: String::new(),
channel_id: String::new(),
duration_seconds: None,
view_count_text: value
.pointer("/overlayMetadata/secondaryText/content")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
published_time_text: String::new(),
thumbnail: image_sources_thumbnail(
value.pointer("/thumbnailViewModel/thumbnailViewModel/image/sources"),
),
is_live: false,
is_short: true,
})
}
fn image_sources_thumbnail(value: Option<&serde_json::Value>) -> Option<YoutubeThumbnail> {
value
.and_then(serde_json::Value::as_array)
.and_then(|sources| sources.last())
.and_then(|thumbnail| {
Some(YoutubeThumbnail {
url: thumbnail.get("url")?.as_str()?.to_string(),
width: thumbnail
.get("width")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
height: thumbnail
.get("height")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
})
})
}
fn parse_video_renderer(value: &serde_json::Value, is_short: bool) -> Option<YoutubeListItem> {
let video_id = value.get("videoId")?.as_str()?.to_string();
let title = text_value(value.get("title")?);
@ -1009,6 +1176,88 @@ mod tests {
assert_eq!(page.next_cursor.as_deref(), Some("next-token"));
}
#[tokio::test]
async fn playlist_decodes_continuation_cursor_before_sending_json() {
crate::install_process_crypto_provider();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/browse"))
.and(body_string_contains(r#""continuation":"next-token==""#))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"contents": []})))
.expect(1)
.mount(&server)
.await;
let client = YoutubeClient::with_endpoint(&server.uri(), reqwest::Client::new())
.expect("client should build");
client
.playlist("PL123", Some("next-token%3D%3D"), None, None)
.await
.expect("playlist continuation should succeed");
}
#[test]
fn list_page_extracts_current_lockup_view_models() {
let page = parse_list_page(&json!({
"contents": [
{"lockupViewModel": {
"contentId": "dQw4w9WgXcQ",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"contentImage": {"thumbnailViewModel": {
"image": {"sources": [{
"url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
"width": 336,
"height": 188
}]},
"overlays": [{"thumbnailBottomOverlayViewModel": {"badges": [{
"thumbnailBadgeViewModel": {"text": "3:32"}
}]}}]
}},
"metadata": {"lockupMetadataViewModel": {
"title": {"content": "Video title"},
"metadata": {"contentMetadataViewModel": {"metadataRows": [
{"metadataParts": [{"text": {
"content": "Channel",
"commandRuns": [{"onTap": {"innertubeCommand": {
"browseEndpoint": {"browseId": "UC123"}
}}}]
}}]},
{"metadataParts": [
{"text": {"content": "1K views"}},
{"text": {"content": "2 days ago"}}
]}
]}}
}}
}},
{"shortsLockupViewModel": {
"onTap": {"innertubeCommand": {"reelWatchEndpoint": {
"videoId": "S069PVmKXZ4"
}}},
"overlayMetadata": {
"primaryText": {"content": "Short title"},
"secondaryText": {"content": "20K views"}
},
"thumbnailViewModel": {"thumbnailViewModel": {"image": {
"sources": [{
"url": "https://i.ytimg.com/vi/S069PVmKXZ4/oar2.jpg",
"width": 405,
"height": 720
}]
}}}
}}
]
}));
assert_eq!(page.items.len(), 2);
assert_eq!(page.items[0].video_id, "dQw4w9WgXcQ");
assert_eq!(page.items[0].channel_id, "UC123");
assert_eq!(page.items[0].duration_seconds, Some(212));
assert_eq!(page.items[0].view_count_text, "1K views");
assert_eq!(page.items[0].published_time_text, "2 days ago");
assert!(page.items[1].is_short);
assert_eq!(page.items[1].view_count_text, "20K views");
}
#[tokio::test]
async fn channel_tabs_and_native_feeds_use_distinct_browse_contracts() {
crate::install_process_crypto_provider();
@ -1017,7 +1266,6 @@ mod tests {
"EgZ2aWRlb3PyBgQKAjoA",
"EgZzaG9ydHPyBgUKA5oBAA==",
"EgdzdHJlYW1z8gYECgJ6AA==",
"FEtrending",
"FEsubscriptions",
] {
Mock::given(method("POST"))
@ -1043,10 +1291,6 @@ mod tests {
.await
.expect("channel tab should succeed");
}
client
.feed("FEtrending", None, None, None)
.await
.expect("trending should succeed");
client
.feed("FEsubscriptions", None, None, Some("SID=session"))
.await

@ -309,7 +309,6 @@ message YoutubePlaylistSourceConfig {
message Search {
string query = 1 [(buf.validate.field).string.min_len = 1];
}
message Trending {}
message Subscriptions {}
message LikedVideos {}
message WatchLater {}
@ -319,10 +318,9 @@ message YoutubePlaylistSourceConfig {
Playlist playlist = 2;
Channel channel = 3;
Search search = 4;
Trending trending = 5;
Subscriptions subscriptions = 6;
LikedVideos liked_videos = 7;
WatchLater watch_later = 8;
Subscriptions subscriptions = 5;
LikedVideos liked_videos = 6;
WatchLater watch_later = 7;
}
}

@ -1503,7 +1503,6 @@ mod source_config_tests {
r#"{"shared":false,"search":{"query":"rust media server"}}"#,
"search",
),
(r#"{"shared":false,"trending":{}}"#, "trending"),
(r#"{"shared":true,"subscriptions":{}}"#, "subscriptions"),
(r#"{"shared":true,"likedVideos":{}}"#, "likedVideos"),
(r#"{"shared":true,"watchLater":{}}"#, "watchLater"),
@ -1521,7 +1520,6 @@ mod source_config_tests {
Some(Source::Playlist(_)) => "playlist",
Some(Source::Channel(_)) => "channel",
Some(Source::Search(_)) => "search",
Some(Source::Trending(_)) => "trending",
Some(Source::Subscriptions(_)) => "subscriptions",
Some(Source::LikedVideos(_)) => "likedVideos",
Some(Source::WatchLater(_)) => "watchLater",

Loading…
Cancel
Save