feat: support static server

This commit is contained in:
master 2025-06-18 02:19:42 +08:00
parent 35312ea1ff
commit 6726cafff4
26 changed files with 321 additions and 230 deletions

View File

@ -1 +1 @@
^https://mikanani.me/*** http://127.0.0.1:5005/$1
^https://mikanani.me/*** http://127.0.0.1:5005/$1 excludeFilter://^**/***.svg excludeFilter://^**/***.css excludeFilter://^**/***.js

View File

@ -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::<Arc<dyn AppContextTrait>>::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>(())

View File

@ -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<opendal::Error>,
},
#[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<reqwest_middleware::Error> for RecorderError {
}
}
impl From<http::header::InvalidHeaderValue> for RecorderError {
fn from(_error: http::header::InvalidHeaderValue) -> Self {
Self::InvalidHeaderValueError
}
}
impl From<http::header::InvalidHeaderName> for RecorderError {
fn from(_error: http::header::InvalidHeaderName) -> Self {
Self::InvalidHeaderNameError
}
}
impl From<http::method::InvalidMethod> for RecorderError {
fn from(_error: http::method::InvalidMethod) -> Self {
Self::InvalidMethodError
}
}
pub type RecorderResult<T> = Result<T, RecorderError>;

View File

@ -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<TorrentEpisodeSubtitleMeta> = 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<TorrentEpisodeMediaMeta> = 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 {

View File

@ -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<MikanBangumiPosterMeta> {
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",
);

View File

@ -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;

View File

@ -0,0 +1,5 @@
pub mod parser;
pub use parser::{
RawEpisodeMeta, extract_episode_meta_from_origin_name, extract_season_from_title_body,
};

View File

@ -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<RawEpisodeMeta> {
pub fn extract_episode_meta_from_origin_name(s: &str) -> RecorderResult<RawEpisodeMeta> {
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<RawEpisodeM
#[cfg(test)]
mod tests {
use super::{RawEpisodeMeta, extract_episode_meta_from_raw_name};
use super::{RawEpisodeMeta, extract_episode_meta_from_origin_name};
fn test_raw_ep_parser_case(raw_name: &str, expected: &str) {
let expected: Option<RawEpisodeMeta> = 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!(

View File

@ -1,5 +0,0 @@
pub mod parser;
pub use parser::{
RawEpisodeMeta, extract_episode_meta_from_raw_name, extract_season_from_title_body,
};

View File

@ -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,

View File

@ -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))

View File

@ -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))

View File

@ -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<String>,
pub subscriber_id: i32,
pub display_name: String,
pub raw_name: String,
pub origin_name: String,
pub season: i32,
pub season_raw: Option<String>,
pub fansub: Option<String>,
@ -49,6 +49,7 @@ pub struct Model {
pub filter: Option<BangumiFilter>,
pub rss_link: Option<String>,
pub poster_link: Option<String>,
pub origin_poster_link: Option<String>,
pub save_path: Option<String>,
pub homepage: Option<String>,
}
@ -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,

View File

@ -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,

View File

@ -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<String>,
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<String>,
pub fansub: Option<String>,
pub poster_link: Option<String>,
pub origin_poster_link: Option<String>,
pub episode_index: i32,
pub homepage: Option<String>,
pub subtitle: Option<String>,
@ -123,7 +124,7 @@ impl ActiveModel {
episode: MikanEpisodeMeta,
) -> RecorderResult<Self> {
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])

View File

@ -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<Path>) -> 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<Path>) -> 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::<PathBuf>(),
)
}
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::<PathBuf>(),
)
}
@ -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<str>,
range: Option<TypedHeader<Range>>,
) -> RecorderResult<Response> {
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::<Option<Vec<_>>>();
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<Item = Result<Bytes, RecorderError>> = {
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)
}
}

View File

@ -1,18 +1,23 @@
use std::ops::Bound;
pub fn bound_range_to_content_range(
r: &(Bound<u64>, Bound<u64>),
l: u64,
) -> Result<String, String> {
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<u64>, Bound<u64>), l: u64) -> Option<HeaderValue> {
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())
}

View File

@ -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<Arc<dyn AppContextTrait>>,
Path((subscriber_id, path)): Path<(i32, String)>,
Extension(auth_user_info): Extension<AuthUserInfo>,
@ -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<Arc<dyn AppContextTrait>>,
Path(path): Path<String>,
range: Option<TypedHeader<Range>>,
) -> RecorderResult<Response> {
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<Item = Result<Bytes, RecorderError>> = {
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<dyn AppContextTrait>) -> RecorderResult<Controller> {
let router = Router::<Arc<dyn AppContextTrait>>::new().route(
"/subscribers/{subscriber_id}/*path",
get(serve_file_with_cache).layer(from_fn_with_state(ctx, auth_middleware)),
);
let router = Router::<Arc<dyn AppContextTrait>>::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))
}

