From 6726cafff4839871be1e489a047127c1d1c3284e Mon Sep 17 00:00:00 2001 From: lonelyhentxi Date: Wed, 18 Jun 2025 02:19:42 +0800 Subject: [PATCH] feat: support static server --- .../proxy/.whistle/rules/files/1.mikan_doppel | 2 +- apps/recorder/src/app/core.rs | 26 ++-- apps/recorder/src/errors/app_error.rs | 37 +++-- .../src/extract/bittorrent/extract.rs | 9 +- apps/recorder/src/extract/mikan/web.rs | 11 +- apps/recorder/src/extract/mod.rs | 4 +- apps/recorder/src/extract/origin/mod.rs | 5 + .../src/extract/{rawname => origin}/parser.rs | 6 +- apps/recorder/src/extract/rawname/mod.rs | 5 - apps/recorder/src/migrations/defs.rs | 8 +- .../src/migrations/m20220101_000001_init.rs | 6 +- .../m20240224_082543_add_downloads.rs | 2 +- apps/recorder/src/models/bangumi.rs | 13 +- apps/recorder/src/models/downloads.rs | 2 +- apps/recorder/src/models/episodes.rs | 12 +- apps/recorder/src/storage/client.rs | 138 +++++++++++++++++- apps/recorder/src/utils/http.rs | 21 ++- .../recorder/src/web/controller/static/mod.rs | 122 +++------------- apps/webui/src/components/layout/app-icon.tsx | 4 +- apps/webui/src/components/ui/image.tsx | 9 -- apps/webui/src/components/ui/img.tsx | 9 ++ .../domains/recorder/schema/subscriptions.ts | 1 - apps/webui/src/infra/graphql/gql/gql.ts | 6 +- apps/webui/src/infra/graphql/gql/graphql.ts | 52 ++++--- .../routes/_app/subscriptions/detail.$id.tsx | 37 +++-- packages/util/src/errors.rs | 4 + 26 files changed, 321 insertions(+), 230 deletions(-) create mode 100644 apps/recorder/src/extract/origin/mod.rs rename apps/recorder/src/extract/{rawname => origin}/parser.rs (99%) delete mode 100644 apps/recorder/src/extract/rawname/mod.rs delete mode 100644 apps/webui/src/components/ui/image.tsx create mode 100644 apps/webui/src/components/ui/img.tsx diff --git a/apps/proxy/.whistle/rules/files/1.mikan_doppel b/apps/proxy/.whistle/rules/files/1.mikan_doppel index 8486f25..c9b993a 100644 --- a/apps/proxy/.whistle/rules/files/1.mikan_doppel +++ b/apps/proxy/.whistle/rules/files/1.mikan_doppel @@ -1 +1 @@ -^https://mikanani.me/*** http://127.0.0.1:5005/$1 \ No newline at end of file +^https://mikanani.me/*** http://127.0.0.1:5005/$1 excludeFilter://^**/***.svg excludeFilter://^**/***.css excludeFilter://^**/***.js \ No newline at end of file diff --git a/apps/recorder/src/app/core.rs b/apps/recorder/src/app/core.rs index 9cfaf63..b2e3068 100644 --- a/apps/recorder/src/app/core.rs +++ b/apps/recorder/src/app/core.rs @@ -6,7 +6,6 @@ use tracing::instrument; use super::{builder::AppBuilder, context::AppContextTrait}; use crate::{ - app::Environment, errors::{RecorderError, RecorderResult}, web::{ controller::{self, core::ControllerTrait}, @@ -52,13 +51,14 @@ impl App { let mut router = Router::>::new(); - let (graphql_c, oidc_c, metadata_c) = futures::try_join!( + let (graphql_c, oidc_c, metadata_c, static_c) = futures::try_join!( controller::graphql::create(context.clone()), controller::oidc::create(context.clone()), - controller::metadata::create(context.clone()) + controller::metadata::create(context.clone()), + controller::r#static::create(context.clone()), )?; - for c in [graphql_c, oidc_c, metadata_c] { + for c in [graphql_c, oidc_c, metadata_c, static_c] { router = c.apply_to(router); } @@ -86,17 +86,13 @@ impl App { async { { let monitor = task.setup_monitor().await?; - if matches!(context.environment(), Environment::Development) { - monitor.run().await?; - } else { - monitor - .run_with_signal(async move { - Self::shutdown_signal().await; - tracing::info!("apalis shutting down..."); - Ok(()) - }) - .await?; - } + monitor + .run_with_signal(async move { + Self::shutdown_signal().await; + tracing::info!("apalis shutting down..."); + Ok(()) + }) + .await?; } Ok::<(), RecorderError>(()) diff --git a/apps/recorder/src/errors/app_error.rs b/apps/recorder/src/errors/app_error.rs index 95e9ce9..689fd60 100644 --- a/apps/recorder/src/errors/app_error.rs +++ b/apps/recorder/src/errors/app_error.rs @@ -40,12 +40,10 @@ pub enum RecorderError { NetAddrParseError { source: std::net::AddrParseError }, #[snafu(transparent)] RegexError { source: regex::Error }, - #[snafu(transparent)] - InvalidMethodError { source: http::method::InvalidMethod }, - #[snafu(transparent)] - InvalidHeaderNameError { - source: http::header::InvalidHeaderName, - }, + #[snafu(display("Invalid method"))] + InvalidMethodError, + #[snafu(display("Invalid header name"))] + InvalidHeaderNameError, #[snafu(transparent)] TracingAppenderInitError { source: tracing_appender::rolling::InitError, @@ -84,10 +82,8 @@ pub enum RecorderError { #[snafu(source(from(opendal::Error, Box::new)))] source: Box, }, - #[snafu(transparent)] - InvalidHeaderValueError { - source: http::header::InvalidHeaderValue, - }, + #[snafu(display("Invalid header value"))] + InvalidHeaderValueError, #[snafu(transparent)] HttpClientError { source: HttpClientError }, #[cfg(feature = "testcontainers")] @@ -234,7 +230,8 @@ impl IntoResponse for RecorderError { headers, source, } => { - let message = Option::<_>::from(source) + let message = source + .into_inner() .map(|s| s.to_string()) .unwrap_or_else(|| { String::from(status.canonical_reason().unwrap_or("Unknown")) @@ -267,4 +264,22 @@ impl From for RecorderError { } } +impl From for RecorderError { + fn from(_error: http::header::InvalidHeaderValue) -> Self { + Self::InvalidHeaderValueError + } +} + +impl From for RecorderError { + fn from(_error: http::header::InvalidHeaderName) -> Self { + Self::InvalidHeaderNameError + } +} + +impl From for RecorderError { + fn from(_error: http::method::InvalidMethod) -> Self { + Self::InvalidMethodError + } +} + pub type RecorderResult = Result; diff --git a/apps/recorder/src/extract/bittorrent/extract.rs b/apps/recorder/src/extract/bittorrent/extract.rs index d122b44..8e723ac 100644 --- a/apps/recorder/src/extract/bittorrent/extract.rs +++ b/apps/recorder/src/extract/bittorrent/extract.rs @@ -268,8 +268,8 @@ mod tests { ) } - pub fn test_torrent_ep_parser(raw_name: &str, expected: &str) { - let extname = Path::new(raw_name) + pub fn test_torrent_ep_parser(origin_name: &str, expected: &str) { + let extname = Path::new(origin_name) .extension() .map(|e| format!(".{e}")) .unwrap_or_default() @@ -278,7 +278,7 @@ mod tests { if extname == ".srt" || extname == ".ass" { let expected: Option = serde_json::from_str(expected).ok(); let found_raw = - parse_episode_subtitle_meta_from_torrent(Path::new(raw_name), None, None); + parse_episode_subtitle_meta_from_torrent(Path::new(origin_name), None, None); let found = found_raw.as_ref().ok().cloned(); if expected != found { @@ -299,7 +299,8 @@ mod tests { assert_eq!(expected, found); } else { let expected: Option = serde_json::from_str(expected).ok(); - let found_raw = parse_episode_media_meta_from_torrent(Path::new(raw_name), None, None); + let found_raw = + parse_episode_media_meta_from_torrent(Path::new(origin_name), None, None); let found = found_raw.as_ref().ok().cloned(); if expected != found { diff --git a/apps/recorder/src/extract/mikan/web.rs b/apps/recorder/src/extract/mikan/web.rs index 0660553..3371f0a 100644 --- a/apps/recorder/src/extract/mikan/web.rs +++ b/apps/recorder/src/extract/mikan/web.rs @@ -741,13 +741,11 @@ pub async fn scrape_mikan_poster_meta_from_image_url( mikan_client: &MikanClient, storage_service: &StorageService, origin_poster_src_url: Url, - subscriber_id: i32, ) -> RecorderResult { if let Some(poster_src) = storage_service .exists( - storage_service.build_subscriber_object_path( + storage_service.build_public_object_path( StorageContentCategory::Image, - subscriber_id, MIKAN_POSTER_BUCKET_KEY, &origin_poster_src_url .path() @@ -768,9 +766,8 @@ pub async fn scrape_mikan_poster_meta_from_image_url( let poster_str = storage_service .write( - storage_service.build_subscriber_object_path( + storage_service.build_public_object_path( StorageContentCategory::Image, - subscriber_id, MIKAN_POSTER_BUCKET_KEY, &origin_poster_src_url .path() @@ -1084,15 +1081,13 @@ mod test { &mikan_client, &storage_service, bangumi_poster_url, - 1, ) .await?; resources_mock.shared_resource_mock.expect(1); - let storage_fullname = storage_service.build_subscriber_object_path( + let storage_fullname = storage_service.build_public_object_path( StorageContentCategory::Image, - 1, MIKAN_POSTER_BUCKET_KEY, "202309/5ce9fed1.jpg", ); diff --git a/apps/recorder/src/extract/mod.rs b/apps/recorder/src/extract/mod.rs index 4e0edf7..c29cd33 100644 --- a/apps/recorder/src/extract/mod.rs +++ b/apps/recorder/src/extract/mod.rs @@ -1,7 +1,7 @@ +pub mod bittorrent; pub mod defs; pub mod html; pub mod http; pub mod media; pub mod mikan; -pub mod rawname; -pub mod bittorrent; +pub mod origin; diff --git a/apps/recorder/src/extract/origin/mod.rs b/apps/recorder/src/extract/origin/mod.rs new file mode 100644 index 0000000..6a56d82 --- /dev/null +++ b/apps/recorder/src/extract/origin/mod.rs @@ -0,0 +1,5 @@ +pub mod parser; + +pub use parser::{ + RawEpisodeMeta, extract_episode_meta_from_origin_name, extract_season_from_title_body, +}; diff --git a/apps/recorder/src/extract/rawname/parser.rs b/apps/recorder/src/extract/origin/parser.rs similarity index 99% rename from apps/recorder/src/extract/rawname/parser.rs rename to apps/recorder/src/extract/origin/parser.rs index f6235e9..1920c81 100644 --- a/apps/recorder/src/extract/rawname/parser.rs +++ b/apps/recorder/src/extract/origin/parser.rs @@ -261,7 +261,7 @@ pub fn check_is_movie(title: &str) -> bool { MOVIE_TITLE_RE.is_match(title) } -pub fn extract_episode_meta_from_raw_name(s: &str) -> RecorderResult { +pub fn extract_episode_meta_from_origin_name(s: &str) -> RecorderResult { let raw_title = s.trim(); let raw_title_without_ch_brackets = replace_ch_bracket_to_en(raw_title); let fansub = extract_fansub(&raw_title_without_ch_brackets); @@ -321,11 +321,11 @@ pub fn extract_episode_meta_from_raw_name(s: &str) -> RecorderResult = serde_json::from_str(expected).unwrap_or_default(); - let found = extract_episode_meta_from_raw_name(raw_name).ok(); + let found = extract_episode_meta_from_origin_name(raw_name).ok(); if expected != found { println!( diff --git a/apps/recorder/src/extract/rawname/mod.rs b/apps/recorder/src/extract/rawname/mod.rs deleted file mode 100644 index f2c3b65..0000000 --- a/apps/recorder/src/extract/rawname/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod parser; - -pub use parser::{ - RawEpisodeMeta, extract_episode_meta_from_raw_name, extract_season_from_title_body, -}; diff --git a/apps/recorder/src/migrations/defs.rs b/apps/recorder/src/migrations/defs.rs index 6b170d1..a3477be 100644 --- a/apps/recorder/src/migrations/defs.rs +++ b/apps/recorder/src/migrations/defs.rs @@ -43,7 +43,7 @@ pub enum Bangumi { MikanBangumiId, DisplayName, SubscriberId, - RawName, + OriginName, Season, SeasonRaw, Fansub, @@ -51,6 +51,7 @@ pub enum Bangumi { Filter, RssLink, PosterLink, + OriginPosterLink, SavePath, Homepage, } @@ -69,7 +70,7 @@ pub enum Episodes { Table, Id, MikanEpisodeId, - RawName, + OriginName, DisplayName, BangumiId, SubscriberId, @@ -80,6 +81,7 @@ pub enum Episodes { SeasonRaw, Fansub, PosterLink, + OriginPosterLink, EpisodeIndex, Homepage, Subtitle, @@ -100,7 +102,7 @@ pub enum SubscriptionEpisode { pub enum Downloads { Table, Id, - RawName, + OriginName, DisplayName, SubscriberId, DownloaderId, diff --git a/apps/recorder/src/migrations/m20220101_000001_init.rs b/apps/recorder/src/migrations/m20220101_000001_init.rs index 44777a1..4429e23 100644 --- a/apps/recorder/src/migrations/m20220101_000001_init.rs +++ b/apps/recorder/src/migrations/m20220101_000001_init.rs @@ -96,7 +96,7 @@ impl MigrationTrait for Migration { .col(text_null(Bangumi::MikanBangumiId)) .col(integer(Bangumi::SubscriberId)) .col(text(Bangumi::DisplayName)) - .col(text(Bangumi::RawName)) + .col(text(Bangumi::OriginName)) .col(integer(Bangumi::Season)) .col(text_null(Bangumi::SeasonRaw)) .col(text_null(Bangumi::Fansub)) @@ -104,6 +104,7 @@ impl MigrationTrait for Migration { .col(json_binary_null(Bangumi::Filter)) .col(text_null(Bangumi::RssLink)) .col(text_null(Bangumi::PosterLink)) + .col(text_null(Bangumi::OriginPosterLink)) .col(text_null(Bangumi::SavePath)) .col(text_null(Bangumi::Homepage)) .foreign_key( @@ -220,7 +221,7 @@ impl MigrationTrait for Migration { table_auto_z(Episodes::Table) .col(pk_auto(Episodes::Id)) .col(text_null(Episodes::MikanEpisodeId)) - .col(text(Episodes::RawName)) + .col(text(Episodes::OriginName)) .col(text(Episodes::DisplayName)) .col(integer(Episodes::BangumiId)) .col(integer(Episodes::SubscriberId)) @@ -230,6 +231,7 @@ impl MigrationTrait for Migration { .col(text_null(Episodes::SeasonRaw)) .col(text_null(Episodes::Fansub)) .col(text_null(Episodes::PosterLink)) + .col(text_null(Episodes::OriginPosterLink)) .col(integer(Episodes::EpisodeIndex)) .col(text_null(Episodes::Homepage)) .col(text_null(Episodes::Subtitle)) diff --git a/apps/recorder/src/migrations/m20240224_082543_add_downloads.rs b/apps/recorder/src/migrations/m20240224_082543_add_downloads.rs index 986731a..f854f66 100644 --- a/apps/recorder/src/migrations/m20240224_082543_add_downloads.rs +++ b/apps/recorder/src/migrations/m20240224_082543_add_downloads.rs @@ -80,7 +80,7 @@ impl MigrationTrait for Migration { .create_table( table_auto_z(Downloads::Table) .col(pk_auto(Downloads::Id)) - .col(string(Downloads::RawName)) + .col(string(Downloads::OriginName)) .col(string(Downloads::DisplayName)) .col(integer(Downloads::SubscriberId)) .col(integer(Downloads::DownloaderId)) diff --git a/apps/recorder/src/models/bangumi.rs b/apps/recorder/src/models/bangumi.rs index cdd5db1..252f094 100644 --- a/apps/recorder/src/models/bangumi.rs +++ b/apps/recorder/src/models/bangumi.rs @@ -17,7 +17,7 @@ use crate::{ MikanBangumiHash, MikanBangumiMeta, build_mikan_bangumi_subscription_rss_url, scrape_mikan_poster_meta_from_image_url, }, - rawname::extract_season_from_title_body, + origin::extract_season_from_title_body, }, }; @@ -41,7 +41,7 @@ pub struct Model { pub mikan_bangumi_id: Option, pub subscriber_id: i32, pub display_name: String, - pub raw_name: String, + pub origin_name: String, pub season: i32, pub season_raw: Option, pub fansub: Option, @@ -49,6 +49,7 @@ pub struct Model { pub filter: Option, pub rss_link: Option, pub poster_link: Option, + pub origin_poster_link: Option, pub save_path: Option, pub homepage: Option, } @@ -130,12 +131,11 @@ impl ActiveModel { Some(&meta.mikan_fansub_id), ); - let poster_link = if let Some(origin_poster_src) = meta.origin_poster_src { + let poster_link = if let Some(origin_poster_src) = meta.origin_poster_src.clone() { let poster_meta = scrape_mikan_poster_meta_from_image_url( mikan_client, storage_service, origin_poster_src, - subscriber_id, ) .await?; poster_meta.poster_src @@ -148,11 +148,12 @@ impl ActiveModel { mikan_fansub_id: ActiveValue::Set(Some(meta.mikan_fansub_id)), subscriber_id: ActiveValue::Set(subscriber_id), display_name: ActiveValue::Set(meta.bangumi_title.clone()), - raw_name: ActiveValue::Set(meta.bangumi_title), + origin_name: ActiveValue::Set(meta.bangumi_title), season: ActiveValue::Set(season_index), season_raw: ActiveValue::Set(season_raw), fansub: ActiveValue::Set(Some(meta.fansub)), poster_link: ActiveValue::Set(poster_link), + origin_poster_link: ActiveValue::Set(meta.origin_poster_src.map(|src| src.to_string())), homepage: ActiveValue::Set(Some(meta.homepage.to_string())), rss_link: ActiveValue::Set(Some(rss_url.to_string())), ..Default::default() @@ -228,7 +229,7 @@ impl Model { Column::SubscriberId, ]) .update_columns([ - Column::RawName, + Column::OriginName, Column::Fansub, Column::PosterLink, Column::Season, diff --git a/apps/recorder/src/models/downloads.rs b/apps/recorder/src/models/downloads.rs index 9138508..fee5538 100644 --- a/apps/recorder/src/models/downloads.rs +++ b/apps/recorder/src/models/downloads.rs @@ -44,7 +44,7 @@ pub struct Model { pub updated_at: DateTimeUtc, #[sea_orm(primary_key)] pub id: i32, - pub raw_name: String, + pub origin_name: String, pub display_name: String, pub downloader_id: i32, pub episode_id: i32, diff --git a/apps/recorder/src/models/episodes.rs b/apps/recorder/src/models/episodes.rs index 1bf500f..5b5921a 100644 --- a/apps/recorder/src/models/episodes.rs +++ b/apps/recorder/src/models/episodes.rs @@ -10,7 +10,7 @@ use crate::{ errors::RecorderResult, extract::{ mikan::{MikanEpisodeHash, MikanEpisodeMeta, build_mikan_episode_homepage_url}, - rawname::extract_episode_meta_from_raw_name, + origin::extract_episode_meta_from_origin_name, }, }; @@ -25,7 +25,7 @@ pub struct Model { pub id: i32, #[sea_orm(indexed)] pub mikan_episode_id: Option, - pub raw_name: String, + pub origin_name: String, pub display_name: String, pub bangumi_id: i32, pub subscriber_id: i32, @@ -35,6 +35,7 @@ pub struct Model { pub season_raw: Option, pub fansub: Option, pub poster_link: Option, + pub origin_poster_link: Option, pub episode_index: i32, pub homepage: Option, pub subtitle: Option, @@ -123,7 +124,7 @@ impl ActiveModel { episode: MikanEpisodeMeta, ) -> RecorderResult { let mikan_base_url = ctx.mikan().base_url().clone(); - let episode_extention_meta = extract_episode_meta_from_raw_name(&episode.episode_title) + let episode_extention_meta = extract_episode_meta_from_origin_name(&episode.episode_title) .inspect_err(|err| { tracing::error!( err = ?err, @@ -136,7 +137,7 @@ impl ActiveModel { let mut episode_active_model = Self { mikan_episode_id: ActiveValue::Set(Some(episode.mikan_episode_id)), - raw_name: ActiveValue::Set(episode.episode_title.clone()), + origin_name: ActiveValue::Set(episode.episode_title.clone()), display_name: ActiveValue::Set(episode.episode_title.clone()), bangumi_id: ActiveValue::Set(bangumi.id), subscriber_id: ActiveValue::Set(bangumi.subscriber_id), @@ -145,6 +146,7 @@ impl ActiveModel { season: ActiveValue::Set(bangumi.season), fansub: ActiveValue::Set(bangumi.fansub.clone()), poster_link: ActiveValue::Set(bangumi.poster_link.clone()), + origin_poster_link: ActiveValue::Set(bangumi.origin_poster_link.clone()), episode_index: ActiveValue::Set(0), ..Default::default() }; @@ -231,7 +233,7 @@ impl Model { let new_episode_ids = Entity::insert_many(new_episode_active_modes) .on_conflict( OnConflict::columns([Column::MikanEpisodeId, Column::SubscriberId]) - .update_columns([Column::RawName, Column::PosterLink, Column::Homepage]) + .update_columns([Column::OriginName, Column::PosterLink, Column::Homepage]) .to_owned(), ) .exec_with_returning_columns(db, [Column::Id]) diff --git a/apps/recorder/src/storage/client.rs b/apps/recorder/src/storage/client.rs index b54ae37..c897528 100644 --- a/apps/recorder/src/storage/client.rs +++ b/apps/recorder/src/storage/client.rs @@ -1,13 +1,23 @@ use std::fmt; +use async_stream::try_stream; +use axum::{body::Body, response::Response}; +use axum_extra::{TypedHeader, headers::Range}; use bytes::Bytes; +use futures::{Stream, StreamExt}; +use http::{HeaderValue, StatusCode, header}; use opendal::{Buffer, Metadata, Operator, Reader, Writer, layers::LoggingLayer}; -use quirks_path::PathBuf; +use quirks_path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use tracing::instrument; use url::Url; +use uuid::Uuid; use super::StorageConfig; -use crate::errors::app_error::RecorderResult; +use crate::{ + errors::{RecorderError, RecorderResult}, + utils::http::{bound_range_to_content_range, build_no_satisfiable_content_range}, +}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -69,23 +79,44 @@ impl StorageService { Ok(op) } - pub fn build_subscriber_path(&self, subscriber_id: i32, path: &str) -> PathBuf { + pub fn build_subscriber_path(&self, subscriber_id: i32, path: impl AsRef) -> PathBuf { let mut p = PathBuf::from("/subscribers"); p.push(subscriber_id.to_string()); p.push(path); p } + pub fn build_public_path(&self, path: impl AsRef) -> PathBuf { + let mut p = PathBuf::from("/public"); + p.push(path); + p + } + pub fn build_subscriber_object_path( &self, - content_category: StorageContentCategory, subscriber_id: i32, + content_category: StorageContentCategory, bucket: &str, object_name: &str, ) -> PathBuf { self.build_subscriber_path( subscriber_id, - &format!("{}/{}/{}", content_category.as_ref(), bucket, object_name), + [content_category.as_ref(), bucket, object_name] + .iter() + .collect::(), + ) + } + + pub fn build_public_object_path( + &self, + content_category: StorageContentCategory, + bucket: &str, + object_name: &str, + ) -> PathBuf { + self.build_public_path( + [content_category.as_ref(), bucket, object_name] + .iter() + .collect::(), ) } @@ -156,4 +187,101 @@ impl StorageService { Ok(metadata) } + + #[instrument(skip_all, err, fields(storage_path = %storage_path.as_ref(), range = ?range))] + pub async fn serve_file( + &self, + storage_path: impl AsRef, + range: Option>, + ) -> RecorderResult { + let metadata = self + .stat(&storage_path) + .await + .map_err(|_| RecorderError::from_status(StatusCode::NOT_FOUND))?; + + if !metadata.is_file() { + return Err(RecorderError::from_status(StatusCode::NOT_FOUND)); + } + + let mime_type = mime_guess::from_path(storage_path.as_ref()).first_or_octet_stream(); + + let content_type = HeaderValue::from_str(mime_type.as_ref())?; + + let response = if let Some(TypedHeader(range)) = range { + let ranges = range + .satisfiable_ranges(metadata.content_length()) + .map(|r| -> Option<(_, _)> { + let a = bound_range_to_content_range(&r, metadata.content_length())?; + Some((r, a)) + }) + .collect::>>(); + + if let Some(mut ranges) = ranges { + if ranges.len() > 1 { + let boundary = Uuid::new_v4().to_string(); + let reader = self.reader(storage_path.as_ref()).await?; + let stream: impl Stream> = { + let boundary = boundary.clone(); + try_stream! { + for (r, content_range) in ranges { + let part_header = format!("--{boundary}\r\nContent-Type: {}\r\nContent-Range: {}\r\n\r\n", + mime_type.as_ref(), + content_range.clone().to_str().unwrap(), + ); + yield part_header.into(); + let mut part_stream = reader.clone().into_bytes_stream(r).await?; + while let Some(chunk) = part_stream.next().await { + yield chunk?; + } + yield "\r\n".into(); + } + yield format!("--{boundary}--").into(); + } + }; + let body = Body::from_stream(stream); + + Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header( + header::CONTENT_TYPE, + HeaderValue::from_str( + format!("multipart/byteranges; boundary={boundary}").as_str(), + ) + .unwrap(), + ) + .body(body)? + } else if let Some((r, content_range)) = ranges.pop() { + let reader = self.reader(storage_path.as_ref()).await?; + let stream = reader.into_bytes_stream(r).await?; + + Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type.clone()) + .header(header::CONTENT_RANGE, content_range) + .body(Body::from_stream(stream))? + } else { + unreachable!("ranges length should be greater than 0") + } + } else { + Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_TYPE, content_type) + .header( + header::CONTENT_RANGE, + build_no_satisfiable_content_range(metadata.content_length()), + ) + .body(Body::empty())? + } + } else { + let reader = self.reader(storage_path.as_ref()).await?; + let stream = reader.into_bytes_stream(..).await?; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .body(Body::from_stream(stream))? + }; + + Ok(response) + } } diff --git a/apps/recorder/src/utils/http.rs b/apps/recorder/src/utils/http.rs index 2318147..19b7e53 100644 --- a/apps/recorder/src/utils/http.rs +++ b/apps/recorder/src/utils/http.rs @@ -1,18 +1,23 @@ use std::ops::Bound; -pub fn bound_range_to_content_range( - r: &(Bound, Bound), - l: u64, -) -> Result { +use http::HeaderValue; + +pub fn build_no_satisfiable_content_range(len: u64) -> HeaderValue { + HeaderValue::from_str(&format!("bytes */{len}")) + .unwrap_or_else(|e| unreachable!("Invalid content range: {e}")) +} + +pub fn bound_range_to_content_range(r: &(Bound, Bound), l: u64) -> Option { match r { - (Bound::Included(start), Bound::Included(end)) => Ok(format!("bytes {start}-{end}/{l}")), + (Bound::Included(start), Bound::Included(end)) => Some(format!("bytes {start}-{end}/{l}")), (Bound::Included(start), Bound::Excluded(end)) => { - Ok(format!("bytes {start}-{}/{l}", end - 1)) + Some(format!("bytes {start}-{}/{l}", end - 1)) } - (Bound::Included(start), Bound::Unbounded) => Ok(format!( + (Bound::Included(start), Bound::Unbounded) => Some(format!( "bytes {start}-{}/{l}", if l > 0 { l - 1 } else { 0 } )), - _ => Err(format!("bytes */{l}")), + _ => None, } + .and_then(|s| HeaderValue::from_str(&s).ok()) } diff --git a/apps/recorder/src/web/controller/static/mod.rs b/apps/recorder/src/web/controller/static/mod.rs index 2ad4522..8cc42ed 100644 --- a/apps/recorder/src/web/controller/static/mod.rs +++ b/apps/recorder/src/web/controller/static/mod.rs @@ -1,32 +1,24 @@ use std::sync::Arc; -use async_stream::try_stream; use axum::{ Extension, Router, - body::Body, extract::{Path, State}, middleware::from_fn_with_state, response::Response, routing::get, }; use axum_extra::{TypedHeader, headers::Range}; -use bytes::Bytes; -use futures::{Stream, StreamExt}; -use http::{HeaderMap, HeaderValue, StatusCode, header}; -use itertools::Itertools; -use uuid::Uuid; use crate::{ app::AppContextTrait, auth::{AuthError, AuthUserInfo, auth_middleware}, - errors::{RecorderError, RecorderResult}, - utils::http::bound_range_to_content_range, + errors::RecorderResult, web::controller::Controller, }; pub const CONTROLLER_PREFIX: &str = "/api/static"; -async fn serve_file_with_cache( +async fn serve_subscriber_static( State(ctx): State>, Path((subscriber_id, path)): Path<(i32, String)>, Extension(auth_user_info): Extension, @@ -40,106 +32,28 @@ async fn serve_file_with_cache( let storage_path = storage.build_subscriber_path(subscriber_id, &path); - let metadata = storage - .stat(&storage_path) - .await - .map_err(|_| RecorderError::from_status(StatusCode::NOT_FOUND))?; + storage.serve_file(storage_path, range).await +} - if !metadata.is_file() { - return Err(RecorderError::from_status(StatusCode::NOT_FOUND)); - } +async fn serve_public_static( + State(ctx): State>, + Path(path): Path, + range: Option>, +) -> RecorderResult { + let storage = ctx.storage(); - let mime_type = mime_guess::from_path(&path).first_or_octet_stream(); + let storage_path = storage.build_public_path(&path); - let response = if let Some(TypedHeader(range)) = range { - let ranges = range - .satisfiable_ranges(metadata.content_length()) - .collect_vec(); - - if ranges.is_empty() { - Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_TYPE, mime_type.as_ref()) - .body(Body::empty())? - } else if ranges.len() == 1 { - let r = ranges[0]; - let reader = storage.reader(&storage_path).await?; - let content_range = bound_range_to_content_range(&r, metadata.content_length()) - .map_err(|s| { - RecorderError::from_status_and_headers( - StatusCode::RANGE_NOT_SATISFIABLE, - HeaderMap::from_iter( - [(header::CONTENT_RANGE, HeaderValue::from_str(&s).unwrap())] - .into_iter(), - ), - ) - })?; - let stream = reader.into_bytes_stream(r).await?; - - Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_TYPE, mime_type.as_ref()) - .header(header::CONTENT_RANGE, content_range) - .body(Body::from_stream(stream))? - } else { - let boundary = Uuid::new_v4().to_string(); - let reader = storage.reader(&storage_path).await?; - let stream: impl Stream> = { - let boundary = boundary.clone(); - try_stream! { - for r in ranges { - let content_range = bound_range_to_content_range(&r, metadata.content_length()) - .map_err(|s| { - RecorderError::from_status_and_headers( - StatusCode::RANGE_NOT_SATISFIABLE, - HeaderMap::from_iter([(header::CONTENT_RANGE, HeaderValue::from_str(&s).unwrap())].into_iter()), - ) - })?; - let part_header = format!("--{boundary}\r\nContent-Type: {}\r\nContent-Range: {}\r\n\r\n", - mime_type.as_ref(), - content_range, - ); - yield part_header.into(); - let mut part_stream = reader.clone().into_bytes_stream(r).await?; - while let Some(chunk) = part_stream.next().await { - yield chunk?; - } - yield "\r\n".into(); - } - yield format!("--{boundary}--").into(); - } - }; - let body = Body::from_stream(stream); - - Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .header( - header::CONTENT_TYPE, - HeaderValue::from_str( - format!("multipart/byteranges; boundary={boundary}").as_str(), - ) - .unwrap(), - ) - .body(body)? - } - } else { - let reader = storage.reader(&storage_path).await?; - let stream = reader.into_bytes_stream(..).await?; - - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, mime_type.as_ref()) - .body(Body::from_stream(stream))? - }; - - Ok(response) + storage.serve_file(storage_path, range).await } pub async fn create(ctx: Arc) -> RecorderResult { - let router = Router::>::new().route( - "/subscribers/{subscriber_id}/*path", - get(serve_file_with_cache).layer(from_fn_with_state(ctx, auth_middleware)), - ); + let router = Router::>::new() + .route( + "/subscribers/{subscriber_id}/{*path}", + get(serve_subscriber_static).layer(from_fn_with_state(ctx, auth_middleware)), + ) + .route("/public/{*path}", get(serve_public_static)); Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) } diff --git a/apps/webui/src/components/layout/app-icon.tsx b/apps/webui/src/components/layout/app-icon.tsx index 9526392..489ac0c 100644 --- a/apps/webui/src/components/layout/app-icon.tsx +++ b/apps/webui/src/components/layout/app-icon.tsx @@ -4,7 +4,7 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; -import { Image } from '@/components/ui/image'; +import { Img } from '@/components/ui/img'; export function AppIcon() { return ( @@ -16,7 +16,7 @@ export function AppIcon() { >
- App Logo, 'alt'> & - Required, 'alt'>>; - -export const Image = (props: ImageProps) => { - // biome-ignore lint/nursery/noImgElement: - return {props.alt}; -}; diff --git a/apps/webui/src/components/ui/img.tsx b/apps/webui/src/components/ui/img.tsx new file mode 100644 index 0000000..0bc1ada --- /dev/null +++ b/apps/webui/src/components/ui/img.tsx @@ -0,0 +1,9 @@ +import type { ComponentProps } from "react"; + +export type ImgProps = Omit, "alt"> & + Required, "alt">>; + +export const Img = (props: ImgProps) => { + // biome-ignore lint/nursery/noImgElement: + return {props.alt}; +}; diff --git a/apps/webui/src/domains/recorder/schema/subscriptions.ts b/apps/webui/src/domains/recorder/schema/subscriptions.ts index 61bb0f7..75243ed 100644 --- a/apps/webui/src/domains/recorder/schema/subscriptions.ts +++ b/apps/webui/src/domains/recorder/schema/subscriptions.ts @@ -106,7 +106,6 @@ query GetSubscriptionDetail ($id: Int!) { id mikanBangumiId displayName - rawName season seasonRaw fansub diff --git a/apps/webui/src/infra/graphql/gql/gql.ts b/apps/webui/src/infra/graphql/gql/gql.ts index dc438e4..fd36210 100644 --- a/apps/webui/src/infra/graphql/gql/gql.ts +++ b/apps/webui/src/infra/graphql/gql/gql.ts @@ -24,7 +24,7 @@ type Documents = { "\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": typeof types.InsertSubscriptionDocument, "\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filters: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filters\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": typeof types.UpdateSubscriptionsDocument, "\n mutation DeleteSubscriptions($filters: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filters)\n }\n": typeof types.DeleteSubscriptionsDocument, - "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n rawName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument, + "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument, "\n mutation SyncSubscriptionFeedsIncremental($filter: SubscriptionsFilterInput!) {\n subscriptionsSyncOneFeedsIncremental(filter: $filter) {\n id\n }\n }\n": typeof types.SyncSubscriptionFeedsIncrementalDocument, "\n mutation SyncSubscriptionFeedsFull($filter: SubscriptionsFilterInput!) {\n subscriptionsSyncOneFeedsFull(filter: $filter) {\n id\n }\n }\n": typeof types.SyncSubscriptionFeedsFullDocument, "\n mutation SyncSubscriptionSources($filter: SubscriptionsFilterInput!) {\n subscriptionsSyncOneSources(filter: $filter) {\n id\n }\n }\n": typeof types.SyncSubscriptionSourcesDocument, @@ -43,7 +43,7 @@ const documents: Documents = { "\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": types.InsertSubscriptionDocument, "\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filters: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filters\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": types.UpdateSubscriptionsDocument, "\n mutation DeleteSubscriptions($filters: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filters)\n }\n": types.DeleteSubscriptionsDocument, - "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n rawName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument, + "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument, "\n mutation SyncSubscriptionFeedsIncremental($filter: SubscriptionsFilterInput!) {\n subscriptionsSyncOneFeedsIncremental(filter: $filter) {\n id\n }\n }\n": types.SyncSubscriptionFeedsIncrementalDocument, "\n mutation SyncSubscriptionFeedsFull($filter: SubscriptionsFilterInput!) {\n subscriptionsSyncOneFeedsFull(filter: $filter) {\n id\n }\n }\n": types.SyncSubscriptionFeedsFullDocument, "\n mutation SyncSubscriptionSources($filter: SubscriptionsFilterInput!) {\n subscriptionsSyncOneSources(filter: $filter) {\n id\n }\n }\n": types.SyncSubscriptionSourcesDocument, @@ -109,7 +109,7 @@ export function gql(source: "\n mutation DeleteSubscriptions($filters: Subscr /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n rawName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n rawName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n"]; +export function gql(source: "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filters: { id: {\n eq: $id\n } }) {\n nodes {\n id\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n savePath\n homepage\n }\n }\n }\n }\n}\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/apps/webui/src/infra/graphql/gql/graphql.ts b/apps/webui/src/infra/graphql/gql/graphql.ts index 2b1f837..44658e3 100644 --- a/apps/webui/src/infra/graphql/gql/graphql.ts +++ b/apps/webui/src/infra/graphql/gql/graphql.ts @@ -30,8 +30,9 @@ export type Bangumi = { id: Scalars['Int']['output']; mikanBangumiId?: Maybe; mikanFansubId?: Maybe; + originName: Scalars['String']['output']; + originPosterLink?: Maybe; posterLink?: Maybe; - rawName: Scalars['String']['output']; rssLink?: Maybe; savePath?: Maybe; season: Scalars['Int']['output']; @@ -74,8 +75,9 @@ export type BangumiBasic = { id: Scalars['Int']['output']; mikanBangumiId?: Maybe; mikanFansubId?: Maybe; + originName: Scalars['String']['output']; + originPosterLink?: Maybe; posterLink?: Maybe; - rawName: Scalars['String']['output']; rssLink?: Maybe; savePath?: Maybe; season: Scalars['Int']['output']; @@ -108,8 +110,9 @@ export type BangumiFilterInput = { mikanBangumiId?: InputMaybe; mikanFansubId?: InputMaybe; or?: InputMaybe>; + originName?: InputMaybe; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName?: InputMaybe; rssLink?: InputMaybe; savePath?: InputMaybe; season?: InputMaybe; @@ -127,8 +130,9 @@ export type BangumiInsertInput = { id?: InputMaybe; mikanBangumiId?: InputMaybe; mikanFansubId?: InputMaybe; + originName: Scalars['String']['input']; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName: Scalars['String']['input']; rssLink?: InputMaybe; savePath?: InputMaybe; season: Scalars['Int']['input']; @@ -146,8 +150,9 @@ export type BangumiOrderInput = { id?: InputMaybe; mikanBangumiId?: InputMaybe; mikanFansubId?: InputMaybe; + originName?: InputMaybe; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName?: InputMaybe; rssLink?: InputMaybe; savePath?: InputMaybe; season?: InputMaybe; @@ -165,8 +170,9 @@ export type BangumiUpdateInput = { id?: InputMaybe; mikanBangumiId?: InputMaybe; mikanFansubId?: InputMaybe; + originName?: InputMaybe; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName?: InputMaybe; rssLink?: InputMaybe; savePath?: InputMaybe; season?: InputMaybe; @@ -492,7 +498,7 @@ export type Downloads = { homepage?: Maybe; id: Scalars['Int']['output']; mime: DownloadMimeEnum; - rawName: Scalars['String']['output']; + originName: Scalars['String']['output']; savePath?: Maybe; status: DownloadStatusEnum; subscriber?: Maybe; @@ -512,7 +518,7 @@ export type DownloadsBasic = { homepage?: Maybe; id: Scalars['Int']['output']; mime: DownloadMimeEnum; - rawName: Scalars['String']['output']; + originName: Scalars['String']['output']; savePath?: Maybe; status: DownloadStatusEnum; subscriberId: Scalars['Int']['output']; @@ -546,7 +552,7 @@ export type DownloadsFilterInput = { id?: InputMaybe; mime?: InputMaybe; or?: InputMaybe>; - rawName?: InputMaybe; + originName?: InputMaybe; savePath?: InputMaybe; status?: InputMaybe; subscriberId?: InputMaybe; @@ -564,7 +570,7 @@ export type DownloadsInsertInput = { homepage?: InputMaybe; id?: InputMaybe; mime: DownloadMimeEnum; - rawName: Scalars['String']['input']; + originName: Scalars['String']['input']; savePath?: InputMaybe; status: DownloadStatusEnum; subscriberId?: InputMaybe; @@ -582,7 +588,7 @@ export type DownloadsOrderInput = { homepage?: InputMaybe; id?: InputMaybe; mime?: InputMaybe; - rawName?: InputMaybe; + originName?: InputMaybe; savePath?: InputMaybe; status?: InputMaybe; subscriberId?: InputMaybe; @@ -600,7 +606,7 @@ export type DownloadsUpdateInput = { homepage?: InputMaybe; id?: InputMaybe; mime?: InputMaybe; - rawName?: InputMaybe; + originName?: InputMaybe; savePath?: InputMaybe; status?: InputMaybe; updatedAt?: InputMaybe; @@ -619,8 +625,9 @@ export type Episodes = { homepage?: Maybe; id: Scalars['Int']['output']; mikanEpisodeId?: Maybe; + originName: Scalars['String']['output']; + originPosterLink?: Maybe; posterLink?: Maybe; - rawName: Scalars['String']['output']; resolution?: Maybe; savePath?: Maybe; season: Scalars['Int']['output']; @@ -665,8 +672,9 @@ export type EpisodesBasic = { homepage?: Maybe; id: Scalars['Int']['output']; mikanEpisodeId?: Maybe; + originName: Scalars['String']['output']; + originPosterLink?: Maybe; posterLink?: Maybe; - rawName: Scalars['String']['output']; resolution?: Maybe; savePath?: Maybe; season: Scalars['Int']['output']; @@ -702,8 +710,9 @@ export type EpisodesFilterInput = { id?: InputMaybe; mikanEpisodeId?: InputMaybe; or?: InputMaybe>; + originName?: InputMaybe; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName?: InputMaybe; resolution?: InputMaybe; savePath?: InputMaybe; season?: InputMaybe; @@ -723,8 +732,9 @@ export type EpisodesInsertInput = { homepage?: InputMaybe; id?: InputMaybe; mikanEpisodeId?: InputMaybe; + originName: Scalars['String']['input']; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName: Scalars['String']['input']; resolution?: InputMaybe; savePath?: InputMaybe; season: Scalars['Int']['input']; @@ -744,8 +754,9 @@ export type EpisodesOrderInput = { homepage?: InputMaybe; id?: InputMaybe; mikanEpisodeId?: InputMaybe; + originName?: InputMaybe; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName?: InputMaybe; resolution?: InputMaybe; savePath?: InputMaybe; season?: InputMaybe; @@ -765,8 +776,9 @@ export type EpisodesUpdateInput = { homepage?: InputMaybe; id?: InputMaybe; mikanEpisodeId?: InputMaybe; + originName?: InputMaybe; + originPosterLink?: InputMaybe; posterLink?: InputMaybe; - rawName?: InputMaybe; resolution?: InputMaybe; savePath?: InputMaybe; season?: InputMaybe; @@ -1708,7 +1720,7 @@ export type GetSubscriptionDetailQueryVariables = Exact<{ }>; -export type GetSubscriptionDetailQuery = { __typename?: 'Query', subscriptions: { __typename?: 'SubscriptionsConnection', nodes: Array<{ __typename?: 'Subscriptions', id: number, displayName: string, createdAt: string, updatedAt: string, category: SubscriptionCategoryEnum, sourceUrl: string, enabled: boolean, credential3rd?: { __typename?: 'Credential3rd', id: number, username?: string | null } | null, bangumi: { __typename?: 'BangumiConnection', nodes: Array<{ __typename?: 'Bangumi', createdAt: string, updatedAt: string, id: number, mikanBangumiId?: string | null, displayName: string, rawName: string, season: number, seasonRaw?: string | null, fansub?: string | null, mikanFansubId?: string | null, rssLink?: string | null, posterLink?: string | null, savePath?: string | null, homepage?: string | null }> } }> } }; +export type GetSubscriptionDetailQuery = { __typename?: 'Query', subscriptions: { __typename?: 'SubscriptionsConnection', nodes: Array<{ __typename?: 'Subscriptions', id: number, displayName: string, createdAt: string, updatedAt: string, category: SubscriptionCategoryEnum, sourceUrl: string, enabled: boolean, credential3rd?: { __typename?: 'Credential3rd', id: number, username?: string | null } | null, bangumi: { __typename?: 'BangumiConnection', nodes: Array<{ __typename?: 'Bangumi', createdAt: string, updatedAt: string, id: number, mikanBangumiId?: string | null, displayName: string, season: number, seasonRaw?: string | null, fansub?: string | null, mikanFansubId?: string | null, rssLink?: string | null, posterLink?: string | null, savePath?: string | null, homepage?: string | null }> } }> } }; export type SyncSubscriptionFeedsIncrementalMutationVariables = Exact<{ filter: SubscriptionsFilterInput; @@ -1765,7 +1777,7 @@ export const GetSubscriptionsDocument = {"kind":"Document","definitions":[{"kind export const InsertSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InsertSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsInsertInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionsCreateOne"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"credentialId"}}]}}]}}]} as unknown as DocumentNode; export const UpdateSubscriptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSubscriptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsUpdateInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsFilterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionsUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}}]}}]}}]} as unknown as DocumentNode; export const DeleteSubscriptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSubscriptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsFilterInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionsDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}}]}]}}]} as unknown as DocumentNode; -export const GetSubscriptionDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSubscriptionDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"credential3rd"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bangumi"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"mikanBangumiId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"rawName"}},{"kind":"Field","name":{"kind":"Name","value":"season"}},{"kind":"Field","name":{"kind":"Name","value":"seasonRaw"}},{"kind":"Field","name":{"kind":"Name","value":"fansub"}},{"kind":"Field","name":{"kind":"Name","value":"mikanFansubId"}},{"kind":"Field","name":{"kind":"Name","value":"rssLink"}},{"kind":"Field","name":{"kind":"Name","value":"posterLink"}},{"kind":"Field","name":{"kind":"Name","value":"savePath"}},{"kind":"Field","name":{"kind":"Name","value":"homepage"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetSubscriptionDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSubscriptionDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"credential3rd"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bangumi"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"mikanBangumiId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"season"}},{"kind":"Field","name":{"kind":"Name","value":"seasonRaw"}},{"kind":"Field","name":{"kind":"Name","value":"fansub"}},{"kind":"Field","name":{"kind":"Name","value":"mikanFansubId"}},{"kind":"Field","name":{"kind":"Name","value":"rssLink"}},{"kind":"Field","name":{"kind":"Name","value":"posterLink"}},{"kind":"Field","name":{"kind":"Name","value":"savePath"}},{"kind":"Field","name":{"kind":"Name","value":"homepage"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const SyncSubscriptionFeedsIncrementalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SyncSubscriptionFeedsIncremental"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsFilterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionsSyncOneFeedsIncremental"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const SyncSubscriptionFeedsFullDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SyncSubscriptionFeedsFull"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsFilterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionsSyncOneFeedsFull"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const SyncSubscriptionSourcesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SyncSubscriptionSources"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionsFilterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionsSyncOneSources"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; diff --git a/apps/webui/src/presentation/routes/_app/subscriptions/detail.$id.tsx b/apps/webui/src/presentation/routes/_app/subscriptions/detail.$id.tsx index c8b148a..001b06e 100644 --- a/apps/webui/src/presentation/routes/_app/subscriptions/detail.$id.tsx +++ b/apps/webui/src/presentation/routes/_app/subscriptions/detail.$id.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/card'; import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { Dialog, DialogTrigger } from '@/components/ui/dialog'; +import { Img } from '@/components/ui/img'; import { Label } from '@/components/ui/label'; import { QueryErrorView } from '@/components/ui/query-error-view'; import { Separator } from '@/components/ui/separator'; @@ -324,7 +325,18 @@ function SubscriptionDetailRouteComponent() {
{subscription.bangumi.nodes.map((bangumi) => ( -
+
+
+
+ {bangumi.posterLink && ( + Poster + )} +
+
-
- -
- {bangumi.season || '-'} -
-
+
+ {bangumi.season || '-'} +
+
+
+
- {bangumi.savePath || '-'} + {format( + new Date(bangumi.updatedAt), + 'yyyy-MM-dd' + )}
diff --git a/packages/util/src/errors.rs b/packages/util/src/errors.rs index adb71c8..b328b61 100644 --- a/packages/util/src/errors.rs +++ b/packages/util/src/errors.rs @@ -21,6 +21,10 @@ impl OptDynErr { pub fn none() -> Self { Self(None) } + + pub fn into_inner(self) -> Option> { + self.0 + } } impl Display for OptDynErr {