mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-08 09:12:56 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b046362d7 | ||
|
|
61c9e7de88 | ||
|
|
3d25c6b321 | ||
|
|
d35858790b | ||
|
|
b441f04cdf | ||
|
|
4db7e6763a | ||
|
|
bbbb7d0c5b | ||
|
|
210c94398a | ||
|
|
6c7d295fe6 | ||
|
|
71519af2f3 | ||
|
|
8ed2fbae24 | ||
|
|
fd90bc8b73 | ||
|
|
66bd3d6a41 | ||
|
|
5ef23a678f | ||
|
|
66079f3adc | ||
|
|
4f780faf64 | ||
|
|
dbcb1fa78b | ||
|
|
386dac7735 | ||
|
|
5537c621be |
2
.github/workflows/pr-check.yaml
vendored
2
.github/workflows/pr-check.yaml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: cargo +nightly fmt --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test
|
||||
|
||||
56
Cargo.lock
generated
56
Cargo.lock
generated
@@ -475,7 +475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync"
|
||||
version = "2.6.2"
|
||||
version = "2.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -524,21 +524,25 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ua_generator",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync_entity"
|
||||
version = "2.6.2"
|
||||
version = "2.7.0"
|
||||
dependencies = [
|
||||
"derivative",
|
||||
"regex",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync_migration"
|
||||
version = "2.6.2"
|
||||
version = "2.7.0"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"sea-orm-migration",
|
||||
@@ -1033,6 +1037,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derivative"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@@ -3123,6 +3138,7 @@ version = "0.23.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4314,6 +4330,20 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
||||
|
||||
[[package]]
|
||||
name = "ua_generator"
|
||||
version = "0.5.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a68ea0a55d5ad9e86e85f767180daff9f24a030490ac66e8490861e8484d7ed"
|
||||
dependencies = [
|
||||
"dotenvy",
|
||||
"fastrand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.6"
|
||||
@@ -4353,6 +4383,26 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"brotli-decompressor",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.4"
|
||||
|
||||
@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.6.2"
|
||||
version = "2.7.0"
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
@@ -29,6 +29,7 @@ clap = { version = "4.5.41", features = ["env", "string"] }
|
||||
cookie = "0.18.1"
|
||||
cow-utils = "0.1.3"
|
||||
dashmap = "6.1.0"
|
||||
derivative = "2.2.0"
|
||||
dirs = "6.0.0"
|
||||
enum_dispatch = "0.3.13"
|
||||
float-ord = "0.3.2"
|
||||
@@ -75,6 +76,7 @@ toml = "0.9.1"
|
||||
tower = "0.5.2"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "json"] }
|
||||
ua_generator = "0.5.22"
|
||||
uuid = { version = "1.17.0", features = ["v4"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ toml = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
ua_generator = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
@@ -55,14 +56,19 @@ impl VideoSource for collection::Model {
|
||||
latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
// 由于 collection 的视频无固定时间顺序,should_take 无法提前中断拉取,因此 should_filter 环节需要进行额外过滤
|
||||
if let Ok(video_info) = video_info {
|
||||
if video_info.release_datetime() > latest_row_at {
|
||||
return Some(video_info);
|
||||
}
|
||||
if let Ok(video_info) = video_info
|
||||
&& video_info.release_datetime() > latest_row_at
|
||||
{
|
||||
return Some(video_info);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
@@ -42,6 +43,10 @@ impl VideoSource for favorite::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -19,6 +19,7 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
#[rustfmt::skip]
|
||||
use bili_sync_entity::collection::Model as Collection;
|
||||
use bili_sync_entity::favorite::Model as Favorite;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::submission::Model as Submission;
|
||||
use bili_sync_entity::watch_later::Model as WatchLater;
|
||||
|
||||
@@ -68,6 +69,8 @@ pub trait VideoSource {
|
||||
video_info.ok()
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule>;
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描{}..", self.display_name());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
@@ -41,6 +42,10 @@ impl VideoSource for submission::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
@@ -41,6 +42,10 @@ impl VideoSource for watch_later::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
@@ -81,14 +82,10 @@ pub struct InsertSubmissionRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImageProxyParams {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateVideoSourceRequest {
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub rule: Option<Rule>,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::{DerivePartialModel, FromQueryResult};
|
||||
use serde::Serialize;
|
||||
@@ -58,6 +59,7 @@ pub struct VideoInfo {
|
||||
pub bvid: String,
|
||||
pub name: String,
|
||||
pub upper_name: String,
|
||||
pub should_download: bool,
|
||||
#[serde(serialize_with = "serde_video_download_status")]
|
||||
pub download_status: u32,
|
||||
}
|
||||
@@ -169,9 +171,19 @@ pub struct SysInfo {
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromQueryResult)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoSourceDetail {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub rule: Option<Rule>,
|
||||
#[serde(default)]
|
||||
pub rule_display: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateVideoSourceResponse {
|
||||
pub rule_display: Option<String>,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn get_config() -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||
|
||||
/// 更新全局配置
|
||||
pub async fn update_config(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(config): ValidatedJson<Config>,
|
||||
) -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||
let Some(_lock) = TASK_STATUS_NOTIFIER.detect_running() else {
|
||||
@@ -30,7 +30,7 @@ pub async fn update_config(
|
||||
return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into());
|
||||
};
|
||||
config.check()?;
|
||||
let new_config = VersionedConfig::get().update(config, db.as_ref()).await?;
|
||||
let new_config = VersionedConfig::get().update(config, &db).await?;
|
||||
drop(_lock);
|
||||
Ok(ApiResponse::ok(new_config))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use bili_sync_entity::*;
|
||||
@@ -14,21 +12,21 @@ pub(super) fn router() -> Router {
|
||||
}
|
||||
|
||||
async fn get_dashboard(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<DashBoardResponse>, ApiError> {
|
||||
let (enabled_favorites, enabled_collections, enabled_submissions, enabled_watch_later, videos_by_day) = tokio::try_join!(
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::Enabled.eq(true))
|
||||
.count(db.as_ref()),
|
||||
.count(&db),
|
||||
collection::Entity::find()
|
||||
.filter(collection::Column::Enabled.eq(true))
|
||||
.count(db.as_ref()),
|
||||
.count(&db),
|
||||
submission::Entity::find()
|
||||
.filter(submission::Column::Enabled.eq(true))
|
||||
.count(db.as_ref()),
|
||||
.count(&db),
|
||||
watch_later::Entity::find()
|
||||
.filter(watch_later::Column::Enabled.eq(true))
|
||||
.count(db.as_ref()),
|
||||
.count(&db),
|
||||
DayCountPair::find_by_statement(Statement::from_string(
|
||||
db.get_database_backend(),
|
||||
// 用 SeaORM 太复杂了,直接写个裸 SQL
|
||||
@@ -55,13 +53,13 @@ ORDER BY
|
||||
dates.day;
|
||||
"
|
||||
))
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
)?;
|
||||
return Ok(ApiResponse::ok(DashBoardResponse {
|
||||
Ok(ApiResponse::ok(DashBoardResponse {
|
||||
enabled_favorites,
|
||||
enabled_collections,
|
||||
enabled_submissions,
|
||||
enable_watch_later: enabled_watch_later > 0,
|
||||
videos_by_day,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ pub(super) fn router() -> Router {
|
||||
|
||||
/// 获取当前用户创建的收藏夹
|
||||
pub async fn get_created_favorites(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
) -> Result<ApiResponse<FavoritesResponse>, ApiError> {
|
||||
let me = Me::new(bili_client.as_ref());
|
||||
let bili_favorites = me.get_created_favorites().await?;
|
||||
|
||||
let favorites = if let Some(bili_favorites) = bili_favorites {
|
||||
// b 站收藏夹相关接口使用的所谓 “fid” 其实是该处的 id,即 fid + mid 后两位
|
||||
// b 站收藏夹相关接口使用的所谓“fid”其实是该处的 id,即 fid + mid 后两位
|
||||
let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect();
|
||||
|
||||
let subscribed_fids: Vec<i64> = favorite::Entity::find()
|
||||
@@ -40,7 +40,7 @@ pub async fn get_created_favorites(
|
||||
.column(favorite::Column::FId)
|
||||
.filter(favorite::Column::FId.is_in(bili_fids))
|
||||
.into_tuple()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
.await?;
|
||||
let subscribed_set: HashSet<i64> = subscribed_fids.into_iter().collect();
|
||||
|
||||
@@ -64,7 +64,7 @@ pub async fn get_created_favorites(
|
||||
|
||||
/// 获取当前用户收藏的合集
|
||||
pub async fn get_followed_collections(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<FollowedCollectionsRequest>,
|
||||
) -> Result<ApiResponse<CollectionsResponse>, ApiError> {
|
||||
@@ -80,7 +80,7 @@ pub async fn get_followed_collections(
|
||||
.column(collection::Column::SId)
|
||||
.filter(collection::Column::SId.is_in(bili_sids))
|
||||
.into_tuple()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
.await?;
|
||||
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
|
||||
|
||||
@@ -106,7 +106,7 @@ pub async fn get_followed_collections(
|
||||
|
||||
/// 获取当前用户关注的 UP 主
|
||||
pub async fn get_followed_uppers(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<FollowedUppersRequest>,
|
||||
) -> Result<ApiResponse<UppersResponse>, ApiError> {
|
||||
@@ -121,7 +121,7 @@ pub async fn get_followed_uppers(
|
||||
.column(submission::Column::UpperId)
|
||||
.filter(submission::Column::UpperId.is_in(bili_uid))
|
||||
.into_tuple()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
.await?;
|
||||
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Query, Request};
|
||||
use axum::extract::Request;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Router, middleware};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||
use reqwest::{Method, StatusCode, header};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::request::ImageProxyParams;
|
||||
use crate::api::wrapper::ApiResponse;
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
mod config;
|
||||
@@ -27,7 +20,7 @@ mod ws;
|
||||
pub use ws::{LogHelper, MAX_HISTORY_LOGS};
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new().route("/image-proxy", get(image_proxy)).nest(
|
||||
Router::new().nest(
|
||||
"/api",
|
||||
config::router()
|
||||
.merge(me::router())
|
||||
@@ -50,57 +43,16 @@ pub async fn auth(mut headers: HeaderMap, request: Request, next: Next) -> Resul
|
||||
{
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
if let Some(protocol) = headers.remove("Sec-WebSocket-Protocol") {
|
||||
if protocol
|
||||
if let Some(protocol) = headers.remove("Sec-WebSocket-Protocol")
|
||||
&& protocol
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| BASE64_URL_SAFE_NO_PAD.decode(s).ok())
|
||||
.is_some_and(|s| s == token.as_bytes())
|
||||
{
|
||||
let mut resp = next.run(request).await;
|
||||
resp.headers_mut().insert("Sec-WebSocket-Protocol", protocol);
|
||||
return Ok(resp);
|
||||
}
|
||||
{
|
||||
let mut resp = next.run(request).await;
|
||||
resp.headers_mut().insert("Sec-WebSocket-Protocol", protocol);
|
||||
return Ok(resp);
|
||||
}
|
||||
Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response())
|
||||
}
|
||||
|
||||
/// B 站的图片会检查 referer,需要做个转发伪造一下,否则直接返回 403
|
||||
pub async fn image_proxy(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<ImageProxyParams>,
|
||||
) -> Response {
|
||||
let resp = bili_client.client.request(Method::GET, ¶ms.url, None).send().await;
|
||||
let whitelist = [
|
||||
header::CONTENT_TYPE,
|
||||
header::CONTENT_LENGTH,
|
||||
header::CACHE_CONTROL,
|
||||
header::EXPIRES,
|
||||
header::LAST_MODIFIED,
|
||||
header::ETAG,
|
||||
header::CONTENT_DISPOSITION,
|
||||
header::CONTENT_ENCODING,
|
||||
header::ACCEPT_RANGES,
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let builder = Response::builder();
|
||||
|
||||
let response = match resp {
|
||||
Err(e) => builder.status(StatusCode::BAD_GATEWAY).body(Body::new(e.to_string())),
|
||||
Ok(res) => {
|
||||
let mut response = builder.status(res.status());
|
||||
for (k, v) in res.headers() {
|
||||
if whitelist.contains(k) {
|
||||
response = response.header(k, v);
|
||||
}
|
||||
}
|
||||
let streams = res.bytes_stream();
|
||||
response.body(Body::from_stream(streams))
|
||||
}
|
||||
};
|
||||
//safety: all previously configured headers are taken from a valid response, ensuring the response is safe to use
|
||||
response.unwrap()
|
||||
}
|
||||
|
||||
@@ -4,25 +4,31 @@ use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::routing::{get, post, put};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::Expr;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, TransactionTrait};
|
||||
|
||||
use crate::adapter::_ActiveModel;
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::request::{
|
||||
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
|
||||
};
|
||||
use crate::api::response::{VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse};
|
||||
use crate::api::response::{
|
||||
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/video-sources", get(get_video_sources))
|
||||
.route("/video-sources/details", get(get_video_sources_details))
|
||||
.route("/video-sources/{type}/{id}", put(update_video_source))
|
||||
.route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source))
|
||||
.route("/video-sources/favorites", post(insert_favorite))
|
||||
.route("/video-sources/collections", post(insert_collection))
|
||||
.route("/video-sources/submissions", post(insert_submission))
|
||||
@@ -30,31 +36,31 @@ pub(super) fn router() -> Router {
|
||||
|
||||
/// 列出所有视频来源
|
||||
pub async fn get_video_sources(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<VideoSourcesResponse>, ApiError> {
|
||||
let (collection, favorite, submission, mut watch_later) = tokio::try_join!(
|
||||
collection::Entity::find()
|
||||
.select_only()
|
||||
.columns([collection::Column::Id, collection::Column::Name])
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
favorite::Entity::find()
|
||||
.select_only()
|
||||
.columns([favorite::Column::Id, favorite::Column::Name])
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
submission::Entity::find()
|
||||
.select_only()
|
||||
.column(submission::Column::Id)
|
||||
.column_as(submission::Column::UpperName, "name")
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
watch_later::Entity::find()
|
||||
.select_only()
|
||||
.column(watch_later::Column::Id)
|
||||
.column_as(Expr::value("稍后再看"), "name")
|
||||
.into_model::<VideoSource>()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
)?;
|
||||
// watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项
|
||||
if watch_later.is_empty() {
|
||||
@@ -73,52 +79,71 @@ pub async fn get_video_sources(
|
||||
|
||||
/// 获取视频来源详情
|
||||
pub async fn get_video_sources_details(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
|
||||
let (collections, favorites, submissions, mut watch_later) = tokio::try_join!(
|
||||
let (mut collections, mut favorites, mut submissions, mut watch_later) = tokio::try_join!(
|
||||
collection::Entity::find()
|
||||
.select_only()
|
||||
.columns([
|
||||
collection::Column::Id,
|
||||
collection::Column::Name,
|
||||
collection::Column::Path,
|
||||
collection::Column::Rule,
|
||||
collection::Column::Enabled
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
favorite::Entity::find()
|
||||
.select_only()
|
||||
.columns([
|
||||
favorite::Column::Id,
|
||||
favorite::Column::Name,
|
||||
favorite::Column::Path,
|
||||
favorite::Column::Rule,
|
||||
favorite::Column::Enabled
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
submission::Entity::find()
|
||||
.select_only()
|
||||
.column(submission::Column::Id)
|
||||
.column_as(submission::Column::UpperName, "name")
|
||||
.columns([submission::Column::Path, submission::Column::Enabled])
|
||||
.columns([
|
||||
submission::Column::Id,
|
||||
submission::Column::Path,
|
||||
submission::Column::Enabled,
|
||||
submission::Column::Rule
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(db.as_ref()),
|
||||
.all(&db),
|
||||
watch_later::Entity::find()
|
||||
.select_only()
|
||||
.column(watch_later::Column::Id)
|
||||
.column_as(Expr::value("稍后再看"), "name")
|
||||
.columns([watch_later::Column::Path, watch_later::Column::Enabled])
|
||||
.columns([
|
||||
watch_later::Column::Id,
|
||||
watch_later::Column::Path,
|
||||
watch_later::Column::Enabled,
|
||||
watch_later::Column::Rule
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
)?;
|
||||
if watch_later.is_empty() {
|
||||
watch_later.push(VideoSourceDetail {
|
||||
id: 1,
|
||||
name: "稍后再看".to_string(),
|
||||
path: String::new(),
|
||||
rule: None,
|
||||
rule_display: None,
|
||||
enabled: false,
|
||||
})
|
||||
}
|
||||
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
|
||||
sources.iter_mut().for_each(|item| {
|
||||
if let Some(rule) = &item.rule {
|
||||
item.rule_display = Some(rule.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
|
||||
collections,
|
||||
favorites,
|
||||
@@ -130,29 +155,33 @@ pub async fn get_video_sources_details(
|
||||
/// 更新视频来源
|
||||
pub async fn update_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
) -> Result<ApiResponse<UpdateVideoSourceResponse>, ApiError> {
|
||||
let rule_display = request.rule.as_ref().map(|rule| rule.to_string());
|
||||
let active_model = match source_type.as_str() {
|
||||
"collections" => collection::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
|
||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: collection::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Collection(active_model)
|
||||
}),
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: favorite::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Favorite(active_model)
|
||||
}),
|
||||
"submissions" => submission::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
|
||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: submission::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Submission(active_model)
|
||||
}),
|
||||
"watch_later" => match watch_later::Entity::find_by_id(id).one(db.as_ref()).await? {
|
||||
"watch_later" => match watch_later::Entity::find_by_id(id).one(&db).await? {
|
||||
// 稍后再看需要做特殊处理,get 时如果稍后再看不存在返回的是 id 为 1 的假记录
|
||||
// 因此此处可能是更新也可能是插入,做个额外的处理
|
||||
Some(model) => {
|
||||
@@ -160,6 +189,7 @@ pub async fn update_video_source(
|
||||
let mut active_model: watch_later::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
Some(_ActiveModel::WatchLater(active_model))
|
||||
}
|
||||
None => {
|
||||
@@ -170,6 +200,7 @@ pub async fn update_video_source(
|
||||
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
|
||||
path: Set(request.path),
|
||||
enabled: Set(request.enabled),
|
||||
rule: Set(request.rule),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
@@ -180,13 +211,90 @@ pub async fn update_video_source(
|
||||
let Some(active_model) = active_model else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
active_model.save(db.as_ref()).await?;
|
||||
active_model.save(&db).await?;
|
||||
Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display }))
|
||||
}
|
||||
|
||||
pub async fn evaluate_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
// 找出对应 source 的规则与 video 筛选条件
|
||||
let (rule, filter_condition) = match source_type.as_str() {
|
||||
"collections" => (
|
||||
collection::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(collection::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::CollectionId.eq(id),
|
||||
),
|
||||
"favorites" => (
|
||||
favorite::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(favorite::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::FavoriteId.eq(id),
|
||||
),
|
||||
"submissions" => (
|
||||
submission::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(submission::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::SubmissionId.eq(id),
|
||||
),
|
||||
"watch_later" => (
|
||||
watch_later::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(watch_later::Column::Rule)
|
||||
.into_tuple::<Option<Rule>>()
|
||||
.one(&db)
|
||||
.await?
|
||||
.and_then(|r| r),
|
||||
video::Column::WatchLaterId.eq(id),
|
||||
),
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let videos: Vec<(video::Model, Vec<page::Model>)> = video::Entity::find()
|
||||
.filter(filter_condition)
|
||||
.find_with_related(page::Entity)
|
||||
.all(&db)
|
||||
.await?;
|
||||
let video_should_download_pairs = videos
|
||||
.into_iter()
|
||||
.map(|(video, pages)| (video.id, rule.evaluate_model(&video, &pages)))
|
||||
.collect::<Vec<(i32, bool)>>();
|
||||
let txn = db.begin().await?;
|
||||
for chunk in video_should_download_pairs.chunks(500) {
|
||||
let sql = format!(
|
||||
"WITH tempdata(id, should_download) AS (VALUES {}) \
|
||||
UPDATE video \
|
||||
SET should_download = tempdata.should_download \
|
||||
FROM tempdata \
|
||||
WHERE video.id = tempdata.id",
|
||||
chunk
|
||||
.iter()
|
||||
.map(|item| format!("({}, {})", item.0, item.1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
/// 新增收藏夹订阅
|
||||
pub async fn insert_favorite(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertFavoriteRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
@@ -196,17 +304,17 @@ pub async fn insert_favorite(
|
||||
f_id: Set(favorite_info.id),
|
||||
name: Set(favorite_info.title.clone()),
|
||||
path: Set(request.path),
|
||||
enabled: Set(true),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(db.as_ref())
|
||||
.exec(&db)
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
/// 新增合集/列表订阅
|
||||
pub async fn insert_collection(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertCollectionRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
@@ -225,10 +333,10 @@ pub async fn insert_collection(
|
||||
r#type: Set(collection_info.collection_type.into()),
|
||||
name: Set(collection_info.name.clone()),
|
||||
path: Set(request.path),
|
||||
enabled: Set(true),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(db.as_ref())
|
||||
.exec(&db)
|
||||
.await?;
|
||||
|
||||
Ok(ApiResponse::ok(true))
|
||||
@@ -236,7 +344,7 @@ pub async fn insert_collection(
|
||||
|
||||
/// 新增投稿订阅
|
||||
pub async fn insert_submission(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
@@ -246,10 +354,10 @@ pub async fn insert_submission(
|
||||
upper_id: Set(upper.mid.parse()?),
|
||||
upper_name: Set(upper.name),
|
||||
path: Set(request.path),
|
||||
enabled: Set(true),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(db.as_ref())
|
||||
.exec(&db)
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
@@ -31,7 +30,7 @@ pub(super) fn router() -> Router {
|
||||
|
||||
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
|
||||
pub async fn get_videos(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Query(params): Query<VideosRequest>,
|
||||
) -> Result<ApiResponse<VideosResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
@@ -48,7 +47,7 @@ pub async fn get_videos(
|
||||
if let Some(query_word) = params.query {
|
||||
query = query.filter(video::Column::Name.contains(query_word));
|
||||
}
|
||||
let total_count = query.clone().count(db.as_ref()).await?;
|
||||
let total_count = query.clone().count(&db).await?;
|
||||
let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) {
|
||||
(page, page_size)
|
||||
} else {
|
||||
@@ -58,7 +57,7 @@ pub async fn get_videos(
|
||||
videos: query
|
||||
.order_by_desc(video::Column::Id)
|
||||
.into_partial_model::<VideoInfo>()
|
||||
.paginate(db.as_ref(), page_size)
|
||||
.paginate(&db, page_size)
|
||||
.fetch_page(page)
|
||||
.await?,
|
||||
total_count,
|
||||
@@ -67,17 +66,15 @@ pub async fn get_videos(
|
||||
|
||||
pub async fn get_video(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<VideoResponse>, ApiError> {
|
||||
let (video_info, pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id)
|
||||
.into_partial_model::<VideoInfo>()
|
||||
.one(db.as_ref()),
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
)?;
|
||||
let Some(video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
@@ -90,18 +87,16 @@ pub async fn get_video(
|
||||
|
||||
pub async fn reset_video(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetRequest>,
|
||||
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
||||
let (video_info, pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id)
|
||||
.into_partial_model::<VideoInfo>()
|
||||
.one(db.as_ref()),
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
)?;
|
||||
let Some(mut video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
@@ -150,13 +145,13 @@ pub async fn reset_video(
|
||||
}
|
||||
|
||||
pub async fn reset_all_videos(
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetRequest>,
|
||||
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
|
||||
// 先查询所有视频和页面数据
|
||||
let (all_videos, all_pages) = tokio::try_join!(
|
||||
video::Entity::find().into_partial_model::<VideoInfo>().all(db.as_ref()),
|
||||
page::Entity::find().into_partial_model::<PageInfo>().all(db.as_ref())
|
||||
video::Entity::find().into_partial_model::<VideoInfo>().all(&db),
|
||||
page::Entity::find().into_partial_model::<PageInfo>().all(&db)
|
||||
)?;
|
||||
let resetted_pages_info = all_pages
|
||||
.into_iter()
|
||||
@@ -210,18 +205,16 @@ pub async fn reset_all_videos(
|
||||
|
||||
pub async fn update_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<UpdateVideoStatusResponse>, ApiError> {
|
||||
let (video_info, mut pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id)
|
||||
.into_partial_model::<VideoInfo>()
|
||||
.one(db.as_ref()),
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(db.as_ref())
|
||||
.all(&db)
|
||||
)?;
|
||||
let Some(mut video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
|
||||
@@ -118,7 +118,7 @@ impl WebSocketHandler {
|
||||
let rx = log_writer_clone.sender.subscribe();
|
||||
let log_stream = futures::stream::iter(history_logs.into_iter())
|
||||
.chain(BroadcastStream::new(rx).filter_map(async |msg| msg.ok()))
|
||||
.map(|msg| ServerEvent::Logs(msg));
|
||||
.map(ServerEvent::Logs);
|
||||
pin!(log_stream);
|
||||
while let Some(event) = log_stream.next().await {
|
||||
if let Err(e) = tx_clone.send(event).await {
|
||||
@@ -133,8 +133,8 @@ impl WebSocketHandler {
|
||||
if task_handle.as_ref().is_none_or(|h: &JoinHandle<()>| h.is_finished()) {
|
||||
let tx_clone = tx.clone();
|
||||
task_handle = Some(tokio::spawn(async move {
|
||||
let mut stream = WatchStream::new(TASK_STATUS_NOTIFIER.subscribe())
|
||||
.map(|status| ServerEvent::Tasks(status));
|
||||
let mut stream =
|
||||
WatchStream::new(TASK_STATUS_NOTIFIER.subscribe()).map(ServerEvent::Tasks);
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Err(e) = tx_clone.send(event).await {
|
||||
error!("Failed to send task status: {:?}", e);
|
||||
@@ -179,7 +179,7 @@ impl WebSocketHandler {
|
||||
// 添加订阅者
|
||||
async fn add_sysinfo_subscriber(&self, uuid: Uuid, sender: tokio::sync::mpsc::Sender<ServerEvent>) {
|
||||
self.sysinfo_subscribers.insert(uuid, sender);
|
||||
if self.sysinfo_subscribers.len() > 0
|
||||
if !self.sysinfo_subscribers.is_empty()
|
||||
&& self
|
||||
.sysinfo_handles
|
||||
.read()
|
||||
@@ -235,10 +235,10 @@ impl WebSocketHandler {
|
||||
|
||||
async fn remove_sysinfo_subscriber(&self, uuid: Uuid) {
|
||||
self.sysinfo_subscribers.remove(&uuid);
|
||||
if self.sysinfo_subscribers.is_empty() {
|
||||
if let Some(handle) = self.sysinfo_handles.write().take() {
|
||||
handle.abort();
|
||||
}
|
||||
if self.sysinfo_subscribers.is_empty()
|
||||
&& let Some(handle) = self.sysinfo_handles.write().take()
|
||||
{
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,39 +263,37 @@ impl PageAnalyzer {
|
||||
});
|
||||
}
|
||||
}
|
||||
if !filter_option.no_hires {
|
||||
if let Some(flac) = self.info.pointer_mut("/dash/flac/audio") {
|
||||
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
|
||||
bail!("invalid flac stream");
|
||||
};
|
||||
let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?;
|
||||
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
if !filter_option.no_hires
|
||||
&& let Some(flac) = self.info.pointer_mut("/dash/flac/audio")
|
||||
{
|
||||
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
|
||||
bail!("invalid flac stream");
|
||||
};
|
||||
let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?;
|
||||
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
backup_url: serde_json::from_value(flac["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !filter_option.no_dolby_audio {
|
||||
if let Some(dolby_audio) = self
|
||||
if !filter_option.no_dolby_audio
|
||||
&& let Some(dolby_audio) = self
|
||||
.info
|
||||
.pointer_mut("/dash/dolby/audio/0")
|
||||
.and_then(|a| a.as_object_mut())
|
||||
{
|
||||
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
|
||||
bail!("invalid dolby audio stream");
|
||||
};
|
||||
let quality =
|
||||
AudioQuality::from_repr(quality as usize).context("invalid dolby audio stream quality")?;
|
||||
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
{
|
||||
let (Some(url), Some(quality)) = (dolby_audio["baseUrl"].as_str(), dolby_audio["id"].as_u64()) else {
|
||||
bail!("invalid dolby audio stream");
|
||||
};
|
||||
let quality = AudioQuality::from_repr(quality as usize).context("invalid dolby audio stream quality")?;
|
||||
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {
|
||||
streams.push(Stream::DashAudio {
|
||||
url: url.to_string(),
|
||||
backup_url: serde_json::from_value(dolby_audio["backupUrl"].take()).unwrap_or_default(),
|
||||
quality,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(streams)
|
||||
|
||||
@@ -4,6 +4,7 @@ use anyhow::Result;
|
||||
use leaky_bucket::RateLimiter;
|
||||
use reqwest::{Method, header};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use ua_generator::ua;
|
||||
|
||||
use crate::bilibili::Credential;
|
||||
use crate::bilibili::credential::WbiImg;
|
||||
@@ -19,9 +20,7 @@ impl Client {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
header::USER_AGENT,
|
||||
header::HeaderValue::from_static(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
),
|
||||
header::HeaderValue::from_static(ua::spoof_chrome_ua()),
|
||||
);
|
||||
headers.insert(
|
||||
header::REFERER,
|
||||
|
||||
@@ -184,7 +184,7 @@ impl<'a, W: AsyncWrite> AssWriter<'a, W> {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_text(text: &str) -> Cow<str> {
|
||||
fn escape_text(text: &'_ str) -> Cow<'_, str> {
|
||||
let text = text.trim();
|
||||
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
|
||||
Cow::from(text.replace('\n', "\\N"))
|
||||
|
||||
@@ -26,7 +26,7 @@ pub struct DanmakuOption {
|
||||
pub bottom_percentage: f64,
|
||||
/// 透明度(0-255)
|
||||
pub opacity: u8,
|
||||
/// 是否加粗,1代表是,0代表否
|
||||
/// 是否加粗,1 代表是,0 代表否
|
||||
pub bold: bool,
|
||||
/// 描边
|
||||
pub outline: f64,
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct Danmu {
|
||||
impl Danmu {
|
||||
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
|
||||
///
|
||||
/// 汉字算一个全宽,英文算2/3宽
|
||||
/// 汉字算一个全宽,英文算 2/3 宽
|
||||
pub fn length(&self, config: &CanvasConfig<'_>) -> f64 {
|
||||
let pts = config.danmaku_option.font_size
|
||||
* self
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct SubTitleItem {
|
||||
impl SubTitleInfo {
|
||||
pub fn is_ai_sub(&self) -> bool {
|
||||
// ai: aisubtitle.hdslb.com/bfs/ai_subtitle/xxxx
|
||||
// 非 ai: aisubtitle.hdslb.com/bfs/subtitle/xxxx
|
||||
// 非 ai:aisubtitle.hdslb.com/bfs/subtitle/xxxx
|
||||
self.subtitle_url.contains("ai_subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use futures::TryStreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use prost::Message;
|
||||
@@ -16,19 +16,6 @@ pub struct Video<'a> {
|
||||
pub bvid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Tag {
|
||||
pub tag_name: String,
|
||||
}
|
||||
|
||||
impl serde::Serialize for Tag {
|
||||
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.tag_name)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct PageInfo {
|
||||
pub cid: i64,
|
||||
@@ -84,8 +71,8 @@ impl<'a> Video<'a> {
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_tags(&self) -> Result<Vec<Tag>> {
|
||||
let mut res = self
|
||||
pub async fn get_tags(&self) -> Result<Vec<String>> {
|
||||
let res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||
.await
|
||||
@@ -96,10 +83,15 @@ impl<'a> Video<'a> {
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
Ok(res["data"]
|
||||
.as_array()
|
||||
.context("tags is not an array")?
|
||||
.iter()
|
||||
.filter_map(|v| v["tag_name"].as_str().map(String::from))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter<'a>> {
|
||||
let tasks = FuturesUnordered::new();
|
||||
for i in 1..=page.duration.div_ceil(360) {
|
||||
tasks.push(self.get_danmaku_segment(page, i as i64));
|
||||
@@ -171,14 +163,18 @@ impl<'a> Video<'a> {
|
||||
.await?
|
||||
.validate()?;
|
||||
// 接口返回的信息,包含了一系列的字幕,每个字幕包含了字幕的语言和 json 下载地址
|
||||
let subtitles_info: SubTitlesInfo = serde_json::from_value(res["data"]["subtitle"].take())?;
|
||||
let tasks = subtitles_info
|
||||
.subtitles
|
||||
.into_iter()
|
||||
.filter(|v| !v.is_ai_sub())
|
||||
.map(|v| self.get_subtitle(v))
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect().await
|
||||
match serde_json::from_value::<Option<SubTitlesInfo>>(res["data"]["subtitle"].take())? {
|
||||
Some(subtitles_info) => {
|
||||
let tasks = subtitles_info
|
||||
.subtitles
|
||||
.into_iter()
|
||||
.filter(|v| !v.is_ai_sub())
|
||||
.map(|v| self.get_subtitle(v))
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect().await
|
||||
}
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_subtitle(&self, info: SubTitleInfo) -> Result<SubTitle> {
|
||||
|
||||
@@ -81,7 +81,7 @@ mod tests {
|
||||
"test_truncate",
|
||||
&json!({"title": "你说得对,但是 Rust 是由 Mozilla 自主研发的一款全新的编译期格斗游戏。\
|
||||
编译将发生在一个被称作「Cargo」的构建系统中。在这里,被引用的指针将被授予「生命周期」之力,导引对象安全。\
|
||||
你将扮演一位名为「Rustacean」的神秘角色, 在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
|
||||
你将扮演一位名为「Rustacean」的神秘角色,在与「Rustc」的搏斗中邂逅各种骨骼惊奇的傲娇报错。\
|
||||
征服她们、通过编译同时,逐步发掘「C++」程序崩溃的真相。"})
|
||||
)
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bili_sync_migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
use sea_orm::sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
||||
use sea_orm::sqlx::{ConnectOptions as SqlxConnectOptions, Sqlite};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection, SqlxSqliteConnector};
|
||||
|
||||
use crate::config::CONFIG_DIR;
|
||||
|
||||
@@ -11,10 +15,24 @@ fn database_url() -> String {
|
||||
async fn database_connection() -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url());
|
||||
option
|
||||
.max_connections(100)
|
||||
.max_connections(50)
|
||||
.min_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(90));
|
||||
Ok(Database::connect(option).await?)
|
||||
.acquire_timeout(Duration::from_secs(90));
|
||||
let connect_option = option
|
||||
.get_url()
|
||||
.parse::<SqliteConnectOptions>()
|
||||
.context("Failed to parse database URL")?
|
||||
.disable_statement_logging()
|
||||
.busy_timeout(Duration::from_secs(90))
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.optimize_on_close(true, None);
|
||||
Ok(SqlxSqliteConnector::from_sqlx_sqlite_pool(
|
||||
option
|
||||
.sqlx_pool_options::<Sqlite>()
|
||||
.connect_with(connect_option)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn migrate_database() -> Result<()> {
|
||||
@@ -26,9 +44,9 @@ async fn migrate_database() -> Result<()> {
|
||||
|
||||
/// 进行数据库迁移并获取数据库连接,供外部使用
|
||||
pub async fn setup_database() -> Result<DatabaseConnection> {
|
||||
tokio::fs::create_dir_all(CONFIG_DIR.as_path())
|
||||
.await
|
||||
.context("Failed to create config directory")?;
|
||||
tokio::fs::create_dir_all(CONFIG_DIR.as_path()).await.context(
|
||||
"Failed to create config directory. Please check if you have granted necessary permissions to your folder.",
|
||||
)?;
|
||||
migrate_database().await.context("Failed to migrate database")?;
|
||||
database_connection().await.context("Failed to connect to database")
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
}
|
||||
res.with_context(|| format!("failed to download from {:?}", urls))
|
||||
res.context("failed to download file")
|
||||
}
|
||||
|
||||
pub async fn merge(&self, video_path: &Path, audio_path: &Path, output_path: &Path) -> Result<()> {
|
||||
@@ -167,7 +167,8 @@ impl Downloader {
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
.await
|
||||
.context("failed to run ffmpeg")?;
|
||||
if !output.status.success() {
|
||||
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
|
||||
}
|
||||
|
||||
@@ -42,10 +42,10 @@ impl From<Result<ExecutionStatus>> for ExecutionStatus {
|
||||
}
|
||||
}
|
||||
// 未包裹的 reqwest::Error
|
||||
if let Some(error) = cause.downcast_ref::<reqwest::Error>() {
|
||||
if is_ignored_reqwest_error(error) {
|
||||
return ExecutionStatus::Ignored(err);
|
||||
}
|
||||
if let Some(error) = cause.downcast_ref::<reqwest::Error>()
|
||||
&& is_ignored_reqwest_error(error)
|
||||
{
|
||||
return ExecutionStatus::Ignored(err);
|
||||
}
|
||||
}
|
||||
ExecutionStatus::Failed(err)
|
||||
|
||||
@@ -47,14 +47,14 @@ async fn main() {
|
||||
if !cfg!(debug_assertions) {
|
||||
spawn_task(
|
||||
"定时下载",
|
||||
video_downloader(connection, bili_client),
|
||||
video_downloader(connection.clone(), bili_client),
|
||||
&tracker,
|
||||
token.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
tracker.close();
|
||||
handle_shutdown(tracker, token).await
|
||||
handle_shutdown(connection, tracker, token).await
|
||||
}
|
||||
|
||||
fn spawn_task(
|
||||
@@ -77,7 +77,7 @@ fn spawn_task(
|
||||
}
|
||||
|
||||
/// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置
|
||||
async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
|
||||
async fn init() -> (DatabaseConnection, LogHelper) {
|
||||
let (tx, _rx) = tokio::sync::broadcast::channel(30);
|
||||
let log_history = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
|
||||
let log_writer = LogHelper::new(tx, log_history.clone());
|
||||
@@ -85,7 +85,7 @@ async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
|
||||
init_logger(&ARGS.log_level, Some(log_writer.clone()));
|
||||
info!("欢迎使用 Bili-Sync,当前程序版本:{}", config::version());
|
||||
info!("项目地址:https://github.com/amtoaer/bili-sync");
|
||||
let connection = Arc::new(setup_database().await.expect("数据库初始化失败"));
|
||||
let connection = setup_database().await.expect("数据库初始化失败");
|
||||
info!("数据库初始化完成");
|
||||
VersionedConfig::init(&connection).await.expect("配置初始化失败");
|
||||
info!("配置初始化完成");
|
||||
@@ -93,16 +93,21 @@ async fn init() -> (Arc<DatabaseConnection>, LogHelper) {
|
||||
(connection, log_writer)
|
||||
}
|
||||
|
||||
async fn handle_shutdown(tracker: TaskTracker, token: CancellationToken) {
|
||||
async fn handle_shutdown(connection: DatabaseConnection, tracker: TaskTracker, token: CancellationToken) {
|
||||
tokio::select! {
|
||||
_ = tracker.wait() => {
|
||||
error!("所有任务均已终止,程序退出")
|
||||
error!("所有任务均已终止..")
|
||||
}
|
||||
_ = terminate() => {
|
||||
info!("接收到终止信号,正在终止任务..");
|
||||
info!("接收到终止信号,开始终止任务..");
|
||||
token.cancel();
|
||||
tracker.wait().await;
|
||||
info!("所有任务均已终止,程序退出");
|
||||
info!("所有任务均已终止..");
|
||||
}
|
||||
}
|
||||
info!("正在关闭数据库连接..");
|
||||
match connection.close().await {
|
||||
Ok(()) => info!("数据库连接已关闭,程序结束"),
|
||||
Err(e) => error!("关闭数据库连接时遇到错误:{:#},程序异常结束", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ use crate::config::VersionedConfig;
|
||||
struct Asset;
|
||||
|
||||
pub async fn http_server(
|
||||
database_connection: Arc<DatabaseConnection>,
|
||||
database_connection: DatabaseConnection,
|
||||
bili_client: Arc<BiliClient>,
|
||||
log_writer: LogHelper,
|
||||
) -> Result<()> {
|
||||
let app = router()
|
||||
.fallback_service(get(frontend_files))
|
||||
.fallback_service(get(frontend_files).head(frontend_files))
|
||||
.layer(Extension(database_connection))
|
||||
.layer(Extension(bili_client))
|
||||
.layer(Extension(log_writer));
|
||||
@@ -34,7 +34,7 @@ pub async fn http_server(
|
||||
let listener = tokio::net::TcpListener::bind(&config.bind_address)
|
||||
.await
|
||||
.context("bind address failed")?;
|
||||
info!("开始运行管理页: http://{}", config.bind_address);
|
||||
info!("开始运行管理页:http://{}", config.bind_address);
|
||||
Ok(axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await?)
|
||||
}
|
||||
|
||||
@@ -48,34 +48,51 @@ async fn frontend_files(request: Request) -> impl IntoResponse {
|
||||
};
|
||||
let mime_type = content.mime_type();
|
||||
let content_type = mime_type.as_deref().unwrap_or("application/octet-stream");
|
||||
if cfg!(debug_assertions) {
|
||||
(
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
// safety: `RustEmbed` returns uncompressed files directly from the filesystem in debug mode
|
||||
content.data().unwrap(),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
let accepted_encodings = request
|
||||
.headers()
|
||||
.get(header::ACCEPT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').map(str::trim).collect::<HashSet<_>>())
|
||||
.unwrap_or_default();
|
||||
for (encoding, data) in [("br", content.data_br()), ("gzip", content.data_gzip())] {
|
||||
if accepted_encodings.contains(encoding) {
|
||||
if let Some(data) = data {
|
||||
return (
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CONTENT_ENCODING, encoding),
|
||||
],
|
||||
data,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
"Unsupported Encoding".into_response()
|
||||
let default_headers = [
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
(header::ETAG, &content.hash()),
|
||||
];
|
||||
if let Some(if_none_match) = request.headers().get(header::IF_NONE_MATCH)
|
||||
&& let Ok(client_etag) = if_none_match.to_str()
|
||||
&& client_etag == content.hash()
|
||||
{
|
||||
return (StatusCode::NOT_MODIFIED, default_headers).into_response();
|
||||
}
|
||||
|
||||
if request.method() == axum::http::Method::HEAD {
|
||||
return (StatusCode::OK, default_headers).into_response();
|
||||
}
|
||||
if cfg!(debug_assertions) {
|
||||
// safety: `RustEmbed` returns uncompressed files directly from the filesystem in debug mode
|
||||
return (StatusCode::OK, default_headers, content.data().unwrap()).into_response();
|
||||
}
|
||||
let accepted_encodings = request
|
||||
.headers()
|
||||
.get(header::ACCEPT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').map(str::trim).collect::<HashSet<_>>())
|
||||
.unwrap_or_default();
|
||||
for (encoding, data) in [("br", content.data_br()), ("gzip", content.data_gzip())] {
|
||||
if accepted_encodings.contains(encoding)
|
||||
&& let Some(data) = data
|
||||
{
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
(header::ETAG, &content.hash()),
|
||||
(header::CONTENT_ENCODING, encoding),
|
||||
],
|
||||
data,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"Client must support gzip or brotli compression",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
|
||||
use crate::workflow::process_video_source;
|
||||
|
||||
/// 启动周期下载视频的任务
|
||||
pub async fn video_downloader(connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) {
|
||||
pub async fn video_downloader(connection: DatabaseConnection, bili_client: Arc<BiliClient>) {
|
||||
let mut anchor = chrono::Local::now().date_naive();
|
||||
loop {
|
||||
info!("开始执行本轮视频下载任务..");
|
||||
|
||||
@@ -124,7 +124,7 @@ impl VideoInfo {
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: if base_model.favtime != NaiveDateTime::default() {
|
||||
NotSet // 之前设置了 favtime,不覆盖
|
||||
Set(base_model.favtime) // 之前设置了 favtime,使用之前的值(等价于 unset,但设置上以支持后续的规则匹配)
|
||||
} else {
|
||||
Set(pubtime.naive_utc()) // 未设置过 favtime,使用 pubtime 填充
|
||||
},
|
||||
@@ -152,10 +152,7 @@ impl VideoInfo {
|
||||
}
|
||||
|
||||
impl PageInfo {
|
||||
pub fn into_active_model(
|
||||
self,
|
||||
video_model: &bili_sync_entity::video::Model,
|
||||
) -> bili_sync_entity::page::ActiveModel {
|
||||
pub fn into_active_model(self, video_model_id: i32) -> bili_sync_entity::page::ActiveModel {
|
||||
let (width, height) = match &self.dimension {
|
||||
Some(d) => {
|
||||
if d.rotate == 0 {
|
||||
@@ -167,7 +164,7 @@ impl PageInfo {
|
||||
None => (None, None),
|
||||
};
|
||||
bili_sync_entity::page::ActiveModel {
|
||||
video_id: Set(video_model.id),
|
||||
video_id: Set(video_model_id),
|
||||
cid: Set(self.cid),
|
||||
pid: Set(self.page),
|
||||
name: Set(self.name),
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod filenamify;
|
||||
pub mod format_arg;
|
||||
pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod rule;
|
||||
pub mod signal;
|
||||
pub mod status;
|
||||
pub mod task_notifier;
|
||||
|
||||
@@ -6,7 +6,7 @@ use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::{DatabaseTransaction, TransactionTrait};
|
||||
|
||||
use crate::adapter::{VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{PageInfo, VideoInfo};
|
||||
use crate::bilibili::VideoInfo;
|
||||
use crate::config::{Config, LegacyConfig};
|
||||
use crate::utils::status::STATUS_COMPLETED;
|
||||
|
||||
@@ -41,6 +41,7 @@ pub async fn filter_unhandled_video_pages(
|
||||
.and(video::Column::DownloadStatus.lt(STATUS_COMPLETED))
|
||||
.and(video::Column::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.and(video::Column::ShouldDownload.eq(true))
|
||||
.and(additional_expr),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
@@ -73,16 +74,8 @@ pub async fn create_videos(
|
||||
}
|
||||
|
||||
/// 尝试创建 Page Model,如果发生冲突则忽略
|
||||
pub async fn create_pages(
|
||||
pages_info: Vec<PageInfo>,
|
||||
video_model: &bili_sync_entity::video::Model,
|
||||
connection: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
let page_models = pages_info
|
||||
.into_iter()
|
||||
.map(|p| p.into_active_model(video_model))
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
for page_chunk in page_models.chunks(50) {
|
||||
pub async fn create_pages(pages_model: Vec<page::ActiveModel>, connection: &DatabaseTransaction) -> Result<()> {
|
||||
for page_chunk in pages_model.chunks(200) {
|
||||
page::Entity::insert_many(page_chunk.to_vec())
|
||||
.on_conflict(
|
||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||
|
||||
@@ -261,7 +261,7 @@ mod tests {
|
||||
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
||||
),
|
||||
bvid: "BV1nWcSeeEkV".to_string(),
|
||||
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
||||
tags: Some(vec!["tag1".to_owned(), "tag2".to_owned()].into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -343,10 +343,7 @@ impl<'a> From<&'a video::Model> for Movie<'a> {
|
||||
NFOTimeType::FavTime => video.favtime,
|
||||
NFOTimeType::PubTime => video.pubtime,
|
||||
},
|
||||
tags: video
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
|
||||
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,10 +360,7 @@ impl<'a> From<&'a video::Model> for TVShow<'a> {
|
||||
NFOTimeType::FavTime => video.favtime,
|
||||
NFOTimeType::PubTime => video.pubtime,
|
||||
},
|
||||
tags: video
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
|
||||
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
267
crates/bili_sync/src/utils/rule.rs
Normal file
267
crates/bili_sync/src/utils/rule.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use bili_sync_entity::rule::{AndGroup, Condition, Rule, RuleTarget};
|
||||
use bili_sync_entity::{page, video};
|
||||
use chrono::{Local, NaiveDateTime};
|
||||
|
||||
pub(crate) trait Evaluatable<T> {
|
||||
fn evaluate(&self, value: T) -> bool;
|
||||
}
|
||||
|
||||
pub(crate) trait FieldEvaluatable {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool;
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool;
|
||||
}
|
||||
|
||||
impl Evaluatable<&str> for Condition<String> {
|
||||
fn evaluate(&self, value: &str) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => expected == value,
|
||||
Condition::Contains(substring) => value.contains(substring),
|
||||
Condition::Prefix(prefix) => value.starts_with(prefix),
|
||||
Condition::Suffix(suffix) => value.ends_with(suffix),
|
||||
Condition::MatchesRegex(_, regex) => regex.is_match(value),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluatable<usize> for Condition<usize> {
|
||||
fn evaluate(&self, value: usize) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => *expected == value,
|
||||
Condition::GreaterThan(threshold) => value > *threshold,
|
||||
Condition::LessThan(threshold) => value < *threshold,
|
||||
Condition::Between(start, end) => value > *start && value < *end,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> {
|
||||
fn evaluate(&self, value: &NaiveDateTime) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => expected == value,
|
||||
Condition::GreaterThan(threshold) => value > threshold,
|
||||
Condition::LessThan(threshold) => value < threshold,
|
||||
Condition::Between(start, end) => value > start && value < end,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldEvaluatable for RuleTarget {
|
||||
/// 修改模型后进行评估,此时能访问的是未保存的 activeModel,就地使用 activeModel 评估
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
match self {
|
||||
RuleTarget::Title(cond) => video.name.try_as_ref().is_some_and(|title| cond.evaluate(title)),
|
||||
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
|
||||
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
|
||||
RuleTarget::Tags(cond) => video
|
||||
.tags
|
||||
.try_as_ref()
|
||||
.and_then(|t| t.as_ref())
|
||||
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
|
||||
RuleTarget::FavTime(cond) => video
|
||||
.favtime
|
||||
.try_as_ref()
|
||||
.map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较
|
||||
.is_some_and(|fav_time| cond.evaluate(&fav_time)),
|
||||
RuleTarget::PubTime(cond) => video
|
||||
.pubtime
|
||||
.try_as_ref()
|
||||
.map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local())
|
||||
.is_some_and(|pub_time| cond.evaluate(&pub_time)),
|
||||
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
|
||||
RuleTarget::Not(inner) => !inner.evaluate(video, pages),
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动触发对历史视频的评估,拿到的是原始 Model,直接使用
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
match self {
|
||||
RuleTarget::Title(cond) => cond.evaluate(&video.name),
|
||||
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
|
||||
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
|
||||
RuleTarget::Tags(cond) => video
|
||||
.tags
|
||||
.as_ref()
|
||||
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
|
||||
RuleTarget::FavTime(cond) => cond.evaluate(&video.favtime.and_utc().with_timezone(&Local).naive_local()),
|
||||
RuleTarget::PubTime(cond) => cond.evaluate(&video.pubtime.and_utc().with_timezone(&Local).naive_local()),
|
||||
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
|
||||
RuleTarget::Not(inner) => !inner.evaluate_model(video, pages),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldEvaluatable for AndGroup {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
self.iter().all(|target| target.evaluate(video, pages))
|
||||
}
|
||||
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
self.iter().all(|target| target.evaluate_model(video, pages))
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldEvaluatable for Rule {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
if self.0.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.0.iter().any(|group| group.evaluate(video, pages))
|
||||
}
|
||||
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
if self.0.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.0.iter().any(|group| group.evaluate_model(video, pages))
|
||||
}
|
||||
}
|
||||
|
||||
/// 对于 Option<Rule> 如果 rule 不存在应该被认为是通过评估
|
||||
impl FieldEvaluatable for Option<Rule> {
|
||||
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||
self.as_ref().is_none_or(|rule| rule.evaluate(video, pages))
|
||||
}
|
||||
|
||||
fn evaluate_model(&self, video: &video::Model, pages: &[page::Model]) -> bool {
|
||||
self.as_ref().is_none_or(|rule| rule.evaluate_model(video, pages))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bili_sync_entity::page;
|
||||
use chrono::NaiveDate;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let test_cases = vec![
|
||||
(
|
||||
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
|
||||
"「(标题包含“唐氏”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![vec![
|
||||
RuleTarget::Title(Condition::Prefix("街霸".to_string())),
|
||||
RuleTarget::Tags(Condition::Contains("套路".to_string())),
|
||||
]]),
|
||||
"「(标题以“街霸”开头)且(标签包含“套路”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![
|
||||
vec![
|
||||
RuleTarget::Title(Condition::Contains("Rust".to_string())),
|
||||
RuleTarget::PageCount(Condition::GreaterThan(5)),
|
||||
],
|
||||
vec![
|
||||
RuleTarget::Tags(Condition::Suffix("入门".to_string())),
|
||||
RuleTarget::PubTime(Condition::GreaterThan(
|
||||
NaiveDate::from_ymd_opt(2023, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
)),
|
||||
],
|
||||
]),
|
||||
"「(标题包含“Rust”)且(视频分页数量大于“5”)」或「(标签以“入门”结尾)且(发布时间大于“2023-01-01 00:00:00”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![vec![
|
||||
RuleTarget::Not(Box::new(RuleTarget::Title(Condition::Contains("广告".to_string())))),
|
||||
RuleTarget::PageCount(Condition::LessThan(10)),
|
||||
]]),
|
||||
"「(标题不包含“广告”)且(视频分页数量小于“10”)」",
|
||||
),
|
||||
(
|
||||
Rule(vec![vec![
|
||||
RuleTarget::FavTime(Condition::Between(
|
||||
NaiveDate::from_ymd_opt(2023, 6, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
NaiveDate::from_ymd_opt(2023, 12, 31)
|
||||
.unwrap()
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.unwrap(),
|
||||
)),
|
||||
// autocorrect-disable
|
||||
RuleTarget::Tags(Condition::MatchesRegex(
|
||||
"技术|教程".to_string(),
|
||||
regex::Regex::new("技术|教程").unwrap(),
|
||||
)),
|
||||
]]),
|
||||
"「(收藏时间在“2023-06-01 00:00:00”和“2023-12-31 23:59:59”之间)且(标签匹配“技术|教程”)」",
|
||||
// autocorrect-enable
|
||||
),
|
||||
];
|
||||
|
||||
for (rule, expected) in test_cases {
|
||||
assert_eq!(rule.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate() {
|
||||
let test_cases = vec![
|
||||
(
|
||||
(
|
||||
video::ActiveModel {
|
||||
name: Set("骂谁唐氏呢!!!".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
|
||||
true,
|
||||
),
|
||||
(
|
||||
(
|
||||
video::ActiveModel::default(),
|
||||
vec![page::ActiveModel::default(); 2],
|
||||
),
|
||||
Rule(vec![vec![RuleTarget::PageCount(Condition::Equals(1))]]),
|
||||
false,
|
||||
),
|
||||
(
|
||||
(
|
||||
video::ActiveModel{
|
||||
tags: Set(Some(vec!["原神".to_owned(),"永雏塔菲".to_owned(),"虚拟主播".to_owned()].into())),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
Rule (vec![vec![RuleTarget::Not(Box::new(RuleTarget::Tags(Condition::Equals(
|
||||
"原神".to_string(),
|
||||
))))]],
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
(
|
||||
video::ActiveModel {
|
||||
name: Set(
|
||||
"万字怒扒网易《归唐》底裤!中国首款大厂买断制单机,靠谱吗?——全网最全!官方非独家幕后!关于《归唐》PV 的所有秘密~都在这里了~".to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
Rule(vec![vec![RuleTarget::Not(Box::new(RuleTarget::Title(Condition::MatchesRegex(
|
||||
r"^\S+字(解析|怒扒|拆解)".to_owned(),
|
||||
regex::Regex::new(r"^\S+字(解析|怒扒)").unwrap(),
|
||||
))))]],
|
||||
),
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
for ((video, pages), rule, expected) in test_cases {
|
||||
assert_eq!(rule.evaluate(&video, &pages), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ pub type VideoStatus = Status<5>;
|
||||
pub type PageStatus = Status<5>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
mod tests {
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::config::VersionedConfig;
|
||||
|
||||
pub static TASK_STATUS_NOTIFIER: LazyLock<TaskStatusNotifier> = LazyLock::new(TaskStatusNotifier::new);
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct TaskStatus {
|
||||
is_running: bool,
|
||||
last_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
@@ -21,17 +21,6 @@ pub struct TaskStatusNotifier {
|
||||
rx: tokio::sync::watch::Receiver<Arc<TaskStatus>>,
|
||||
}
|
||||
|
||||
impl Default for TaskStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_running: false,
|
||||
last_run: None,
|
||||
last_finish: None,
|
||||
next_run: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskStatusNotifier {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = tokio::sync::watch::channel(Arc::new(TaskStatus::default()));
|
||||
@@ -42,7 +31,7 @@ impl TaskStatusNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_running(&self) -> MutexGuard<()> {
|
||||
pub async fn start_running(&'_ self) -> MutexGuard<'_, ()> {
|
||||
let lock = self.mutex.lock().await;
|
||||
let _ = self.tx.send(Arc::new(TaskStatus {
|
||||
is_running: true,
|
||||
@@ -55,7 +44,7 @@ impl TaskStatusNotifier {
|
||||
|
||||
pub fn finish_running(&self, _lock: MutexGuard<()>) {
|
||||
let last_status = self.tx.borrow();
|
||||
let last_run = last_status.last_run.clone();
|
||||
let last_run = last_status.last_run;
|
||||
drop(last_status);
|
||||
let config = VersionedConfig::get().load();
|
||||
let now = chrono::Local::now();
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::utils::model::{
|
||||
update_videos_model,
|
||||
};
|
||||
use crate::utils::nfo::NFO;
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
|
||||
|
||||
/// 完整地处理某个视频来源
|
||||
@@ -102,43 +103,49 @@ pub async fn fetch_video_details(
|
||||
) -> Result<()> {
|
||||
video_source.log_fetch_video_start();
|
||||
let videos_model = filter_unfilled_videos(video_source.filter_expr(), connection).await?;
|
||||
let semaphore = Semaphore::new(VersionedConfig::get().load().concurrent_limit.video);
|
||||
let semaphore_ref = &semaphore;
|
||||
let tasks = videos_model
|
||||
.into_iter()
|
||||
.map(|video_model| {
|
||||
async move {
|
||||
let video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
.map(|video_model| async move {
|
||||
let _permit = semaphore_ref.acquire().await.context("acquire semaphore failed")?;
|
||||
let video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Err(e) => {
|
||||
error!(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
}
|
||||
Ok((tags, mut view_info)) => {
|
||||
let VideoInfo::Detail { pages, .. } = &mut view_info else {
|
||||
unreachable!()
|
||||
};
|
||||
let pages = std::mem::take(pages);
|
||||
let pages_len = pages.len();
|
||||
let txn = connection.begin().await?;
|
||||
// 将分页信息写入数据库
|
||||
create_pages(pages, &video_model, &txn).await?;
|
||||
let mut video_active_model = view_info.into_detail_model(video_model);
|
||||
video_source.set_relation_id(&mut video_active_model);
|
||||
video_active_model.single_page = Set(Some(pages_len == 1));
|
||||
video_active_model.tags = Set(Some(serde_json::to_value(tags)?));
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
};
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
}
|
||||
Ok((tags, mut view_info)) => {
|
||||
let VideoInfo::Detail { pages, .. } = &mut view_info else {
|
||||
unreachable!()
|
||||
};
|
||||
// 构造 page model
|
||||
let pages = std::mem::take(pages);
|
||||
let pages = pages
|
||||
.into_iter()
|
||||
.map(|p| p.into_active_model(video_model.id))
|
||||
.collect::<Vec<page::ActiveModel>>();
|
||||
// 更新 video model 的各项有关属性
|
||||
let mut video_active_model = view_info.into_detail_model(video_model);
|
||||
video_source.set_relation_id(&mut video_active_model);
|
||||
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||
video_active_model.tags = Set(Some(tags.into()));
|
||||
video_active_model.should_download = Set(video_source.rule().evaluate(&video_active_model, &pages));
|
||||
let txn = connection.begin().await?;
|
||||
create_pages(pages, &txn).await?;
|
||||
video_active_model.save(&txn).await?;
|
||||
txn.commit().await?;
|
||||
}
|
||||
};
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect::<Vec<_>>().await?;
|
||||
@@ -281,18 +288,18 @@ pub async fn download_video_pages(
|
||||
ExecutionStatus::Succeeded => info!("处理视频「{}」{}成功", &video_model.name, task_name),
|
||||
ExecutionStatus::Ignored(e) => {
|
||||
error!(
|
||||
"处理视频「{}」{}出现常见错误,已忽略: {:#}",
|
||||
"处理视频「{}」{}出现常见错误,已忽略:{:#}",
|
||||
&video_model.name, task_name, e
|
||||
)
|
||||
}
|
||||
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => {
|
||||
error!("处理视频「{}」{}失败: {:#}", &video_model.name, task_name, e)
|
||||
error!("处理视频「{}」{}失败:{:#}", &video_model.name, task_name, e)
|
||||
}
|
||||
});
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(4).context("page download result not found")? {
|
||||
if e.downcast_ref::<DownloadAbortError>().is_some() {
|
||||
return Err(e);
|
||||
}
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(4).context("page download result not found")?
|
||||
&& e.downcast_ref::<DownloadAbortError>().is_some()
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.download_status = Set(status.into());
|
||||
@@ -471,20 +478,20 @@ pub async fn download_page(
|
||||
),
|
||||
ExecutionStatus::Ignored(e) => {
|
||||
error!(
|
||||
"处理视频「{}」第 {} 页{}出现常见错误,已忽略: {:#}",
|
||||
"处理视频「{}」第 {} 页{}出现常见错误,已忽略:{:#}",
|
||||
&video_model.name, page_model.pid, task_name, e
|
||||
)
|
||||
}
|
||||
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => error!(
|
||||
"处理视频「{}」第 {} 页{}失败: {:#}",
|
||||
"处理视频「{}」第 {} 页{}失败:{:#}",
|
||||
&video_model.name, page_model.pid, task_name, e
|
||||
),
|
||||
});
|
||||
// 如果下载视频时触发风控,直接返回 DownloadAbortError
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(1).context("video download result not found")? {
|
||||
if let Ok(BiliError::RiskControlOccurred) = e.downcast::<BiliError>() {
|
||||
bail!(DownloadAbortError());
|
||||
}
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(1).context("video download result not found")?
|
||||
&& let Ok(BiliError::RiskControlOccurred) = e.downcast::<BiliError>()
|
||||
{
|
||||
bail!(DownloadAbortError());
|
||||
}
|
||||
let mut page_active_model: page::ActiveModel = page_model.into();
|
||||
page_active_model.download_status = Set(status.into());
|
||||
|
||||
@@ -5,5 +5,8 @@ edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
derivative = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
2
crates/bili_sync_entity/src/custom_type/mod.rs
Normal file
2
crates/bili_sync_entity/src/custom_type/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod rule;
|
||||
pub mod string_vec;
|
||||
120
crates/bili_sync_entity/src/custom_type/rule.rs
Normal file
120
crates/bili_sync_entity/src/custom_type/rule.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use derivative::Derivative;
|
||||
use sea_orm::FromJsonQueryResult;
|
||||
use sea_orm::prelude::DateTime;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Derivative)]
|
||||
#[derivative(PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", tag = "operator", content = "value")]
|
||||
pub enum Condition<T: Serialize + Display> {
|
||||
Equals(T),
|
||||
Contains(T),
|
||||
#[serde(deserialize_with = "deserialize_regex", serialize_with = "serialize_regex")]
|
||||
MatchesRegex(String, #[derivative(PartialEq = "ignore")] regex::Regex),
|
||||
Prefix(T),
|
||||
Suffix(T),
|
||||
GreaterThan(T),
|
||||
LessThan(T),
|
||||
Between(T, T),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "rule")]
|
||||
pub enum RuleTarget {
|
||||
Title(Condition<String>),
|
||||
Tags(Condition<String>),
|
||||
FavTime(Condition<DateTime>),
|
||||
PubTime(Condition<DateTime>),
|
||||
PageCount(Condition<usize>),
|
||||
Not(Box<RuleTarget>),
|
||||
}
|
||||
|
||||
pub type AndGroup = Vec<RuleTarget>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
pub struct Rule(pub Vec<AndGroup>);
|
||||
|
||||
impl<T: Serialize + Display> Display for Condition<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Condition::Equals(v) => write!(f, "等于“{}”", v),
|
||||
Condition::Contains(v) => write!(f, "包含“{}”", v),
|
||||
Condition::MatchesRegex(pat, _) => write!(f, "匹配“{}”", pat),
|
||||
Condition::Prefix(v) => write!(f, "以“{}”开头", v),
|
||||
Condition::Suffix(v) => write!(f, "以“{}”结尾", v),
|
||||
Condition::GreaterThan(v) => write!(f, "大于“{}”", v),
|
||||
Condition::LessThan(v) => write!(f, "小于“{}”", v),
|
||||
Condition::Between(start, end) => write!(f, "在“{}”和“{}”之间", start, end),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RuleTarget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
fn get_field_name(rt: &RuleTarget, depth: usize) -> &'static str {
|
||||
match rt {
|
||||
RuleTarget::Title(_) => "标题",
|
||||
RuleTarget::Tags(_) => "标签",
|
||||
RuleTarget::FavTime(_) => "收藏时间",
|
||||
RuleTarget::PubTime(_) => "发布时间",
|
||||
RuleTarget::PageCount(_) => "视频分页数量",
|
||||
RuleTarget::Not(inner) => {
|
||||
if depth == 0 {
|
||||
get_field_name(inner, depth + 1)
|
||||
} else {
|
||||
"格式化失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let field_name = get_field_name(self, 0);
|
||||
match self {
|
||||
RuleTarget::Not(inner) => match inner.as_ref() {
|
||||
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}不{}", field_name, cond),
|
||||
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||
write!(f, "{}不{}", field_name, cond)
|
||||
}
|
||||
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond),
|
||||
RuleTarget::Not(_) => write!(f, "格式化失败"),
|
||||
},
|
||||
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond),
|
||||
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||
write!(f, "{}{}", field_name, cond)
|
||||
}
|
||||
RuleTarget::PageCount(cond) => write!(f, "{}{}", field_name, cond),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Rule {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let groups: Vec<String> = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|group| {
|
||||
let conditions: Vec<String> = group.iter().map(|target| format!("({})", target)).collect();
|
||||
format!("「{}」", conditions.join("且"))
|
||||
})
|
||||
.collect();
|
||||
write!(f, "{}", groups.join("或"))
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_regex<'de, D>(deserializer: D) -> Result<(String, regex::Regex), D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let pattern = String::deserialize(deserializer)?;
|
||||
// 反序列化时预编译 regex,优化性能
|
||||
let regex = regex::Regex::new(&pattern).map_err(serde::de::Error::custom)?;
|
||||
Ok((pattern, regex))
|
||||
}
|
||||
|
||||
fn serialize_regex<S>(pattern: &str, _regex: ®ex::Regex, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(pattern)
|
||||
}
|
||||
20
crates/bili_sync_entity/src/custom_type/string_vec.rs
Normal file
20
crates/bili_sync_entity/src/custom_type/string_vec.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use sea_orm::FromJsonQueryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// reference: https://www.sea-ql.org/SeaORM/docs/generate-entity/column-types/#json-column
|
||||
// 在 entity 中使用裸 Vec 仅在 postgres 中支持,sea-orm 会将其映射为 postgres array
|
||||
// 如果需要实现跨数据库的 array,必须将其包裹在 wrapper type 中
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
pub struct StringVec(pub Vec<String>);
|
||||
|
||||
impl From<Vec<String>> for StringVec {
|
||||
fn from(value: Vec<String>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StringVec> for Vec<String> {
|
||||
fn from(value: StringVec) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "collection")]
|
||||
pub struct Model {
|
||||
@@ -14,6 +16,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "favorite")]
|
||||
pub struct Model {
|
||||
@@ -13,6 +15,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "submission")]
|
||||
pub struct Model {
|
||||
@@ -12,6 +14,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::string_vec::StringVec;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
|
||||
#[sea_orm(table_name = "video")]
|
||||
pub struct Model {
|
||||
@@ -25,7 +27,8 @@ pub struct Model {
|
||||
pub favtime: DateTime,
|
||||
pub download_status: u32,
|
||||
pub valid: bool,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub should_download: bool,
|
||||
pub tags: Option<StringVec>,
|
||||
pub single_page: Option<bool>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "watch_later")]
|
||||
pub struct Model {
|
||||
@@ -10,6 +12,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
mod custom_type;
|
||||
mod entities;
|
||||
|
||||
pub use custom_type::*;
|
||||
pub use entities::*;
|
||||
|
||||
@@ -8,6 +8,7 @@ mod m20250122_062926_add_latest_row_at;
|
||||
mod m20250612_090826_add_enabled;
|
||||
mod m20250613_043257_add_config;
|
||||
mod m20250712_080013_add_video_created_at_index;
|
||||
mod m20250903_094454_add_rule_and_should_download;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -23,6 +24,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250612_090826_add_enabled::Migration),
|
||||
Box::new(m20250613_043257_add_config::Migration),
|
||||
Box::new(m20250712_080013_add_video_created_at_index::Migration),
|
||||
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
use sea_orm_migration::schema::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.add_column(boolean(Video::ShouldDownload).default(true))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(WatchLater::Table)
|
||||
.add_column(text_null(WatchLater::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.add_column(text_null(Submission::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Favorite::Table)
|
||||
.add_column(text_null(Favorite::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Collection::Table)
|
||||
.add_column(text_null(Collection::Rule))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Video::Table)
|
||||
.drop_column(Video::ShouldDownload)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(WatchLater::Table)
|
||||
.drop_column(WatchLater::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.drop_column(Submission::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Favorite::Table)
|
||||
.drop_column(Favorite::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Collection::Table)
|
||||
.drop_column(Collection::Rule)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
ShouldDownload,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum WatchLater {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Submission {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Favorite {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Collection {
|
||||
Table,
|
||||
Rule,
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.6.2",
|
||||
text: "v2.7.0",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
EMBY 的一般结构是: `媒体库 - 文件夹 - 电影/电视剧 - 分季/分集`,方便起见,我采用了如下的对应关系:
|
||||
|
||||
1. **文件夹**:对应 b 站的 video source;
|
||||
2. **电视剧**: 对应 b 站的 video;
|
||||
2. **电视剧**:对应 b 站的 video;
|
||||
3. **第一季的所有分集**:对应 b 站的 page。
|
||||
|
||||
特别的,当 video 仅有一个 page 时,为了避免过多的层级,bili-sync 会将 page 展开到第二层级,变成与电视剧同级的电影。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.6.2,文档将始终与最新程序版本保持一致。
|
||||
> 当前最新程序版本为 v2.7.0,文档将始终与最新程序版本保持一致。
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
在[程序发布页](https://github.com/amtoaer/bili-sync/releases)选择最新版本中对应机器架构的压缩包,解压后会获取一个名为 `bili-sync-rs` 的可执行文件,直接双击执行。
|
||||
|
||||
### 其二: 使用 Docker Compose 运行
|
||||
### 其二:使用 Docker Compose 运行
|
||||
|
||||
Linux/amd64 与 Linux/arm64 两个平台可直接使用 Docker 或 Docker Compose 运行,此处以 Compose 为例:
|
||||
> 请注意其中的注释,有不清楚的地方可以先继续往下看。
|
||||
@@ -88,9 +88,9 @@ Jul 12 16:11:10 INFO 开始运行管理页: http://0.0.0.0:12345
|
||||
|
||||
认证后会看到一系列的配置,除绑定地址外的选项**基本都会实时生效**。为避免意料外的情况,建议将配置文件一次修改完毕后再点击保存。
|
||||
|
||||
如无特殊需求,一般仅需修改“B站认证”与“视频质量”两个标签下的配置。
|
||||
如无特殊需求,一般仅需修改“B 站认证”与“视频质量”两个标签下的配置。
|
||||
|
||||
其中“B站认证”在一次填写后即可忽略,程序会在**每日第一次运行视频下载任务**时检查认证状态,并在有必要时自动刷新。
|
||||
其中“B 站认证”在一次填写后即可忽略,程序会在**每日第一次运行视频下载任务**时检查认证状态,并在有必要时自动刷新。
|
||||
|
||||
对于这些设置项的含义,请参考[配置说明](./configuration.md),可善用右侧导航在不同配置项间跳转。
|
||||
|
||||
@@ -98,7 +98,7 @@ Jul 12 16:11:10 INFO 开始运行管理页: http://0.0.0.0:12345
|
||||
|
||||
配置完毕后,我们便可以随时添加视频源订阅。
|
||||
|
||||
用户在正确填写“B站认证”后可以在“快捷订阅”部分查看自己创建的收藏夹、关注的合集与 UP 主一键订阅,也可以在“视频源”页手动添加并管理。
|
||||
用户在正确填写“B 站认证”后可以在“快捷订阅”部分查看自己创建的收藏夹、关注的合集与 UP 主一键订阅,也可以在“视频源”页手动添加并管理。
|
||||
|
||||
对于手动添加的视频源,可参考如下页面获取所需的参数:
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from pathlib import Path
|
||||
|
||||
def main():
|
||||
if len(sys.argv) <= 1:
|
||||
print("用法: python 2.0.3_add_fanart.py <path1> <path2> ...")
|
||||
print("用法:python 2.0.3_add_fanart.py <path1> <path2> ...")
|
||||
exit(1)
|
||||
paths = [Path(path) for path in sys.argv[1:]]
|
||||
for path in paths:
|
||||
|
||||
14
web/bun.lock
14
web/bun.lock
@@ -7,7 +7,7 @@
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.525.0",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
@@ -16,7 +16,7 @@
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"bits-ui": "^2.8.6",
|
||||
"bits-ui": "^2.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
"@layerstack/utils": ["@layerstack/utils@2.0.0-next.12", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-fhGZUlSr3N+D44BYm37WKMGSEFyZBW+dwIqtGU8Cl54mR4TLQ/UwyGhdpgIHyH/x/8q1abE0fP0Dn6ZsrDE3BA=="],
|
||||
|
||||
"@lucide/svelte": ["@lucide/svelte@0.525.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-dyUxkXzepagLUzL8jHQNdeH286nC66ClLACsg+Neu/bjkRJWPWMzkT+H0DKlE70QdkicGCfs1ZGmXCc351hmZA=="],
|
||||
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
|
||||
"bits-ui": ["bits-ui@2.11.0", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.31.1", "svelte-toolbelt": "^0.10.4", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-j/lOFHz6ZDWwj9sOUb6zYSJQdvPc7kr1IRyAdPjln4wOw9UVvKCosbRFEyP4JEzvNFX7HksMG4naDrDHta5bSA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
||||
"runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="],
|
||||
|
||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
|
||||
@@ -649,7 +649,7 @@
|
||||
|
||||
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
|
||||
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.5", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w=="],
|
||||
|
||||
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||
|
||||
@@ -745,6 +745,8 @@
|
||||
|
||||
"svelte-sonner/runed": ["runed@0.26.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw=="],
|
||||
|
||||
"svelte-toolbelt/runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
||||
|
||||
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
|
||||
|
||||
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "bili-sync-web",
|
||||
"version": "2.6.2",
|
||||
"version": "2.7.0",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.525.0",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"bits-ui": "^2.8.6",
|
||||
"bits-ui": "^2.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth !important;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -21,7 +21,8 @@ import type {
|
||||
DashBoardResponse,
|
||||
SysInfo,
|
||||
TaskStatus,
|
||||
ResetRequest
|
||||
ResetRequest,
|
||||
UpdateVideoSourceResponse
|
||||
} from './types';
|
||||
import { wsManager } from './ws';
|
||||
|
||||
@@ -59,7 +60,7 @@ class ApiClient {
|
||||
clearAuthToken() {
|
||||
delete this.defaultHeaders['Authorization'];
|
||||
localStorage.removeItem('authToken');
|
||||
// 断开WebSocket连接,因为token已经无效
|
||||
// 断开 WebSocket 连接,因为 token 已经无效
|
||||
wsManager.disconnect();
|
||||
}
|
||||
|
||||
@@ -212,8 +213,12 @@ class ApiClient {
|
||||
type: string,
|
||||
id: number,
|
||||
request: UpdateVideoSourceRequest
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
||||
): Promise<ApiResponse<UpdateVideoSourceResponse>> {
|
||||
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
|
||||
}
|
||||
|
||||
async evaluateVideoSourceRules(type: string, id: number): Promise<ApiResponse<boolean>> {
|
||||
return this.post<boolean>(`/video-sources/${type}/${id}/evaluate`, null);
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ApiResponse<Config>> {
|
||||
@@ -261,6 +266,8 @@ const api = {
|
||||
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
||||
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
||||
apiClient.updateVideoSource(type, id, request),
|
||||
evaluateVideoSourceRules: (type: string, id: number) =>
|
||||
apiClient.evaluateVideoSourceRules(type, id),
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||
getDashboard: () => apiClient.getDashboard(),
|
||||
|
||||
479
web/src/lib/components/rule-editor.svelte
Normal file
479
web/src/lib/components/rule-editor.svelte
Normal file
@@ -0,0 +1,479 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import type { Rule, RuleTarget, Condition } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
rule: Rule | null;
|
||||
onRuleChange: (rule: Rule | null) => void;
|
||||
}
|
||||
|
||||
let { rule, onRuleChange }: Props = $props();
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
{ value: 'title', label: '标题' },
|
||||
{ value: 'tags', label: '标签' },
|
||||
{ value: 'favTime', label: '收藏时间' },
|
||||
{ value: 'pubTime', label: '发布时间' },
|
||||
{ value: 'pageCount', label: '视频分页数量' }
|
||||
];
|
||||
|
||||
const getOperatorOptions = (field: string) => {
|
||||
switch (field) {
|
||||
case 'title':
|
||||
case 'tags':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'prefix', label: '以...开头' },
|
||||
{ value: 'suffix', label: '以...结尾' },
|
||||
{ value: 'matchesRegex', label: '匹配正则' }
|
||||
];
|
||||
case 'pageCount':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'greaterThan', label: '大于' },
|
||||
{ value: 'lessThan', label: '小于' },
|
||||
{ value: 'between', label: '范围' }
|
||||
];
|
||||
case 'favTime':
|
||||
case 'pubTime':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'greaterThan', label: '晚于' },
|
||||
{ value: 'lessThan', label: '早于' },
|
||||
{ value: 'between', label: '时间范围' }
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
interface LocalCondition {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
value2?: string;
|
||||
isNot: boolean;
|
||||
}
|
||||
|
||||
interface LocalAndGroup {
|
||||
conditions: LocalCondition[];
|
||||
}
|
||||
|
||||
let localRule: LocalAndGroup[] = $state([]);
|
||||
|
||||
onMount(() => {
|
||||
if (rule && rule.length > 0) {
|
||||
localRule = rule.map((andGroup) => ({
|
||||
conditions: andGroup.map((target) => convertRuleTargetToLocal(target))
|
||||
}));
|
||||
} else {
|
||||
localRule = [];
|
||||
}
|
||||
});
|
||||
|
||||
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition {
|
||||
if (typeof target.rule === 'object' && 'field' in target.rule) {
|
||||
// 嵌套的 not
|
||||
const innerCondition = convertRuleTargetToLocal(target.rule);
|
||||
return {
|
||||
...innerCondition,
|
||||
isNot: true
|
||||
};
|
||||
}
|
||||
const condition = target.rule as Condition<string | number | Date>;
|
||||
let value = '';
|
||||
let value2 = '';
|
||||
if (Array.isArray(condition.value)) {
|
||||
value = String(condition.value[0] || '');
|
||||
value2 = String(condition.value[1] || '');
|
||||
} else {
|
||||
value = String(condition.value || '');
|
||||
}
|
||||
return {
|
||||
field: target.field,
|
||||
operator: condition.operator,
|
||||
value,
|
||||
value2,
|
||||
isNot: false
|
||||
};
|
||||
}
|
||||
|
||||
function convertLocalToRule(): Rule | null {
|
||||
if (localRule.length === 0) return null;
|
||||
return localRule.map((andGroup) =>
|
||||
andGroup.conditions.map((condition) => {
|
||||
let value: string | number | Date | (string | number | Date)[];
|
||||
if (condition.field === 'pageCount') {
|
||||
if (condition.operator === 'between') {
|
||||
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
|
||||
} else {
|
||||
value = parseInt(condition.value) || 0;
|
||||
}
|
||||
} else if (condition.field === 'favTime' || condition.field === 'pubTime') {
|
||||
if (condition.operator === 'between') {
|
||||
value = [condition.value, condition.value2 || ''];
|
||||
} else {
|
||||
value = condition.value;
|
||||
}
|
||||
} else {
|
||||
if (condition.operator === 'between') {
|
||||
value = [condition.value, condition.value2 || ''];
|
||||
} else {
|
||||
value = condition.value;
|
||||
}
|
||||
}
|
||||
const conditionObj: Condition<string | number | Date> = {
|
||||
operator: condition.operator,
|
||||
value
|
||||
};
|
||||
|
||||
let target: RuleTarget<string | number | Date> = {
|
||||
field: condition.field,
|
||||
rule: conditionObj
|
||||
};
|
||||
if (condition.isNot) {
|
||||
target = {
|
||||
field: 'not',
|
||||
rule: target
|
||||
};
|
||||
}
|
||||
return target;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function addAndGroup() {
|
||||
localRule.push({ conditions: [] });
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function removeAndGroup(index: number) {
|
||||
localRule.splice(index, 1);
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function addCondition(groupIndex: number) {
|
||||
localRule[groupIndex].conditions.push({
|
||||
field: 'title',
|
||||
operator: 'contains',
|
||||
value: '',
|
||||
isNot: false
|
||||
});
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function removeCondition(groupIndex: number, conditionIndex: number) {
|
||||
localRule[groupIndex].conditions.splice(conditionIndex, 1);
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function updateCondition(
|
||||
groupIndex: number,
|
||||
conditionIndex: number,
|
||||
field: string,
|
||||
value: string
|
||||
) {
|
||||
const condition = localRule[groupIndex].conditions[conditionIndex];
|
||||
if (field === 'field') {
|
||||
condition.field = value;
|
||||
const operators = getOperatorOptions(value);
|
||||
condition.operator = operators[0]?.value || 'equals';
|
||||
condition.value = '';
|
||||
condition.value2 = '';
|
||||
} else if (field === 'operator') {
|
||||
condition.operator = value;
|
||||
// 如果切换到/从 between 操作符,重置值
|
||||
if (value === 'between') {
|
||||
condition.value2 = condition.value2 || '';
|
||||
}
|
||||
} else if (field === 'value') {
|
||||
condition.value = value;
|
||||
} else if (field === 'value2') {
|
||||
condition.value2 = value;
|
||||
} else if (field === 'isNot') {
|
||||
condition.isNot = value === 'true';
|
||||
}
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function clearRules() {
|
||||
localRule = [];
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm font-medium">过滤规则</Label>
|
||||
<div class="flex gap-2">
|
||||
{#if localRule.length > 0}
|
||||
<Button size="sm" variant="outline" onclick={clearRules}>清空规则</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={addAndGroup}>
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
添加规则组
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if localRule.length === 0}
|
||||
<div class="border-muted-foreground/25 rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<p class="text-muted-foreground mb-4 text-sm">暂无过滤规则,将下载所有视频</p>
|
||||
<Button size="sm" onclick={addAndGroup}>
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
添加第一个规则组
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each localRule as andGroup, groupIndex (groupIndex)}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="secondary">规则组 {groupIndex + 1}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => removeAndGroup(groupIndex)}
|
||||
class="h-7 w-7 p-0"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
{#each andGroup.conditions as condition, conditionIndex (conditionIndex)}
|
||||
<div class="space-y-3 rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="secondary">条件 {conditionIndex + 1}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => removeCondition(groupIndex, conditionIndex)}
|
||||
class="h-7 w-7 p-0"
|
||||
>
|
||||
<MinusIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 取反选项 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`not-${groupIndex}-${conditionIndex}`}
|
||||
checked={condition.isNot}
|
||||
onCheckedChange={(checked) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'isNot',
|
||||
checked ? 'true' : 'false'
|
||||
)}
|
||||
/>
|
||||
<Label for={`not-${groupIndex}-${conditionIndex}`} class="text-sm">
|
||||
取反(NOT)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- 字段和操作符 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 字段选择 -->
|
||||
<div>
|
||||
<Label class="text-muted-foreground text-xs">字段</Label>
|
||||
<select
|
||||
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={condition.field}
|
||||
onchange={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
|
||||
>
|
||||
{#each FIELD_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 操作符选择 -->
|
||||
<div>
|
||||
<Label class="text-muted-foreground text-xs">操作符</Label>
|
||||
<select
|
||||
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={condition.operator}
|
||||
onchange={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'operator',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
>
|
||||
{#each getOperatorOptions(condition.field) as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 值输入 -->
|
||||
<div>
|
||||
<Label class="text-muted-foreground text-xs">值</Label>
|
||||
{#if condition.operator === 'between'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#if condition.field === 'pageCount'}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最小值"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最大值"
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value2',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="开始时间"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value + ':00' // 前端选择器只能精确到分钟,此处附加额外的 :00 以满足后端传参条件
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="结束时间"
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value2',
|
||||
e.currentTarget.value + ':00'
|
||||
)}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="起始值"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="结束值"
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value2',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if condition.field === 'pageCount'}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="输入数值"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
|
||||
/>
|
||||
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="选择时间"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value + ':00'
|
||||
)}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入文本"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => addCondition(groupIndex)}
|
||||
class="w-full"
|
||||
>
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
添加条件
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if localRule.length > 0}
|
||||
<div class="text-muted-foreground bg-muted/50 rounded p-3 text-xs">
|
||||
<p class="mb-1 font-medium">规则说明:</p>
|
||||
<ul class="space-y-1">
|
||||
<li>• 多个规则组之间是"或"的关系,同一规则组内的条件是"且"的关系</li>
|
||||
<li>
|
||||
• 规则内配置的时间不包含时区,在处理时会默认应用<strong>服务器时区</strong
|
||||
>,不受浏览器影响
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
export let onsubmit: (request: UpdateVideoStatusRequest) => void;
|
||||
|
||||
// 视频任务名称(与后端 VideoStatus 对应)
|
||||
const videoTaskNames = ['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分页下载'];
|
||||
const videoTaskNames = ['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载'];
|
||||
|
||||
// 分页任务名称(与后端 PageStatus 对应)
|
||||
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
case 'collection':
|
||||
return '合集';
|
||||
case 'upper':
|
||||
return 'UP主';
|
||||
return 'UP 主';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
function getAvatarUrl(): string {
|
||||
switch (type) {
|
||||
case 'upper':
|
||||
return `/image-proxy?url=${(item as UpperWithSubscriptionStatus).face}`;
|
||||
return (item as UpperWithSubscriptionStatus).face;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
let customPath = '';
|
||||
let loading = false;
|
||||
|
||||
// 根据类型和item生成默认路径
|
||||
// 根据类型和 item 生成默认路径
|
||||
function generateDefaultPath(): string {
|
||||
if (!item) return '';
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
case 'upper': {
|
||||
const upper = item as UpperWithSubscriptionStatus;
|
||||
return `UP主/${upper.uname}`;
|
||||
return `UP 主/${upper.uname}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
@@ -63,7 +63,7 @@
|
||||
case 'collection':
|
||||
return '合集';
|
||||
case 'upper':
|
||||
return 'UP主';
|
||||
return 'UP 主';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -145,7 +145,7 @@
|
||||
open = false;
|
||||
}
|
||||
|
||||
// 当对话框打开时重置path
|
||||
// 当对话框打开时重置 path
|
||||
$: if (open && item) {
|
||||
customPath = generateDefaultPath();
|
||||
}
|
||||
|
||||
17
web/src/lib/components/ui/popover/index.ts
Normal file
17
web/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import Content from './popover-content.svelte';
|
||||
import Trigger from './popover-trigger.svelte';
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose
|
||||
};
|
||||
29
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
29
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn('', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -49,20 +49,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getOverallStatus(downloadStatus: number[]): {
|
||||
function getOverallStatus(
|
||||
downloadStatus: number[],
|
||||
shouldDownload: boolean
|
||||
): {
|
||||
text: string;
|
||||
color: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
style: string;
|
||||
} {
|
||||
if (!shouldDownload) {
|
||||
// 被过滤规则排除,显示为“跳过”
|
||||
return { text: '跳过', style: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
const completed = downloadStatus.filter((status) => status === 7).length;
|
||||
const total = downloadStatus.length;
|
||||
const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length;
|
||||
|
||||
if (completed === total) {
|
||||
return { text: '完成', color: 'outline' };
|
||||
// 全部完成,显示为“完成”
|
||||
return { text: '完成', style: 'bg-emerald-700 text-emerald-100' };
|
||||
} else if (failed > 0) {
|
||||
return { text: '失败', color: 'destructive' };
|
||||
// 出现了失败,显示为“失败”
|
||||
return { text: '失败', style: 'bg-rose-700 text-rose-100' };
|
||||
} else {
|
||||
return { text: '进行中', color: 'secondary' };
|
||||
// 还未开始,显示为“等待”
|
||||
return { text: '等待', style: 'bg-yellow-700 text-yellow-100' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +84,7 @@
|
||||
return defaultTaskNames[index] || `任务${index + 1}`;
|
||||
}
|
||||
|
||||
$: overallStatus = getOverallStatus(video.download_status);
|
||||
$: overallStatus = getOverallStatus(video.download_status, video.should_download);
|
||||
$: completed = video.download_status.filter((status) => status === 7).length;
|
||||
$: total = video.download_status.length;
|
||||
|
||||
@@ -112,7 +122,10 @@
|
||||
>
|
||||
{displayTitle}
|
||||
</CardTitle>
|
||||
<Badge variant={overallStatus.color} class="shrink-0 px-2 py-1 text-xs font-medium">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="shrink-0 px-2 py-1 text-xs font-medium {overallStatus.style} "
|
||||
>
|
||||
{overallStatus.text}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface VideoInfo {
|
||||
bvid: string;
|
||||
name: string;
|
||||
upper_name: string;
|
||||
should_download: boolean;
|
||||
download_status: [number, number, number, number, number];
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ export interface CollectionsResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// UP主相关类型
|
||||
// UP 主相关类型
|
||||
export interface UpperWithSubscriptionStatus {
|
||||
mid: number;
|
||||
uname: string;
|
||||
@@ -168,11 +169,27 @@ export interface InsertSubmissionRequest {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Rule 相关类型
|
||||
export interface Condition<T> {
|
||||
operator: string;
|
||||
value: T | T[];
|
||||
}
|
||||
|
||||
export interface RuleTarget<T> {
|
||||
field: string;
|
||||
rule: Condition<T> | RuleTarget<T>;
|
||||
}
|
||||
|
||||
export type AndGroup = RuleTarget<string | number | Date>[];
|
||||
export type Rule = AndGroup[];
|
||||
|
||||
// 视频源详细信息类型
|
||||
export interface VideoSourceDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
rule?: Rule | null;
|
||||
ruleDisplay?: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -188,6 +205,7 @@ export interface VideoSourcesDetailsResponse {
|
||||
export interface UpdateVideoSourceRequest {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
rule?: Rule | null;
|
||||
}
|
||||
|
||||
// 配置相关类型
|
||||
@@ -295,3 +313,7 @@ export interface TaskStatus {
|
||||
last_finish: Date | null;
|
||||
next_run: Date | null;
|
||||
}
|
||||
|
||||
export interface UpdateVideoSourceResponse {
|
||||
ruleDisplay?: string;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class WebSocketManager {
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error, event.data);
|
||||
toast.error('解析 WebSocket 消息失败', {
|
||||
description: `消息内容: ${event.data}\n错误信息: ${error instanceof Error ? error.message : String(error)}`
|
||||
description: `消息内容:${event.data}\n错误信息:${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export class WebSocketManager {
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toast.error('发送 WebSocket 消息失败', {
|
||||
description: `消息内容: ${JSON.stringify(message)}\n错误信息: ${error instanceof Error ? error.message : String(error)}`
|
||||
description: `消息内容:${JSON.stringify(message)}\n错误信息:${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
const response = await api.getDashboard();
|
||||
dashboardData = response.data;
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error);
|
||||
console.error('加载仪表盘数据失败:', error);
|
||||
toast.error('加载仪表盘数据失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
async function loadCollections(page: number = 0) {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await api.getFollowedCollections(page + 1, pageSize); // API使用1基索引
|
||||
const response = await api.getFollowedCollections(page + 1, pageSize); // API 使用 1 基索引
|
||||
collections = response.data.collections;
|
||||
totalCount = response.data.total;
|
||||
} catch (error) {
|
||||
console.error('加载合集失败:', error);
|
||||
console.error('加载合集失败:', error);
|
||||
toast.error('加载合集失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="space-y-2 text-center">
|
||||
<p class="text-muted-foreground">暂无合集数据</p>
|
||||
<p class="text-muted-foreground text-sm">请先在B站关注一些合集,或检查账号配置</p>
|
||||
<p class="text-muted-foreground text-sm">请先在 B 站关注一些合集,或检查账号配置</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const response = await api.getCreatedFavorites();
|
||||
favorites = response.data.favorites;
|
||||
} catch (error) {
|
||||
console.error('加载收藏夹失败:', error);
|
||||
console.error('加载收藏夹失败:', error);
|
||||
toast.error('加载收藏夹失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
@@ -73,7 +73,7 @@
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="space-y-2 text-center">
|
||||
<p class="text-muted-foreground">暂无收藏夹数据</p>
|
||||
<p class="text-muted-foreground text-sm">请先在B站创建收藏夹,或检查账号配置</p>
|
||||
<p class="text-muted-foreground text-sm">请先在 B 站创建收藏夹,或检查账号配置</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
async function loadUppers(page: number = 0) {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await api.getFollowedUppers(page + 1, pageSize); // API使用1基索引
|
||||
const response = await api.getFollowedUppers(page + 1, pageSize); // API 使用 1 基索引
|
||||
uppers = response.data.uppers;
|
||||
totalCount = response.data.total;
|
||||
} catch (error) {
|
||||
console.error('加载UP主失败:', error);
|
||||
toast.error('加载UP主失败', {
|
||||
console.error('加载 UP 主失败:', error);
|
||||
toast.error('加载 UP 主失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
@@ -92,8 +92,8 @@
|
||||
{:else}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="space-y-2 text-center">
|
||||
<p class="text-muted-foreground">暂无UP主数据</p>
|
||||
<p class="text-muted-foreground text-sm">请先在B站关注一些UP主,或检查账号配置</p>
|
||||
<p class="text-muted-foreground">暂无 UP 主数据</p>
|
||||
<p class="text-muted-foreground text-sm">请先在 B 站关注一些 UP 主,或检查账号配置</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -8,17 +8,19 @@
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import EditIcon from '@lucide/svelte/icons/edit';
|
||||
import SaveIcon from '@lucide/svelte/icons/save';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
|
||||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse, Rule } from '$lib/types';
|
||||
import api from '$lib/api';
|
||||
import RuleEditor from '$lib/components/rule-editor.svelte';
|
||||
import ListRestartIcon from '@lucide/svelte/icons/list-restart';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
|
||||
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
||||
let loading = false;
|
||||
@@ -29,19 +31,31 @@
|
||||
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||||
let adding = false;
|
||||
|
||||
// 编辑对话框状态
|
||||
let showEditDialog = false;
|
||||
let editingSource: VideoSourceDetail | null = null;
|
||||
let editingType = '';
|
||||
let editingIdx: number = 0;
|
||||
let saving = false;
|
||||
|
||||
// 规则评估对话框状态
|
||||
let showEvaluateDialog = false;
|
||||
let evaluateSource: VideoSourceDetail | null = null;
|
||||
let evaluateType = '';
|
||||
let evaluating = false;
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = {
|
||||
path: '',
|
||||
enabled: false,
|
||||
rule: null as Rule | null
|
||||
};
|
||||
|
||||
// 表单数据
|
||||
let favoriteForm = { fid: '', path: '' };
|
||||
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
|
||||
let submissionForm = { upper_id: '', path: '' };
|
||||
|
||||
type ExtendedVideoSource = VideoSourceDetail & {
|
||||
type: string;
|
||||
originalIndex: number;
|
||||
editing?: boolean;
|
||||
editingPath?: string;
|
||||
editingEnabled?: boolean;
|
||||
};
|
||||
|
||||
const TAB_CONFIG = {
|
||||
favorites: { label: '收藏夹', icon: HeartIcon },
|
||||
collections: { label: '合集 / 列表', icon: FolderIcon },
|
||||
@@ -64,75 +78,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = true;
|
||||
source.editingPath = source.path;
|
||||
source.editingEnabled = source.enabled;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
// 打开编辑对话框
|
||||
function openEditDialog(type: string, source: VideoSourceDetail, idx: number) {
|
||||
editingSource = source;
|
||||
editingType = type;
|
||||
editingIdx = idx;
|
||||
editForm = {
|
||||
path: source.path,
|
||||
enabled: source.enabled,
|
||||
rule: source.rule || null
|
||||
};
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
function cancelEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
function openEvaluateRules(type: string, source: VideoSourceDetail) {
|
||||
evaluateSource = source;
|
||||
evaluateType = type;
|
||||
showEvaluateDialog = true;
|
||||
}
|
||||
|
||||
async function saveEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
// 保存编辑
|
||||
async function saveEdit() {
|
||||
if (!editingSource) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
if (!source.editingPath?.trim()) {
|
||||
if (!editForm.path?.trim()) {
|
||||
toast.error('路径不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateVideoSource(type, source.id, {
|
||||
path: source.editingPath,
|
||||
enabled: source.editingEnabled ?? false
|
||||
let response = await api.updateVideoSource(editingType, editingSource.id, {
|
||||
path: editForm.path,
|
||||
enabled: editForm.enabled,
|
||||
rule: editForm.rule
|
||||
});
|
||||
|
||||
source.path = source.editingPath;
|
||||
source.enabled = source.editingEnabled ?? false;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
|
||||
// 更新本地数据
|
||||
if (videoSourcesData && editingSource) {
|
||||
const sources = videoSourcesData[
|
||||
editingType as keyof VideoSourcesDetailsResponse
|
||||
] as VideoSourceDetail[];
|
||||
sources[editingIdx] = {
|
||||
...sources[editingIdx],
|
||||
path: editForm.path,
|
||||
enabled: editForm.enabled,
|
||||
rule: editForm.rule,
|
||||
ruleDisplay: response.data.ruleDisplay
|
||||
};
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
}
|
||||
showEditDialog = false;
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourcesForTab(tabValue: string): ExtendedVideoSource[] {
|
||||
async function evaluateRules() {
|
||||
if (!evaluateSource) return;
|
||||
evaluating = true;
|
||||
try {
|
||||
let response = await api.evaluateVideoSourceRules(evaluateType, evaluateSource.id);
|
||||
if (response && response.data) {
|
||||
showEvaluateDialog = false;
|
||||
toast.success('重新评估规则成功');
|
||||
} else {
|
||||
toast.error('重新评估规则失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('重新评估规则失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
evaluating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourcesForTab(tabValue: string): VideoSourceDetail[] {
|
||||
if (!videoSourcesData) return [];
|
||||
const sources = videoSourcesData[
|
||||
tabValue as keyof VideoSourcesDetailsResponse
|
||||
] as VideoSourceDetail[];
|
||||
// 直接返回原始数据的引用,只添加必要的属性
|
||||
return sources.map((source, originalIndex) => {
|
||||
// 使用类型断言来扩展 VideoSourceDetail
|
||||
const extendedSource = source as ExtendedVideoSource;
|
||||
extendedSource.type = tabValue;
|
||||
extendedSource.originalIndex = originalIndex;
|
||||
return extendedSource;
|
||||
});
|
||||
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
|
||||
}
|
||||
|
||||
// 打开添加对话框
|
||||
@@ -238,80 +265,69 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[30%] md:w-[25%]">名称</Table.Head>
|
||||
<Table.Head class="w-[30%] md:w-[40%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[25%] md:w-[20%]">状态</Table.Head>
|
||||
<Table.Head class="w-[15%] text-right sm:w-[12%]">操作</Table.Head>
|
||||
<Table.Head class="w-[20%]">名称</Table.Head>
|
||||
<Table.Head class="w-[40%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[15%]">过滤规则</Table.Head>
|
||||
<Table.Head class="w-[15%]">状态</Table.Head>
|
||||
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sources as source, index (index)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[30%] font-medium md:w-[25%]">{source.name}</Table.Cell>
|
||||
<Table.Cell class="w-[30%] md:w-[40%]">
|
||||
{#if source.editing}
|
||||
<input
|
||||
bind:value={source.editingPath}
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="输入下载路径"
|
||||
/>
|
||||
<Table.Cell class="font-medium">{source.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</code>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if source.rule && source.rule.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{source.rule.length} 条规则</span
|
||||
>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-xs">{source.ruleDisplay}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</code>
|
||||
<span class="text-muted-foreground text-sm">-</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[25%] md:w-[20%]">
|
||||
{#if source.editing}
|
||||
<div class="flex h-8 items-center">
|
||||
<Switch bind:checked={source.editingEnabled} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[15%] text-right sm:w-[12%]">
|
||||
{#if source.editing}
|
||||
<div
|
||||
class="flex flex-col items-end justify-end gap-1 sm:flex-row sm:items-center"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => saveEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="保存"
|
||||
>
|
||||
<SaveIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => cancelEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="取消"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => startEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="编辑"
|
||||
>
|
||||
<EditIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openEditDialog(key, source, index)}
|
||||
class="h-8 w-8 p-0"
|
||||
title="编辑"
|
||||
>
|
||||
<EditIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openEvaluateRules(key, source)}
|
||||
class="h-8 w-8 p-0"
|
||||
title="重新评估规则"
|
||||
>
|
||||
<ListRestartIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -352,11 +368,77 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Root bind:open={showAddDialog}>
|
||||
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
|
||||
<!-- 编辑对话框 -->
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content
|
||||
class="data-[state=open]:animate-content-show bg-background fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-md outline-none"
|
||||
class="no-scrollbar max-h-[85vh] !max-w-[90vw] overflow-y-auto lg:!max-w-[70vw]"
|
||||
>
|
||||
<Dialog.Title class="text-lg font-semibold">
|
||||
编辑视频源: {editingSource?.name || ''}
|
||||
</Dialog.Title>
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- 下载路径 -->
|
||||
<div>
|
||||
<Label for="edit-path" class="text-sm font-medium">下载路径</Label>
|
||||
<Input
|
||||
id="edit-path"
|
||||
type="text"
|
||||
bind:value={editForm.path}
|
||||
placeholder="请输入下载路径,例如:/path/to/download"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 启用状态 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch bind:checked={editForm.enabled} />
|
||||
<Label class="text-sm font-medium">启用此视频源</Label>
|
||||
</div>
|
||||
|
||||
<!-- 规则编辑器 -->
|
||||
<div>
|
||||
<RuleEditor rule={editForm.rule} onRuleChange={(rule) => (editForm.rule = rule)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={() => (showEditDialog = false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showEvaluateDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>重新评估规则</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
确定要重新评估视频源 <strong>"{evaluateSource?.name}"</strong> 的过滤规则吗?<br />
|
||||
规则修改后默认仅对新视频生效,该操作可使用当前规则对数据库中已存在的历史视频进行重新评估,<span
|
||||
class="text-destructive font-medium">无法撤销</span
|
||||
>。<br />
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
disabled={evaluating}
|
||||
onclick={() => {
|
||||
showEvaluateDialog = false;
|
||||
}}>取消</AlertDialog.Cancel
|
||||
>
|
||||
<AlertDialog.Action onclick={evaluateRules} disabled={evaluating}>
|
||||
{evaluating ? '重新评估中' : '确认重新评估'}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- 添加对话框 -->
|
||||
<Dialog.Root bind:open={showAddDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Title class="text-lg font-semibold">
|
||||
{#if addDialogType === 'favorites'}
|
||||
添加收藏夹
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
async function loadVideoDetail() {
|
||||
const videoId = parseInt($page.params.id);
|
||||
if (isNaN(videoId)) {
|
||||
error = '无效的视频ID';
|
||||
toast.error('无效的视频ID');
|
||||
error = '无效的视频 ID';
|
||||
toast.error('无效的视频 ID');
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
@@ -35,7 +35,7 @@
|
||||
const result = await api.getVideo(videoId);
|
||||
videoData = result.data;
|
||||
} catch (error) {
|
||||
console.error('加载视频详情失败:', error);
|
||||
console.error('加载视频详情失败:', error);
|
||||
toast.error('加载视频详情失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
@@ -79,7 +79,7 @@
|
||||
toast.error('状态更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('状态更新失败:', error);
|
||||
console.error('状态更新失败:', error);
|
||||
toast.error('状态更新失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
@@ -156,11 +156,12 @@
|
||||
bvid: videoData.video.bvid,
|
||||
name: videoData.video.name,
|
||||
upper_name: videoData.video.upper_name,
|
||||
download_status: videoData.video.download_status
|
||||
download_status: videoData.video.download_status,
|
||||
should_download: videoData.video.should_download
|
||||
}}
|
||||
mode="detail"
|
||||
showActions={false}
|
||||
taskNames={['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分页下载']}
|
||||
taskNames={['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载']}
|
||||
bind:resetDialogOpen
|
||||
bind:resetting
|
||||
onReset={async (forceReset: boolean) => {
|
||||
@@ -209,7 +210,8 @@
|
||||
id: pageInfo.id,
|
||||
name: `P${pageInfo.pid}: ${pageInfo.name}`,
|
||||
upper_name: '',
|
||||
download_status: pageInfo.download_status
|
||||
download_status: pageInfo.download_status,
|
||||
should_download: videoData.video.should_download
|
||||
}}
|
||||
mode="page"
|
||||
showActions={false}
|
||||
@@ -223,8 +225,8 @@
|
||||
{:else}
|
||||
<div class="py-12 text-center">
|
||||
<div class="space-y-2">
|
||||
<p class="text-muted-foreground">暂无分P数据</p>
|
||||
<p class="text-muted-foreground text-sm">该视频可能为单P视频</p>
|
||||
<p class="text-muted-foreground">暂无分 P 数据</p>
|
||||
<p class="text-muted-foreground text-sm">该视频可能为单 P 视频</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
const result = await api.getVideos(params);
|
||||
videosData = result.data;
|
||||
} catch (error) {
|
||||
console.error('加载视频失败:', error);
|
||||
console.error('加载视频失败:', error);
|
||||
toast.error('加载视频失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
@@ -111,7 +111,7 @@
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
console.error('重置失败:', error);
|
||||
toast.error('重置失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
@@ -133,7 +133,7 @@
|
||||
toast.info('没有需要重置的视频');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
console.error('重置失败:', error);
|
||||
toast.error('重置失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
|
||||
@@ -11,8 +11,7 @@ export default defineConfig({
|
||||
ws: true,
|
||||
rewriteWsOrigin: true
|
||||
},
|
||||
'/api': 'http://localhost:12345',
|
||||
'/image-proxy': 'http://localhost:12345'
|
||||
'/api': 'http://localhost:12345'
|
||||
},
|
||||
host: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user