View File

@ -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() {
>
<div className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<div className="relative size-8">
<Image
<Img
src="/assets/favicon.png"
alt="App Logo"
className="object-cover"

View File

@ -1,9 +0,0 @@
import type { ComponentProps } from 'react';
export type ImageProps = Omit<ComponentProps<'img'>, 'alt'> &
Required<Pick<ComponentProps<'img'>, 'alt'>>;
export const Image = (props: ImageProps) => {
// biome-ignore lint/nursery/noImgElement: <explanation>
return <img {...props} alt={props.alt} />;
};

View File

@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
export type ImgProps = Omit<ComponentProps<"img">, "alt"> &
Required<Pick<ComponentProps<"img">, "alt">>;
export const Img = (props: ImgProps) => {
// biome-ignore lint/nursery/noImgElement: <explanation>
return <img {...props} alt={props.alt} />;
};

View File

@ -106,7 +106,6 @@ query GetSubscriptionDetail ($id: Int!) {
id
mikanBangumiId
displayName
rawName
season
seasonRaw
fansub

View File

@ -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.
*/

View File

@ -30,8 +30,9 @@ export type Bangumi = {
id: Scalars['Int']['output'];
mikanBangumiId?: Maybe<Scalars['String']['output']>;
mikanFansubId?: Maybe<Scalars['String']['output']>;
originName: Scalars['String']['output'];
originPosterLink?: Maybe<Scalars['String']['output']>;
posterLink?: Maybe<Scalars['String']['output']>;
rawName: Scalars['String']['output'];
rssLink?: Maybe<Scalars['String']['output']>;
savePath?: Maybe<Scalars['String']['output']>;
season: Scalars['Int']['output'];
@ -74,8 +75,9 @@ export type BangumiBasic = {
id: Scalars['Int']['output'];
mikanBangumiId?: Maybe<Scalars['String']['output']>;
mikanFansubId?: Maybe<Scalars['String']['output']>;
originName: Scalars['String']['output'];
originPosterLink?: Maybe<Scalars['String']['output']>;
posterLink?: Maybe<Scalars['String']['output']>;
rawName: Scalars['String']['output'];
rssLink?: Maybe<Scalars['String']['output']>;
savePath?: Maybe<Scalars['String']['output']>;
season: Scalars['Int']['output'];
@ -108,8 +110,9 @@ export type BangumiFilterInput = {
mikanBangumiId?: InputMaybe<StringFilterInput>;
mikanFansubId?: InputMaybe<StringFilterInput>;
or?: InputMaybe<Array<BangumiFilterInput>>;
originName?: InputMaybe<StringFilterInput>;
originPosterLink?: InputMaybe<StringFilterInput>;
posterLink?: InputMaybe<StringFilterInput>;
rawName?: InputMaybe<StringFilterInput>;
rssLink?: InputMaybe<StringFilterInput>;
savePath?: InputMaybe<StringFilterInput>;
season?: InputMaybe<IntegerFilterInput>;
@ -127,8 +130,9 @@ export type BangumiInsertInput = {
id?: InputMaybe<Scalars['Int']['input']>;
mikanBangumiId?: InputMaybe<Scalars['String']['input']>;
mikanFansubId?: InputMaybe<Scalars['String']['input']>;
originName: Scalars['String']['input'];
originPosterLink?: InputMaybe<Scalars['String']['input']>;
posterLink?: InputMaybe<Scalars['String']['input']>;
rawName: Scalars['String']['input'];
rssLink?: InputMaybe<Scalars['String']['input']>;
savePath?: InputMaybe<Scalars['String']['input']>;
season: Scalars['Int']['input'];
@ -146,8 +150,9 @@ export type BangumiOrderInput = {
id?: InputMaybe<OrderByEnum>;
mikanBangumiId?: InputMaybe<OrderByEnum>;
mikanFansubId?: InputMaybe<OrderByEnum>;
originName?: InputMaybe<OrderByEnum>;
originPosterLink?: InputMaybe<OrderByEnum>;
posterLink?: InputMaybe<OrderByEnum>;
rawName?: InputMaybe<OrderByEnum>;
rssLink?: InputMaybe<OrderByEnum>;
savePath?: InputMaybe<OrderByEnum>;
season?: InputMaybe<OrderByEnum>;
@ -165,8 +170,9 @@ export type BangumiUpdateInput = {
id?: InputMaybe<Scalars['Int']['input']>;
mikanBangumiId?: InputMaybe<Scalars['String']['input']>;
mikanFansubId?: InputMaybe<Scalars['String']['input']>;
originName?: InputMaybe<Scalars['String']['input']>;
originPosterLink?: InputMaybe<Scalars['String']['input']>;
posterLink?: InputMaybe<Scalars['String']['input']>;
rawName?: InputMaybe<Scalars['String']['input']>;
rssLink?: InputMaybe<Scalars['String']['input']>;
savePath?: InputMaybe<Scalars['String']['input']>;
season?: InputMaybe<Scalars['Int']['input']>;
@ -492,7 +498,7 @@ export type Downloads = {
homepage?: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
mime: DownloadMimeEnum;
rawName: Scalars['String']['output'];
originName: Scalars['String']['output'];
savePath?: Maybe<Scalars['String']['output']>;
status: DownloadStatusEnum;
subscriber?: Maybe<Subscribers>;
@ -512,7 +518,7 @@ export type DownloadsBasic = {
homepage?: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
mime: DownloadMimeEnum;
rawName: Scalars['String']['output'];
originName: Scalars['String']['output'];
savePath?: Maybe<Scalars['String']['output']>;
status: DownloadStatusEnum;
subscriberId: Scalars['Int']['output'];
@ -546,7 +552,7 @@ export type DownloadsFilterInput = {
id?: InputMaybe<IntegerFilterInput>;
mime?: InputMaybe<DownloadMimeEnumFilterInput>;
or?: InputMaybe<Array<DownloadsFilterInput>>;
rawName?: InputMaybe<StringFilterInput>;
originName?: InputMaybe<StringFilterInput>;
savePath?: InputMaybe<StringFilterInput>;
status?: InputMaybe<DownloadStatusEnumFilterInput>;
subscriberId?: InputMaybe<SubscriberIdFilterInput>;
@ -564,7 +570,7 @@ export type DownloadsInsertInput = {
homepage?: InputMaybe<Scalars['String']['input']>;
id?: InputMaybe<Scalars['Int']['input']>;
mime: DownloadMimeEnum;
rawName: Scalars['String']['input'];
originName: Scalars['String']['input'];
savePath?: InputMaybe<Scalars['String']['input']>;
status: DownloadStatusEnum;
subscriberId?: InputMaybe<Scalars['Int']['input']>;
@ -582,7 +588,7 @@ export type DownloadsOrderInput = {
homepage?: InputMaybe<OrderByEnum>;
id?: InputMaybe<OrderByEnum>;
mime?: InputMaybe<OrderByEnum>;
rawName?: InputMaybe<OrderByEnum>;
originName?: InputMaybe<OrderByEnum>;
savePath?: InputMaybe<OrderByEnum>;
status?: InputMaybe<OrderByEnum>;
subscriberId?: InputMaybe<OrderByEnum>;
@ -600,7 +606,7 @@ export type DownloadsUpdateInput = {
homepage?: InputMaybe<Scalars['String']['input']>;
id?: InputMaybe<Scalars['Int']['input']>;
mime?: InputMaybe<DownloadMimeEnum>;
rawName?: InputMaybe<Scalars['String']['input']>;
originName?: InputMaybe<Scalars['String']['input']>;
savePath?: InputMaybe<Scalars['String']['input']>;
status?: InputMaybe<DownloadStatusEnum>;
updatedAt?: InputMaybe<Scalars['String']['input']>;
@ -619,8 +625,9 @@ export type Episodes = {
homepage?: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
mikanEpisodeId?: Maybe<Scalars['String']['output']>;
originName: Scalars['String']['output'];
originPosterLink?: Maybe<Scalars['String']['output']>;
posterLink?: Maybe<Scalars['String']['output']>;
rawName: Scalars['String']['output'];
resolution?: Maybe<Scalars['String']['output']>;
savePath?: Maybe<Scalars['String']['output']>;
season: Scalars['Int']['output'];
@ -665,8 +672,9 @@ export type EpisodesBasic = {
homepage?: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
mikanEpisodeId?: Maybe<Scalars['String']['output']>;
originName: Scalars['String']['output'];
originPosterLink?: Maybe<Scalars['String']['output']>;
posterLink?: Maybe<Scalars['String']['output']>;
rawName: Scalars['String']['output'];
resolution?: Maybe<Scalars['String']['output']>;
savePath?: Maybe<Scalars['String']['output']>;
season: Scalars['Int']['output'];
@ -702,8 +710,9 @@ export type EpisodesFilterInput = {
id?: InputMaybe<IntegerFilterInput>;
mikanEpisodeId?: InputMaybe<StringFilterInput>;
or?: InputMaybe<Array<EpisodesFilterInput>>;
originName?: InputMaybe<StringFilterInput>;
originPosterLink?: InputMaybe<StringFilterInput>;
posterLink?: InputMaybe<StringFilterInput>;
rawName?: InputMaybe<StringFilterInput>;
resolution?: InputMaybe<StringFilterInput>;
savePath?: InputMaybe<StringFilterInput>;
season?: InputMaybe<IntegerFilterInput>;
@ -723,8 +732,9 @@ export type EpisodesInsertInput = {
homepage?: InputMaybe<Scalars['String']['input']>;
id?: InputMaybe<Scalars['Int']['input']>;
mikanEpisodeId?: InputMaybe<Scalars['String']['input']>;
originName: Scalars['String']['input'];
originPosterLink?: InputMaybe<Scalars['String']['input']>;
posterLink?: InputMaybe<Scalars['String']['input']>;
rawName: Scalars['String']['input'];
resolution?: InputMaybe<Scalars['String']['input']>;
savePath?: InputMaybe<Scalars['String']['input']>;
season: Scalars['Int']['input'];
@ -744,8 +754,9 @@ export type EpisodesOrderInput = {
homepage?: InputMaybe<OrderByEnum>;
id?: InputMaybe<OrderByEnum>;
mikanEpisodeId?: InputMaybe<OrderByEnum>;
originName?: InputMaybe<OrderByEnum>;
originPosterLink?: InputMaybe<OrderByEnum>;
posterLink?: InputMaybe<OrderByEnum>;
rawName?: InputMaybe<OrderByEnum>;
resolution?: InputMaybe<OrderByEnum>;
savePath?: InputMaybe<OrderByEnum>;
season?: InputMaybe<OrderByEnum>;
@ -765,8 +776,9 @@ export type EpisodesUpdateInput = {
homepage?: InputMaybe<Scalars['String']['input']>;
id?: InputMaybe<Scalars['Int']['input']>;
mikanEpisodeId?: InputMaybe<Scalars['String']['input']>;
originName?: InputMaybe<Scalars['String']['input']>;
originPosterLink?: InputMaybe<Scalars['String']['input']>;
posterLink?: InputMaybe<Scalars['String']['input']>;
rawName?: InputMaybe<Scalars['String']['input']>;
resolution?: InputMaybe<Scalars['String']['input']>;
savePath?: InputMaybe<Scalars['String']['input']>;
season?: InputMaybe<Scalars['Int']['input']>;
@ -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<InsertSubscriptionMutation, InsertSubscriptionMutationVariables>;
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<UpdateSubscriptionsMutation, UpdateSubscriptionsMutationVariables>;
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<DeleteSubscriptionsMutation, DeleteSubscriptionsMutationVariables>;
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<GetSubscriptionDetailQuery, GetSubscriptionDetailQueryVariables>;
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<GetSubscriptionDetailQuery, GetSubscriptionDetailQueryVariables>;
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<SyncSubscriptionFeedsIncrementalMutation, SyncSubscriptionFeedsIncrementalMutationVariables>;
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<SyncSubscriptionFeedsFullMutation, SyncSubscriptionFeedsFullMutationVariables>;
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<SyncSubscriptionSourcesMutation, SyncSubscriptionSourcesMutationVariables>;

View File

@ -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() {
<div className="space-y-3">
{subscription.bangumi.nodes.map((bangumi) => (
<Card key={bangumi.id} className="p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<div className="col-span-1 row-span-2 space-y-2">
<div className="flex h-full items-center justify-center overflow-hidden rounded-md bg-muted">
{bangumi.posterLink && (
<Img
src={`/api/static${bangumi.posterLink}`}
alt="Poster"
className="h-full w-full object-cover"
/>
)}
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-muted-foreground text-xs">
Display Name
@ -333,14 +345,6 @@ function SubscriptionDetailRouteComponent() {
{bangumi.displayName}
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-muted-foreground text-xs">
Season
</Label>
<div className="text-sm">
{bangumi.season || '-'}
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-muted-foreground text-xs">
Fansub
@ -351,10 +355,21 @@ function SubscriptionDetailRouteComponent() {
</div>
<div className="space-y-2">
<Label className="font-medium text-muted-foreground text-xs">
Save Path
Season
</Label>
<div className="text-sm">
{bangumi.season || '-'}
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-muted-foreground text-xs">
Updated At
</Label>
<div className="font-mono text-sm">
{bangumi.savePath || '-'}
{format(
new Date(bangumi.updatedAt),
'yyyy-MM-dd'
)}
</div>
</div>
</div>

View File

@ -21,6 +21,10 @@ impl OptDynErr {
pub fn none() -> Self {
Self(None)
}
pub fn into_inner(self) -> Option<Box<dyn std::error::Error + Send + Sync>> {
self.0
}
}
impl Display for OptDynErr {