Compare commits
35 Commits
30f3745ac3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c000bb56a | |||
|
|
91ab64a068 | ||
|
|
55dde84f96 | ||
|
|
eea233e576 | ||
|
|
72bf2b6a4d | ||
|
|
47ce8f148b | ||
|
|
1c68f13c54 | ||
|
|
2a4c1313b0 | ||
|
|
ec44798523 | ||
|
|
8cb59d6b2a | ||
|
|
3a2df55314 | ||
|
|
04448c6d8f | ||
| f44ec797b8 | |||
| 5f8d4450cc | |||
| 315f00d654 | |||
|
|
09604fd283 | ||
|
|
29f36238e3 | ||
|
|
980779d5c5 | ||
|
|
dd96a32b35 | ||
|
|
d39cce043c | ||
|
|
e97fa73542 | ||
|
|
2bd660efc9 | ||
|
|
fe13029e84 | ||
|
|
bdf4ab58f2 | ||
|
|
681617cf02 | ||
|
|
b6c5b547a3 | ||
|
|
8aba906904 | ||
|
|
3e465d9b71 | ||
|
|
1930a57edd | ||
|
|
bb1576a0df | ||
|
|
5350d3491b | ||
|
|
e130f14c13 | ||
|
|
980f74a242 | ||
| fc63c64a97 | |||
| 47909dd6bf |
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[source.crates-io]
|
||||
replace-with = "rsproxy-sparse"
|
||||
|
||||
[source.rsproxy-sparse]
|
||||
registry = "sparse+https://rsproxy.cn/index/"
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
25
.github/workflows/build-binary.yaml
vendored
25
.github/workflows/build-binary.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
working-directory: web
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Build Frontend
|
||||
run: bun run build
|
||||
- name: Upload Web Build Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: web-build
|
||||
path: web/build
|
||||
@@ -40,6 +40,11 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- release_for: Linux-armv7
|
||||
os: ubuntu-24.04
|
||||
target: armv7-unknown-linux-musleabihf
|
||||
bin: bili-sync-rs
|
||||
name: bili-sync-rs-Linux-armv7-musl.tar.gz
|
||||
- release_for: Linux-x86_64
|
||||
os: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
@@ -67,20 +72,20 @@ jobs:
|
||||
name: bili-sync-rs-Windows-x86_64.zip
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Download Web Build Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: web-build
|
||||
path: web/build
|
||||
- name: Read Toolchain Version
|
||||
uses: SebRollen/toml-action@v1.2.0
|
||||
id: read_rust_toolchain
|
||||
with:
|
||||
file: rust-toolchain.toml
|
||||
field: toolchain.channel
|
||||
shell: bash
|
||||
run: |
|
||||
channel=$(grep '^channel' rust-toolchain.toml | sed 's/.*= *"\(.*\)"/\1/')
|
||||
echo "value=$channel" >> $GITHUB_OUTPUT
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v1
|
||||
with:
|
||||
@@ -99,7 +104,7 @@ jobs:
|
||||
tar czvf ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }}
|
||||
fi
|
||||
- name: Upload release artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: bili-sync-rs-${{ matrix.platform.release_for }}
|
||||
path: |
|
||||
|
||||
8
.github/workflows/build-doc.yaml
vendored
8
.github/workflows/build-doc.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- "docs/**"
|
||||
|
||||
jobs:
|
||||
doc:
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
|
||||
@@ -38,4 +38,4 @@ jobs:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: docs/.vitepress/dist
|
||||
force_orphan: true
|
||||
commit_message: 部署来自 main 的最新文档变更:
|
||||
commit_message: 部署来自 main 的最新文档变更:
|
||||
|
||||
6
.github/workflows/pr-check.yaml
vendored
6
.github/workflows/pr-check.yaml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- run: rustup install && rustup component add rustfmt --toolchain nightly
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
working-directory: web
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
|
||||
|
||||
9
.github/workflows/release-build.yaml
vendored
9
.github/workflows/release-build.yaml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Download release artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
merge-multiple: true
|
||||
- name: Publish GitHub release
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Download release artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
merge-multiple: true
|
||||
- name: Docker Meta
|
||||
@@ -65,6 +65,7 @@ jobs:
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -353,7 +353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync"
|
||||
version = "2.10.4"
|
||||
version = "2.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -370,6 +370,7 @@ dependencies = [
|
||||
"croner",
|
||||
"dashmap",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"enum_dispatch",
|
||||
"float-ord",
|
||||
"futures",
|
||||
@@ -412,9 +413,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync_entity"
|
||||
version = "2.10.4"
|
||||
version = "2.11.0"
|
||||
dependencies = [
|
||||
"derivative",
|
||||
"either",
|
||||
"regex",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -423,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bili_sync_migration"
|
||||
version = "2.10.4"
|
||||
version = "2.11.0"
|
||||
dependencies = [
|
||||
"sea-orm-migration",
|
||||
]
|
||||
@@ -1087,6 +1089,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
|
||||
@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.10.4"
|
||||
version = "2.11.0"
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
@@ -30,6 +30,8 @@ croner = "3.0.1"
|
||||
dashmap = "6.1.0"
|
||||
derivative = "2.2.0"
|
||||
dirs = "6.0.0"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
enum_dispatch = "0.3.13"
|
||||
float-ord = "0.3.2"
|
||||
futures = "0.3.31"
|
||||
|
||||
@@ -13,6 +13,8 @@ COPY ./bili-sync-rs-Linux-*.tar.gz ./targets/
|
||||
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
tar xzvf ./targets/bili-sync-rs-Linux-x86_64-musl.tar.gz -C ./; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
tar xzvf ./targets/bili-sync-rs-Linux-armv7-musl.tar.gz -C ./; \
|
||||
else \
|
||||
tar xzvf ./targets/bili-sync-rs-Linux-aarch64-musl.tar.gz -C ./; \
|
||||
fi
|
||||
@@ -34,4 +36,3 @@ COPY --from=base / /
|
||||
ENTRYPOINT [ "/app/bili-sync-rs" ]
|
||||
|
||||
VOLUME [ "/app/.config/bili-sync" ]
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||

|
||||
|
||||
## 简介
|
||||
## 简介
|
||||
|
||||
> [!NOTE]
|
||||
> [点击此处](https://bili-sync.allwens.work/)查看文档
|
||||
> [查看文档](https://bili-sync.amto.cc/) | [加入 Telegram 交流群](https://t.me/+nuYrt8q6uEo4MWI1)
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust & Tokio 驱动。
|
||||
|
||||
## 效果演示
|
||||
|
||||
### 管理页
|
||||

|
||||

|
||||
### 媒体库概览
|
||||

|
||||
### 媒体库详情
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 138 KiB |
@@ -24,6 +24,7 @@ cookie = { workspace = true }
|
||||
croner = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
enum_dispatch = { workspace = true }
|
||||
float-ord = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../../web/build");
|
||||
built::write_built_file().expect("Failed to acquire build-time information");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use bili_sync_entity::video;
|
||||
use bili_sync_migration::SimpleExpr;
|
||||
use itertools::Itertools;
|
||||
use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction};
|
||||
use sea_orm::{ColumnTrait, Condition, ConnectionTrait, DatabaseTransaction};
|
||||
|
||||
use crate::api::request::StatusFilter;
|
||||
use crate::api::request::{StatusFilter, ValidationFilter};
|
||||
use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo};
|
||||
use crate::utils::status::VideoStatus;
|
||||
|
||||
@@ -18,6 +20,20 @@ impl StatusFilter {
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidationFilter {
|
||||
pub fn to_video_query(&self) -> SimpleExpr {
|
||||
match self {
|
||||
ValidationFilter::Invalid => video::Column::Valid.eq(false),
|
||||
ValidationFilter::Skipped => video::Column::Valid
|
||||
.eq(true)
|
||||
.and(video::Column::ShouldDownload.eq(false)),
|
||||
ValidationFilter::Normal => video::Column::Valid
|
||||
.eq(true)
|
||||
.and(video::Column::ShouldDownload.eq(true)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait VideoRecord {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32);
|
||||
}
|
||||
@@ -116,10 +132,7 @@ async fn execute_page_update_batch(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: impl Iterator<Item = (i32, u32)>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
let values = pages
|
||||
.map(|p| format!("({}, {})", p.0, p.1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let values = pages.map(|p| format!("({}, {})", p.0, p.1)).join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ pub enum StatusFilter {
|
||||
Waiting,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ValidationFilter {
|
||||
Skipped,
|
||||
Invalid,
|
||||
Normal,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VideosRequest {
|
||||
pub collection: Option<i32>,
|
||||
@@ -20,6 +28,7 @@ pub struct VideosRequest {
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
pub validation_filter: Option<ValidationFilter>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
@@ -38,6 +47,7 @@ pub struct ResetFilteredVideoStatusRequest {
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
pub validation_filter: Option<ValidationFilter>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
@@ -75,6 +85,7 @@ pub struct UpdateFilteredVideoStatusRequest {
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
pub validation_filter: Option<ValidationFilter>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub video_updates: Vec<StatusUpdate>,
|
||||
@@ -132,3 +143,8 @@ pub struct DefaultPathRequest {
|
||||
pub struct PollQrcodeRequest {
|
||||
pub qrcode_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FullSyncVideoSourceRequest {
|
||||
pub delete_local: bool,
|
||||
}
|
||||
|
||||
@@ -73,9 +73,14 @@ pub struct VideoInfo {
|
||||
pub bvid: String,
|
||||
pub name: String,
|
||||
pub upper_name: String,
|
||||
pub valid: bool,
|
||||
pub should_download: bool,
|
||||
#[serde(serialize_with = "serde_video_download_status")]
|
||||
pub download_status: u32,
|
||||
pub collection_id: Option<i32>,
|
||||
pub favorite_id: Option<i32>,
|
||||
pub submission_id: Option<i32>,
|
||||
pub watch_later_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult)]
|
||||
@@ -224,3 +229,9 @@ pub struct UpdateVideoSourceResponse {
|
||||
pub type GenerateQrcodeResponse = Qrcode;
|
||||
|
||||
pub type PollQrcodeResponse = PollStatus;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FullSyncVideoSourceResponse {
|
||||
pub removed_count: usize,
|
||||
pub warnings: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use anyhow::{Context, Result};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use bili_sync_migration::Expr;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use itertools::Itertools;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTrait, TransactionTrait};
|
||||
@@ -15,11 +19,12 @@ use serde_json::json;
|
||||
use crate::adapter::{_ActiveModel, VideoSource as _, VideoSourceEnum};
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::request::{
|
||||
DefaultPathRequest, InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest,
|
||||
UpdateVideoSourceRequest,
|
||||
DefaultPathRequest, FullSyncVideoSourceRequest, InsertCollectionRequest, InsertFavoriteRequest,
|
||||
InsertSubmissionRequest, UpdateVideoSourceRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||
FullSyncVideoSourceResponse, UpdateVideoSourceResponse, VideoSource, VideoSourceDetail,
|
||||
VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||
@@ -39,6 +44,7 @@ pub(super) fn router() -> Router {
|
||||
put(update_video_source).delete(remove_video_source),
|
||||
)
|
||||
.route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source))
|
||||
.route("/video-sources/{type}/{id}/full-sync", post(full_sync_video_source))
|
||||
.route("/video-sources/favorites", post(insert_favorite))
|
||||
.route("/video-sources/collections", post(insert_collection))
|
||||
.route("/video-sources/submissions", post(insert_submission))
|
||||
@@ -373,11 +379,7 @@ pub async fn evaluate_video_source(
|
||||
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(", ")
|
||||
chunk.iter().map(|item| format!("({}, {})", item.0, item.1)).join(", ")
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
}
|
||||
@@ -385,6 +387,86 @@ pub async fn evaluate_video_source(
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
pub async fn full_sync_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Json(request): Json<FullSyncVideoSourceRequest>,
|
||||
) -> Result<ApiResponse<FullSyncVideoSourceResponse>, ApiError> {
|
||||
let video_source: Option<VideoSourceEnum> = match source_type.as_str() {
|
||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"watch_later" => watch_later::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let Some(video_source) = video_source else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let filter_expr = video_source.filter_expr();
|
||||
let (_, video_streams) = video_source.refresh(&bili_client, credential, &db).await?;
|
||||
let all_videos = video_streams
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.context("failed to read all videos from video stream")?;
|
||||
let all_bvids = all_videos.into_iter().map(|v| v.bvid_owned()).collect::<HashSet<_>>();
|
||||
let videos_to_remove = video::Entity::find()
|
||||
.filter(video::Column::Bvid.is_not_in(all_bvids).and(filter_expr))
|
||||
.select_only()
|
||||
.columns([video::Column::Id, video::Column::Path])
|
||||
.into_tuple::<(i32, String)>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
if videos_to_remove.is_empty() {
|
||||
return Ok(ApiResponse::ok(FullSyncVideoSourceResponse {
|
||||
removed_count: 0,
|
||||
warnings: None,
|
||||
}));
|
||||
}
|
||||
let remove_count = videos_to_remove.len();
|
||||
let (video_ids, video_paths): (Vec<i32>, Vec<String>) = videos_to_remove.into_iter().unzip();
|
||||
let txn = db.begin().await?;
|
||||
page::Entity::delete_many()
|
||||
.filter(page::Column::VideoId.is_in(video_ids.iter().copied()))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
video::Entity::delete_many()
|
||||
.filter(video::Column::Id.is_in(video_ids))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
let warnings = if request.delete_local {
|
||||
let tasks = video_paths
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(async move {
|
||||
tokio::fs::remove_dir_all(&path)
|
||||
.await
|
||||
.with_context(|| format!("failed to remove {path}"))?;
|
||||
Result::<_, anyhow::Error>::Ok(())
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
Some(
|
||||
tasks
|
||||
.filter_map(|res| futures::future::ready(res.err().map(|e| format!("{:#}", e))))
|
||||
.collect::<Vec<_>>()
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ApiResponse::ok(FullSyncVideoSourceResponse {
|
||||
removed_count: remove_count,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 新增收藏夹订阅
|
||||
pub async fn insert_favorite(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
|
||||
@@ -65,6 +65,9 @@ pub async fn get_videos(
|
||||
if let Some(status_filter) = params.status_filter {
|
||||
query = query.filter(status_filter.to_video_query());
|
||||
}
|
||||
if let Some(validation_filter) = params.validation_filter {
|
||||
query = query.filter(validation_filter.to_video_query());
|
||||
}
|
||||
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)
|
||||
@@ -174,6 +177,7 @@ pub async fn clear_and_reset_video_status(
|
||||
let mut video_info = video_info.into_active_model();
|
||||
video_info.single_page = Set(None);
|
||||
video_info.download_status = Set(0);
|
||||
video_info.valid = Set(true);
|
||||
let video_info = video_info.update(&txn).await?;
|
||||
page::Entity::delete_many()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
@@ -181,11 +185,15 @@ pub async fn clear_and_reset_video_status(
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
let video_info = video_info.try_into_model()?;
|
||||
let warning = tokio::fs::remove_dir_all(&video_info.path)
|
||||
.await
|
||||
.context(format!("删除本地路径「{}」失败", video_info.path))
|
||||
.err()
|
||||
.map(|e| format!("{:#}", e));
|
||||
let warning = if video_info.path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
tokio::fs::remove_dir_all(&video_info.path)
|
||||
.await
|
||||
.context(format!("删除本地路径「{}」失败", video_info.path))
|
||||
.err()
|
||||
.map(|e| format!("{:#}", e))
|
||||
};
|
||||
Ok(ApiResponse::ok(ClearAndResetVideoStatusResponse {
|
||||
warning,
|
||||
video: VideoInfo {
|
||||
@@ -193,8 +201,13 @@ pub async fn clear_and_reset_video_status(
|
||||
bvid: video_info.bvid,
|
||||
name: video_info.name,
|
||||
upper_name: video_info.upper_name,
|
||||
valid: video_info.valid,
|
||||
should_download: video_info.should_download,
|
||||
download_status: video_info.download_status,
|
||||
collection_id: video_info.collection_id,
|
||||
favorite_id: video_info.favorite_id,
|
||||
submission_id: video_info.submission_id,
|
||||
watch_later_id: video_info.watch_later_id,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -224,6 +237,9 @@ pub async fn reset_filtered_video_status(
|
||||
if let Some(status_filter) = request.status_filter {
|
||||
query = query.filter(status_filter.to_video_query());
|
||||
}
|
||||
if let Some(validation_filter) = request.validation_filter {
|
||||
query = query.filter(validation_filter.to_video_query());
|
||||
}
|
||||
let all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
|
||||
let all_pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
|
||||
@@ -360,6 +376,9 @@ pub async fn update_filtered_video_status(
|
||||
if let Some(status_filter) = request.status_filter {
|
||||
query = query.filter(status_filter.to_video_query());
|
||||
}
|
||||
if let Some(validation_filter) = request.validation_filter {
|
||||
query = query.filter(validation_filter.to_video_query());
|
||||
}
|
||||
let mut all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
|
||||
let mut all_pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
|
||||
|
||||
@@ -263,10 +263,13 @@ impl PageAnalyzer {
|
||||
}
|
||||
}
|
||||
if !filter_option.no_hires
|
||||
&& let Some(flac) = self.info.pointer_mut("/dash/flac/audio")
|
||||
&& let Some(flac) = self
|
||||
.info
|
||||
.pointer_mut("/dash/flac/audio")
|
||||
.and_then(|f| f.as_object_mut())
|
||||
{
|
||||
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
|
||||
bail!("invalid flac stream, flac content: {}", flac);
|
||||
bail!("invalid flac stream, flac content: {:?}", flac);
|
||||
};
|
||||
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 {
|
||||
@@ -426,7 +429,7 @@ mod tests {
|
||||
let config = VersionedConfig::get().read();
|
||||
for (bvid, video_quality, video_codec, audio_quality) in testcases.into_iter() {
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, bvid.to_owned(), &config.credential);
|
||||
let video = Video::new(&client, bvid, &config.credential);
|
||||
let pages = video.get_pages().await.expect("failed to get pages");
|
||||
let first_page = pages.into_iter().next().expect("no page found");
|
||||
let best_stream = video
|
||||
|
||||
@@ -196,6 +196,9 @@ impl<'a> Collection<'a> {
|
||||
})?;
|
||||
let archives = &mut videos["data"]["archives"];
|
||||
if archives.as_array().is_none_or(|v| v.is_empty()) {
|
||||
if page == 1 {
|
||||
break;
|
||||
}
|
||||
Err(anyhow!(
|
||||
"no videos found in collection {:?} page {}",
|
||||
self.collection,
|
||||
|
||||
@@ -52,7 +52,15 @@ impl<'a> Dynamic<'a> {
|
||||
.get_dynamics(offset.take())
|
||||
.await
|
||||
.with_context(|| "failed to get dynamics")?;
|
||||
let items = res["data"]["items"].as_array_mut().context("items not exist")?;
|
||||
let items = match res["data"]["items"].as_array_mut() {
|
||||
Some(items) if !items.is_empty() => items,
|
||||
_ => {
|
||||
if offset.is_none() {
|
||||
break;
|
||||
}
|
||||
Err(anyhow!("no dynamics found in offset {:?}", offset))?
|
||||
}
|
||||
};
|
||||
for item in items.iter_mut() {
|
||||
if item["type"].as_str().is_none_or(|t| t != "DYNAMIC_TYPE_AV") {
|
||||
continue;
|
||||
|
||||
@@ -16,12 +16,6 @@ pub struct FavoriteListInfo {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Upper<T> {
|
||||
pub mid: T,
|
||||
pub name: String,
|
||||
pub face: String,
|
||||
}
|
||||
impl<'a> FavoriteList<'a> {
|
||||
pub fn new(client: &'a BiliClient, fid: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
@@ -85,6 +79,9 @@ impl<'a> FavoriteList<'a> {
|
||||
.with_context(|| format!("failed to get videos of favorite {} page {}", self.fid, page))?;
|
||||
let medias = &mut videos["data"]["medias"];
|
||||
if medias.as_array().is_none_or(|v| v.is_empty()) {
|
||||
if page == 1 {
|
||||
break;
|
||||
}
|
||||
Err(anyhow!("no medias found in favorite {} page {}", self.fid, page))?;
|
||||
}
|
||||
let videos_info: Vec<VideoInfo> = serde_json::from_value(medias.take())
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
pub use analyzer::{BestStream, FilterOption};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use bili_sync_entity::upper_vec::Upper;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
pub use client::{BiliClient, Client};
|
||||
@@ -13,7 +14,6 @@ pub use danmaku::DanmakuOption;
|
||||
pub use dynamic::Dynamic;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::FavoriteList;
|
||||
use favorite_list::Upper;
|
||||
pub use me::Me;
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{RequestBuilder, StatusCode};
|
||||
@@ -133,7 +133,9 @@ pub enum VideoInfo {
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(rename = "owner")]
|
||||
upper: Upper<i64>,
|
||||
upper: Upper<i64, String>,
|
||||
#[serde(default)]
|
||||
staff: Option<Vec<Upper<i64, String>>>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
@@ -152,7 +154,7 @@ pub enum VideoInfo {
|
||||
bvid: String,
|
||||
intro: String,
|
||||
cover: String,
|
||||
upper: Upper<i64>,
|
||||
upper: Upper<i64, String>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
@@ -170,7 +172,7 @@ pub enum VideoInfo {
|
||||
#[serde(rename = "pic")]
|
||||
cover: String,
|
||||
#[serde(rename = "owner")]
|
||||
upper: Upper<i64>,
|
||||
upper: Upper<i64, String>,
|
||||
#[serde(with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "add_at", with = "ts_seconds")]
|
||||
@@ -311,7 +313,7 @@ mod tests {
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string(), &credential);
|
||||
let video = Video::new(&bili_client, "BV1gLfnY8E6D", &credential);
|
||||
let pages = video.get_pages().await?;
|
||||
println!("pages: {:?}", pages);
|
||||
let subtitles = video.get_subtitles(&pages[0]).await?;
|
||||
@@ -342,7 +344,7 @@ mod tests {
|
||||
("BV16w41187fx", (true, true)), // 充电专享但有权观看
|
||||
("BV1n34jzPEYq", (false, false)), // 普通视频
|
||||
] {
|
||||
let video = Video::new(&bili_client, bvid.to_string(), credential);
|
||||
let video = Video::new(&bili_client, bvid, credential);
|
||||
let info = video.get_view_info().await?;
|
||||
let VideoInfo::Detail {
|
||||
is_upower_exclusive,
|
||||
@@ -375,7 +377,7 @@ mod tests {
|
||||
("BV13xtnzPEye", false), // 番剧
|
||||
("BV1kT4NzTEZj", true), // 普通视频
|
||||
] {
|
||||
let video = Video::new(&bili_client, bvid.to_string(), credential);
|
||||
let video = Video::new(&bili_client, bvid, credential);
|
||||
let info = video.get_view_info().await?;
|
||||
let VideoInfo::Detail { redirect_url, .. } = info else {
|
||||
unreachable!();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_stream::try_stream;
|
||||
use bili_sync_entity::upper_vec::Upper;
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::favorite_list::Upper;
|
||||
use crate::bilibili::{BiliClient, Credential, Dynamic, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
pub struct Submission<'a> {
|
||||
client: &'a BiliClient,
|
||||
@@ -27,7 +27,7 @@ impl<'a> Submission<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<Upper<String>> {
|
||||
pub async fn get_info(&self) -> Result<Upper<String, String>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(
|
||||
@@ -82,6 +82,9 @@ impl<'a> Submission<'a> {
|
||||
.with_context(|| format!("failed to get videos of upper {} page {}", self.upper_id, page))?;
|
||||
let vlist = &mut videos["data"]["list"]["vlist"];
|
||||
if vlist.as_array().is_none_or(|v| v.is_empty()) {
|
||||
if page == 1 {
|
||||
break;
|
||||
}
|
||||
Err(anyhow!("no medias found in upper {} page {}", self.upper_id, page))?;
|
||||
}
|
||||
let videos_info: Vec<VideoInfo> = serde_json::from_value(vlist.take())
|
||||
|
||||
@@ -3,6 +3,7 @@ use futures::TryStreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use prost::Message;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::analyzer::PageAnalyzer;
|
||||
use crate::bilibili::client::BiliClient;
|
||||
@@ -12,7 +13,7 @@ use crate::bilibili::{Credential, ErrorForStatusExt, MIXIN_KEY, Validate, VideoI
|
||||
|
||||
pub struct Video<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub bvid: String,
|
||||
pub bvid: &'a str,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
@@ -35,7 +36,7 @@ pub struct Dimension {
|
||||
}
|
||||
|
||||
impl<'a> Video<'a> {
|
||||
pub fn new(client: &'a BiliClient, bvid: String, credential: &'a Credential) -> Self {
|
||||
pub fn new(client: &'a BiliClient, bvid: &'a str, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
bvid,
|
||||
@@ -85,7 +86,7 @@ impl<'a> Video<'a> {
|
||||
}
|
||||
|
||||
pub async fn get_tags(&self) -> Result<Vec<String>> {
|
||||
let res = self
|
||||
let mut res = self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -101,10 +102,10 @@ impl<'a> Video<'a> {
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(res["data"]
|
||||
.as_array()
|
||||
.as_array_mut()
|
||||
.context("tags is not an array")?
|
||||
.iter()
|
||||
.filter_map(|v| v["tag_name"].as_str().map(String::from))
|
||||
.iter_mut()
|
||||
.filter_map(|v| if let Value::String(s) = v.take() { Some(s) } else { None })
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ impl<'a> Video<'a> {
|
||||
)
|
||||
.await
|
||||
.query(&[
|
||||
("bvid", self.bvid.as_str()),
|
||||
("bvid", self.bvid),
|
||||
("qn", "127"),
|
||||
("otype", "json"),
|
||||
("fnval", "4048"),
|
||||
@@ -176,7 +177,7 @@ impl<'a> Video<'a> {
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2", self.credential)
|
||||
.await
|
||||
.query(&[("bvid", self.bvid.as_str())])
|
||||
.query(&[("bvid", self.bvid)])
|
||||
.query(&[("cid", page.cid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use anyhow::{Context, Result};
|
||||
use async_stream::try_stream;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
@@ -38,7 +38,7 @@ impl<'a> WatchLater<'a> {
|
||||
.with_context(|| "Failed to get watch later list")?;
|
||||
let list = &mut videos["data"]["list"];
|
||||
if list.as_array().is_none_or(|v| v.is_empty()) {
|
||||
Err(anyhow!("No videos found in watch later list"))?;
|
||||
return;
|
||||
}
|
||||
let videos_info: Vec<VideoInfo> =
|
||||
serde_json::from_value(list.take()).with_context(|| "Failed to parse watch later list")?;
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use croner::parser::CronParser;
|
||||
use itertools::Itertools;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
@@ -51,6 +52,8 @@ pub struct Config {
|
||||
pub concurrent_limit: ConcurrentLimit,
|
||||
pub time_format: String,
|
||||
pub cdn_sorting: bool,
|
||||
#[serde(default)]
|
||||
pub try_upower_anyway: bool,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
@@ -105,13 +108,7 @@ impl Config {
|
||||
}
|
||||
};
|
||||
if !errors.is_empty() {
|
||||
bail!(
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|e| format!("- {}", e))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
bail!(errors.into_iter().map(|e| format!("- {}", e)).join("\n"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -139,6 +136,7 @@ impl Default for Config {
|
||||
concurrent_limit: ConcurrentLimit::default(),
|
||||
time_format: default_time_format(),
|
||||
cdn_sorting: false,
|
||||
try_upower_anyway: false,
|
||||
version: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ mod tests {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let config = VersionedConfig::get().read();
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, "BV1QJmaYKEv4".to_owned(), &config.credential);
|
||||
let video = Video::new(&client, "BV1QJmaYKEv4", &config.credential);
|
||||
let pages = video.get_pages().await.expect("failed to get pages");
|
||||
let first_page = pages.into_iter().next().expect("no page found");
|
||||
let mut page_analyzer = video
|
||||
|
||||
67
crates/bili_sync/src/notifier/info.rs
Normal file
67
crates/bili_sync/src/notifier/info.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use bili_sync_entity::video;
|
||||
|
||||
use crate::utils::status::{STATUS_OK, VideoStatus};
|
||||
|
||||
pub enum DownloadNotifyInfo {
|
||||
List {
|
||||
source: String,
|
||||
img_url: Option<String>,
|
||||
titles: Vec<String>,
|
||||
},
|
||||
Summary {
|
||||
source: String,
|
||||
img_url: Option<String>,
|
||||
count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl DownloadNotifyInfo {
|
||||
pub fn new(source: String) -> Self {
|
||||
Self::List {
|
||||
source,
|
||||
img_url: None,
|
||||
titles: Vec::with_capacity(10),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&mut self, models: &[video::ActiveModel]) {
|
||||
let success_models = models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let sub_task_status: [u32; 5] = VideoStatus::from(*m.download_status.as_ref()).into();
|
||||
sub_task_status.into_iter().all(|s| s == STATUS_OK)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match self {
|
||||
Self::List {
|
||||
source,
|
||||
img_url,
|
||||
titles,
|
||||
} => {
|
||||
let count = success_models.len() + titles.len();
|
||||
if count > 10 {
|
||||
*self = Self::Summary {
|
||||
source: std::mem::take(source),
|
||||
img_url: std::mem::take(img_url),
|
||||
count,
|
||||
};
|
||||
} else {
|
||||
if img_url.is_none() {
|
||||
*img_url = success_models.first().map(|m| m.cover.as_ref().clone());
|
||||
}
|
||||
titles.extend(success_models.into_iter().map(|m| m.name.as_ref().clone()));
|
||||
}
|
||||
}
|
||||
Self::Summary { count, .. } => *count += success_models.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_notify(&self) -> bool {
|
||||
if let Self::List { titles, .. } = self
|
||||
&& titles.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
59
crates/bili_sync/src/notifier/message.rs
Normal file
59
crates/bili_sync/src/notifier/message.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::notifier::DownloadNotifyInfo;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Message<'a> {
|
||||
pub message: Cow<'a, str>,
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for Message<'a> {
|
||||
fn from(message: &'a str) -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed(message),
|
||||
image_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Message<'_> {
|
||||
fn from(message: String) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
image_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DownloadNotifyInfo> for Message<'_> {
|
||||
fn from(info: DownloadNotifyInfo) -> Self {
|
||||
match info {
|
||||
DownloadNotifyInfo::List {
|
||||
source,
|
||||
img_url,
|
||||
titles,
|
||||
} => Self {
|
||||
message: format!(
|
||||
"{}的 {} 条新视频已入库:\n{}",
|
||||
source,
|
||||
titles.len(),
|
||||
titles
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| format!("{}. {title}", i + 1))
|
||||
.join("\n")
|
||||
)
|
||||
.into(),
|
||||
image_url: img_url,
|
||||
},
|
||||
DownloadNotifyInfo::Summary { source, img_url, count } => Self {
|
||||
message: format!("{}的 {} 条新视频已入库,快去看看吧!", source, count).into(),
|
||||
image_url: img_url,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
mod info;
|
||||
mod message;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::future;
|
||||
pub use info::DownloadNotifyInfo;
|
||||
pub use message::Message;
|
||||
use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -15,6 +22,8 @@ pub enum Notifier {
|
||||
Webhook {
|
||||
url: String,
|
||||
template: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip)]
|
||||
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
|
||||
ignore_cache: Option<()>,
|
||||
@@ -33,46 +42,65 @@ pub fn webhook_template_content(template: &Option<String>) -> &str {
|
||||
}
|
||||
|
||||
pub trait NotifierAllExt {
|
||||
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()>;
|
||||
async fn notify_all<'a>(&self, client: &reqwest::Client, message: impl Into<Message<'a>>) -> Result<()>;
|
||||
}
|
||||
|
||||
impl NotifierAllExt for Vec<Notifier> {
|
||||
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()> {
|
||||
future::join_all(self.iter().map(|notifier| notifier.notify(client, message))).await;
|
||||
async fn notify_all<'a>(&self, client: &reqwest::Client, message: impl Into<Message<'a>>) -> Result<()> {
|
||||
let message = message.into();
|
||||
future::join_all(self.iter().map(|notifier| notifier.notify_internal(client, &message))).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Notifier {
|
||||
pub async fn notify(&self, client: &reqwest::Client, message: &str) -> Result<()> {
|
||||
pub async fn notify<'a>(&self, client: &reqwest::Client, message: impl Into<Message<'a>>) -> Result<()> {
|
||||
self.notify_internal(client, &message.into()).await
|
||||
}
|
||||
|
||||
async fn notify_internal<'a>(&self, client: &reqwest::Client, message: &Message<'a>) -> Result<()> {
|
||||
match self {
|
||||
Notifier::Telegram { bot_token, chat_id } => {
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
|
||||
let params = [("chat_id", chat_id.as_str()), ("text", message)];
|
||||
client.post(&url).form(¶ms).send().await?;
|
||||
if let Some(img_url) = &message.image_url {
|
||||
let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token);
|
||||
let params = [
|
||||
("chat_id", chat_id.as_str()),
|
||||
("photo", img_url.as_str()),
|
||||
("caption", message.message.as_ref()),
|
||||
];
|
||||
client.post(&url).form(¶ms).send().await?;
|
||||
} else {
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
|
||||
let params = [("chat_id", chat_id.as_str()), ("text", message.message.as_ref())];
|
||||
client.post(&url).form(¶ms).send().await?;
|
||||
}
|
||||
}
|
||||
Notifier::Webhook {
|
||||
url,
|
||||
template,
|
||||
headers,
|
||||
ignore_cache,
|
||||
} => {
|
||||
let key = webhook_template_key(url);
|
||||
let data = serde_json::json!(
|
||||
{
|
||||
"message": message,
|
||||
}
|
||||
);
|
||||
let handlebar = TEMPLATE.read();
|
||||
let payload = match ignore_cache {
|
||||
Some(_) => handlebar.render_template(webhook_template_content(template), &data)?,
|
||||
None => handlebar.render(&key, &data)?,
|
||||
Some(_) => handlebar.render_template(webhook_template_content(template), &message)?,
|
||||
None => handlebar.render(&key, &message)?,
|
||||
};
|
||||
client
|
||||
.post(url)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(payload)
|
||||
.send()
|
||||
.await?;
|
||||
let mut headers_map = header::HeaderMap::new();
|
||||
headers_map.insert(header::CONTENT_TYPE, "application/json".try_into()?);
|
||||
|
||||
if let Some(custom_headers) = headers {
|
||||
for (key, value) in custom_headers {
|
||||
if let (Ok(key), Ok(value)) =
|
||||
(header::HeaderName::try_from(key), header::HeaderValue::try_from(value))
|
||||
{
|
||||
headers_map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.post(url).headers(headers_map).body(payload).send().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -10,6 +10,7 @@ impl VideoInfo {
|
||||
let default = bili_sync_entity::video::ActiveModel {
|
||||
id: NotSet,
|
||||
created_at: NotSet,
|
||||
should_download: NotSet,
|
||||
// 此处不使用 ActiveModel::default() 是为了让其它字段有默认值
|
||||
..bili_sync_entity::video::Model::default().into_active_model()
|
||||
};
|
||||
@@ -49,7 +50,7 @@ impl VideoInfo {
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(attr == 0),
|
||||
valid: Set(attr == 0 || attr == 4),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name),
|
||||
upper_face: Set(upper.face),
|
||||
@@ -119,7 +120,12 @@ impl VideoInfo {
|
||||
|
||||
/// 填充视频详情时调用,该方法会将视频详情附加到原有的 Model 上
|
||||
/// 特殊地,如果在检测视频更新时记录了 favtime,那么 favtime 会维持原样,否则会使用 pubtime 填充
|
||||
pub fn into_detail_model(self, base_model: bili_sync_entity::video::Model) -> bili_sync_entity::video::ActiveModel {
|
||||
/// 如果开启 try_upower_anyway,标记视频状态时不再检测是否充电,一律进入后面的下载环节
|
||||
pub fn into_detail_model(
|
||||
self,
|
||||
base_model: bili_sync_entity::video::Model,
|
||||
try_upower_anyway: bool,
|
||||
) -> bili_sync_entity::video::ActiveModel {
|
||||
match self {
|
||||
VideoInfo::Detail {
|
||||
title,
|
||||
@@ -127,6 +133,7 @@ impl VideoInfo {
|
||||
intro,
|
||||
cover,
|
||||
upper,
|
||||
staff,
|
||||
ctime,
|
||||
pubtime,
|
||||
state,
|
||||
@@ -153,10 +160,13 @@ impl VideoInfo {
|
||||
// 2. 都为 false,表示视频是非充电视频
|
||||
// redirect_url 仅在视频为番剧、影视、纪录片等特殊视频时才会有值,如果为空说明是普通视频
|
||||
// 仅在三种条件都满足时,才认为视频是可下载的
|
||||
valid: Set(state == 0 && (is_upower_exclusive == is_upower_play) && redirect_url.is_none()),
|
||||
valid: Set(state == 0
|
||||
&& (try_upower_anyway || (is_upower_exclusive == is_upower_play))
|
||||
&& redirect_url.is_none()),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name),
|
||||
upper_face: Set(upper.face),
|
||||
staff: Set(staff.map(Into::into)),
|
||||
..base_model.into_active_model()
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -174,6 +184,17 @@ impl VideoInfo {
|
||||
VideoInfo::Detail { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bvid_owned(self) -> String {
|
||||
match self {
|
||||
VideoInfo::Collection { bvid, .. }
|
||||
| VideoInfo::Favorite { bvid, .. }
|
||||
| VideoInfo::WatchLater { bvid, .. }
|
||||
| VideoInfo::Submission { bvid, .. }
|
||||
| VideoInfo::Dynamic { bvid, .. }
|
||||
| VideoInfo::Detail { bvid, .. } => bvid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageInfo {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::upper_vec::Upper as EntityUpper;
|
||||
use bili_sync_entity::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use quick_xml::Error;
|
||||
@@ -20,9 +21,7 @@ pub struct Movie<'a> {
|
||||
pub name: &'a str,
|
||||
pub intro: &'a str,
|
||||
pub bvid: &'a str,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: &'a str,
|
||||
pub upper_thumb: &'a str,
|
||||
pub uppers: Vec<EntityUpper<i64, &'a str>>,
|
||||
pub premiered: NaiveDateTime,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
@@ -31,9 +30,7 @@ pub struct TVShow<'a> {
|
||||
pub name: &'a str,
|
||||
pub intro: &'a str,
|
||||
pub bvid: &'a str,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: &'a str,
|
||||
pub upper_thumb: &'a str,
|
||||
pub uppers: Vec<EntityUpper<i64, &'a str>>,
|
||||
pub premiered: NaiveDateTime,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
@@ -87,24 +84,26 @@ impl NFO<'_> {
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(movie.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&movie.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(movie.upper_name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(movie.upper_thumb))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
for upper in movie.uppers {
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&upper.mid.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(upper.role().as_ref()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(upper.face))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&movie.premiered.format("%Y").to_string()))
|
||||
@@ -145,24 +144,26 @@ impl NFO<'_> {
|
||||
.create_element("title")
|
||||
.write_text_content_async(BytesText::new(tvshow.name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&tvshow.upper_id.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(tvshow.upper_name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(tvshow.upper_thumb))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
for upper in tvshow.uppers {
|
||||
writer
|
||||
.create_element("actor")
|
||||
.write_inner_content_async::<_, _, Error>(|writer| async move {
|
||||
writer
|
||||
.create_element("name")
|
||||
.write_text_content_async(BytesText::new(&upper.mid.to_string()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(upper.role().as_ref()))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(upper.face))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&tvshow.premiered.format("%Y").to_string()))
|
||||
@@ -320,7 +321,7 @@ mod tests {
|
||||
</tvshow>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFO::Upper((&video).to_nfo(NFOTimeType::FavTime))
|
||||
NFO::Upper(((&video, &video.uppers().next().unwrap())).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
@@ -366,9 +367,7 @@ impl<'a> ToNFO<'a, Movie<'a>> for &'a video::Model {
|
||||
name: &self.name,
|
||||
intro: &self.intro,
|
||||
bvid: &self.bvid,
|
||||
upper_id: self.upper_id,
|
||||
upper_name: &self.upper_name,
|
||||
upper_thumb: &self.upper_face,
|
||||
uppers: self.uppers().collect(),
|
||||
premiered: match nfo_time_type {
|
||||
NFOTimeType::FavTime => self.favtime,
|
||||
NFOTimeType::PubTime => self.pubtime,
|
||||
@@ -384,9 +383,7 @@ impl<'a> ToNFO<'a, TVShow<'a>> for &'a video::Model {
|
||||
name: &self.name,
|
||||
intro: &self.intro,
|
||||
bvid: &self.bvid,
|
||||
upper_id: self.upper_id,
|
||||
upper_name: &self.upper_name,
|
||||
upper_thumb: &self.upper_face,
|
||||
uppers: self.uppers().collect(),
|
||||
premiered: match nfo_time_type {
|
||||
NFOTimeType::FavTime => self.favtime,
|
||||
NFOTimeType::PubTime => self.pubtime,
|
||||
@@ -396,11 +393,11 @@ impl<'a> ToNFO<'a, TVShow<'a>> for &'a video::Model {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToNFO<'a, Upper> for &'a video::Model {
|
||||
impl<'a> ToNFO<'a, Upper> for (&video::Model, &EntityUpper<i64, &str>) {
|
||||
fn to_nfo(&'a self, _nfo_time_type: NFOTimeType) -> Upper {
|
||||
Upper {
|
||||
upper_id: self.upper_id.to_string(),
|
||||
pubtime: self.pubtime,
|
||||
upper_id: self.1.mid.to_string(),
|
||||
pubtime: self.0.pubtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::Config;
|
||||
use crate::notifier::NotifierAllExt;
|
||||
use crate::notifier::{Message, NotifierAllExt};
|
||||
|
||||
pub fn notify(config: &Config, bili_client: &BiliClient, msg: impl Into<Message<'static>>) {
|
||||
if let Some(notifiers) = &config.notifiers
|
||||
&& !notifiers.is_empty()
|
||||
{
|
||||
let (notifiers, inner_client) = (notifiers.clone(), bili_client.inner_client().clone());
|
||||
let msg = msg.into();
|
||||
tokio::spawn(async move { notifiers.notify_all(&inner_client, msg).await });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_and_notify(config: &Config, bili_client: &BiliClient, msg: String) {
|
||||
error!("{msg}");
|
||||
@@ -8,6 +18,6 @@ pub fn error_and_notify(config: &Config, bili_client: &BiliClient, msg: String)
|
||||
&& !notifiers.is_empty()
|
||||
{
|
||||
let (notifiers, inner_client) = (notifiers.clone(), bili_client.inner_client().clone());
|
||||
tokio::spawn(async move { notifiers.notify_all(&inner_client, msg.as_str()).await });
|
||||
tokio::spawn(async move { notifiers.notify_all(&inner_client, msg).await });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,22 @@ impl Evaluatable<usize> for Condition<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> {
|
||||
fn evaluate(&self, value: &NaiveDateTime) -> bool {
|
||||
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,
|
||||
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<bool> for Condition<bool> {
|
||||
fn evaluate(&self, value: bool) -> bool {
|
||||
match self {
|
||||
Condition::Equals(expected) => *expected == value,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -65,13 +74,20 @@ impl FieldEvaluatable for RuleTarget {
|
||||
.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)),
|
||||
.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)),
|
||||
.is_some_and(|pub_time| cond.evaluate(pub_time)),
|
||||
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
|
||||
RuleTarget::SumVideoLength(cond) => pages
|
||||
.iter()
|
||||
.try_fold(0usize, |acc, page| {
|
||||
page.duration.try_as_ref().map(|d| acc + *d as usize).ok_or(())
|
||||
})
|
||||
.is_ok_and(|total_length| cond.evaluate(total_length)),
|
||||
RuleTarget::MultiUpper(cond) => cond.evaluate(video.staff.as_ref().is_some()),
|
||||
RuleTarget::Not(inner) => !inner.evaluate(video, pages),
|
||||
}
|
||||
}
|
||||
@@ -86,9 +102,13 @@ impl FieldEvaluatable for RuleTarget {
|
||||
.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::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::SumVideoLength(cond) => {
|
||||
cond.evaluate(pages.iter().fold(0usize, |acc, page| acc + page.duration as usize))
|
||||
}
|
||||
RuleTarget::MultiUpper(cond) => cond.evaluate(video.staff.is_some()),
|
||||
RuleTarget::Not(inner) => !inner.evaluate_model(video, pages),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use bili_sync_entity::upper_vec::Upper;
|
||||
use bili_sync_entity::*;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
@@ -17,6 +18,7 @@ use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Vi
|
||||
use crate::config::{ARGS, Config, PathSafeTemplate};
|
||||
use crate::downloader::Downloader;
|
||||
use crate::error::ExecutionStatus;
|
||||
use crate::notifier::DownloadNotifyInfo;
|
||||
use crate::utils::download_context::DownloadContext;
|
||||
use crate::utils::format_arg::{page_format_args, video_format_args};
|
||||
use crate::utils::model::{
|
||||
@@ -24,6 +26,7 @@ use crate::utils::model::{
|
||||
update_videos_model,
|
||||
};
|
||||
use crate::utils::nfo::{NFO, ToNFO};
|
||||
use crate::utils::notify::notify;
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
|
||||
|
||||
@@ -49,7 +52,11 @@ pub async fn process_video_source(
|
||||
warn!("已开启仅扫描模式,跳过视频下载..");
|
||||
} else {
|
||||
// 从数据库中查找所有未下载的视频与分页,下载并处理
|
||||
download_unprocessed_videos(bili_client, &video_source, connection, template, config).await?;
|
||||
let download_notify_info =
|
||||
download_unprocessed_videos(bili_client, &video_source, connection, template, config).await?;
|
||||
if download_notify_info.should_notify() {
|
||||
notify(config, bili_client, download_notify_info);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -125,7 +132,7 @@ pub async fn fetch_video_details(
|
||||
.into_iter()
|
||||
.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(), &config.credential);
|
||||
let video = Video::new(bili_client, video_model.bvid.as_str(), &config.credential);
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Err(e) => {
|
||||
@@ -150,7 +157,7 @@ pub async fn fetch_video_details(
|
||||
.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);
|
||||
let mut video_active_model = view_info.into_detail_model(video_model, config.try_upower_anyway);
|
||||
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()));
|
||||
@@ -164,7 +171,7 @@ pub async fn fetch_video_details(
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect::<Vec<_>>().await?;
|
||||
tasks.try_collect::<()>().await?;
|
||||
video_source.log_fetch_video_end();
|
||||
Ok(())
|
||||
}
|
||||
@@ -176,19 +183,24 @@ pub async fn download_unprocessed_videos(
|
||||
connection: &DatabaseConnection,
|
||||
template: &handlebars::Handlebars<'_>,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
) -> Result<DownloadNotifyInfo> {
|
||||
video_source.log_download_video_start();
|
||||
let semaphore = Semaphore::new(config.concurrent_limit.video);
|
||||
let downloader = Downloader::new(bili_client.client.clone());
|
||||
let cx = DownloadContext::new(bili_client, video_source, template, connection, &downloader, config);
|
||||
let unhandled_videos_pages = filter_unhandled_video_pages(video_source.filter_expr(), connection).await?;
|
||||
let mut assigned_upper = HashSet::new();
|
||||
let mut assigned_upper_ids = HashSet::new();
|
||||
let tasks = unhandled_videos_pages
|
||||
.into_iter()
|
||||
.map(|(video_model, pages_model)| {
|
||||
let should_download_upper = !assigned_upper.contains(&video_model.upper_id);
|
||||
assigned_upper.insert(video_model.upper_id);
|
||||
download_video_pages(video_model, pages_model, &semaphore, should_download_upper, cx)
|
||||
// 这里按理说是可以直接拿到 assigned_uppers 的,但rust 会错误地认为它引用了 local variable
|
||||
// 导致编译出错,暂时先这样单独提取出一个 owned 的 upper id 列表,再在任务内部筛选
|
||||
let task_uids = video_model
|
||||
.uppers()
|
||||
.map(|u| u.mid)
|
||||
.filter(|uid| assigned_upper_ids.insert(*uid))
|
||||
.collect::<Vec<_>>();
|
||||
download_video_pages(video_model, pages_model, &semaphore, task_uids, cx)
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let mut risk_control_related_error = None;
|
||||
@@ -207,21 +219,23 @@ pub async fn download_unprocessed_videos(
|
||||
.filter_map(|res| futures::future::ready(res.ok()))
|
||||
// 将成功返回的 Model 按十个一组合并
|
||||
.chunks(10);
|
||||
let mut download_notify_info = DownloadNotifyInfo::new(video_source.display_name().into());
|
||||
while let Some(models) = stream.next().await {
|
||||
download_notify_info.record(&models);
|
||||
update_videos_model(models, connection).await?;
|
||||
}
|
||||
if let Some(e) = risk_control_related_error {
|
||||
bail!(e);
|
||||
}
|
||||
video_source.log_download_video_end();
|
||||
Ok(())
|
||||
Ok(download_notify_info)
|
||||
}
|
||||
|
||||
pub async fn download_video_pages(
|
||||
video_model: video::Model,
|
||||
page_models: Vec<page::Model>,
|
||||
semaphore: &Semaphore,
|
||||
should_download_upper: bool,
|
||||
upper_uids: Vec<i64>,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<video::ActiveModel> {
|
||||
let _permit = semaphore.acquire().await.context("acquire semaphore failed")?;
|
||||
@@ -236,13 +250,27 @@ pub async fn download_video_pages(
|
||||
.path_safe_render("video", &video_format_args(&video_model, &cx.config.time_format))?,
|
||||
)
|
||||
};
|
||||
let upper_id = video_model.upper_id.to_string();
|
||||
let base_upper_path = cx
|
||||
.config
|
||||
.upper_path
|
||||
.join(upper_id.chars().next().context("upper_id is empty")?.to_string())
|
||||
.join(upper_id);
|
||||
fs::create_dir_all(&base_path).await?;
|
||||
|
||||
let base_path = dunce::canonicalize(base_path).context("canonicalize video path failed")?;
|
||||
let is_single_page = video_model.single_page.context("single_page is null")?;
|
||||
let uppers_with_path = video_model
|
||||
.uppers()
|
||||
.filter_map(|u| {
|
||||
if !upper_uids.contains(&u.mid) {
|
||||
None
|
||||
} else {
|
||||
let id_string = u.mid.to_string();
|
||||
Some((
|
||||
u,
|
||||
cx.config
|
||||
.upper_path
|
||||
.join(id_string.chars().next()?.to_string())
|
||||
.join(id_string),
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// 对于单页视频,page 的下载已经足够
|
||||
// 对于多页视频,page 下载仅包含了分集内容,需要额外补上视频的 poster 的 tvshow.nfo
|
||||
let (res_1, res_2, res_3, res_4, res_5) = tokio::join!(
|
||||
@@ -263,16 +291,15 @@ pub async fn download_video_pages(
|
||||
),
|
||||
// 下载 Up 主头像
|
||||
fetch_upper_face(
|
||||
separate_status[2] && should_download_upper && !cx.config.skip_option.no_upper,
|
||||
&video_model,
|
||||
base_upper_path.join("folder.jpg"),
|
||||
separate_status[2] && !cx.config.skip_option.no_upper,
|
||||
&uppers_with_path,
|
||||
cx
|
||||
),
|
||||
// 生成 Up 主信息的 nfo
|
||||
generate_upper_nfo(
|
||||
separate_status[3] && should_download_upper && !cx.config.skip_option.no_upper,
|
||||
separate_status[3] && !cx.config.skip_option.no_upper,
|
||||
&video_model,
|
||||
base_upper_path.join("person.nfo"),
|
||||
&uppers_with_path,
|
||||
cx,
|
||||
),
|
||||
// 分发并执行分页下载的任务
|
||||
@@ -416,6 +443,7 @@ pub async fn download_page(
|
||||
)?,
|
||||
)
|
||||
};
|
||||
let base_path = dunce::canonicalize(base_path).context("canonicalize base path failed")?;
|
||||
let (poster_path, video_path, nfo_path, danmaku_path, fanart_path, subtitle_path) = if is_single_page {
|
||||
(
|
||||
base_path.join(format!("{}-poster.jpg", &base_name)),
|
||||
@@ -578,7 +606,7 @@ pub async fn fetch_page_video(
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.as_str(), &cx.config.credential);
|
||||
let streams = bili_video
|
||||
.get_page_analyzer(page_info)
|
||||
.await?
|
||||
@@ -632,7 +660,7 @@ pub async fn fetch_page_danmaku(
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.as_str(), &cx.config.credential);
|
||||
bili_video
|
||||
.get_danmaku_writer(page_info)
|
||||
.await?
|
||||
@@ -651,7 +679,7 @@ pub async fn fetch_page_subtitle(
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.as_str(), &cx.config.credential);
|
||||
let subtitles = bili_video.get_subtitles(page_info).await?;
|
||||
let tasks = subtitles
|
||||
.into_iter()
|
||||
@@ -660,7 +688,7 @@ pub async fn fetch_page_subtitle(
|
||||
tokio::fs::write(path, subtitle.body.to_string()).await
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect::<Vec<()>>().await?;
|
||||
tasks.try_collect::<()>().await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
@@ -703,33 +731,48 @@ pub async fn fetch_video_poster(
|
||||
|
||||
pub async fn fetch_upper_face(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
upper_face_path: PathBuf,
|
||||
uppers_with_path: &[(Upper<i64, &str>, PathBuf)],
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
if !should_run || uppers_with_path.is_empty() {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
cx.downloader
|
||||
.fetch(
|
||||
&video_model.upper_face,
|
||||
&upper_face_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?;
|
||||
let tasks = uppers_with_path
|
||||
.iter()
|
||||
.map(|(upper, base_path)| async move {
|
||||
cx.downloader
|
||||
.fetch(
|
||||
upper.face,
|
||||
&base_path.join("folder.jpg"),
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect::<()>().await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
pub async fn generate_upper_nfo(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
nfo_path: PathBuf,
|
||||
uppers_with_path: &[(Upper<i64, &str>, PathBuf)],
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
generate_nfo(NFO::Upper(video_model.to_nfo(cx.config.nfo_time_type)), nfo_path).await?;
|
||||
let tasks = uppers_with_path
|
||||
.iter()
|
||||
.map(|(upper, base_path)| {
|
||||
generate_nfo(
|
||||
NFO::Upper((video_model, upper).to_nfo(cx.config.nfo_time_type)),
|
||||
base_path.join("person.nfo"),
|
||||
)
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tasks.try_collect::<()>().await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
derivative = { workspace = true }
|
||||
either = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod rule;
|
||||
pub mod string_vec;
|
||||
pub mod upper_vec;
|
||||
|
||||
@@ -30,6 +30,8 @@ pub enum RuleTarget {
|
||||
FavTime(Condition<DateTime>),
|
||||
PubTime(Condition<DateTime>),
|
||||
PageCount(Condition<usize>),
|
||||
SumVideoLength(Condition<usize>),
|
||||
MultiUpper(Condition<bool>),
|
||||
Not(Box<RuleTarget>),
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ impl Display for RuleTarget {
|
||||
RuleTarget::FavTime(_) => "收藏时间",
|
||||
RuleTarget::PubTime(_) => "发布时间",
|
||||
RuleTarget::PageCount(_) => "视频分页数量",
|
||||
RuleTarget::SumVideoLength(_) => "视频总时长",
|
||||
RuleTarget::MultiUpper(_) => "联合投稿",
|
||||
RuleTarget::Not(inner) => {
|
||||
if depth == 0 {
|
||||
get_field_name(inner, depth + 1)
|
||||
@@ -79,14 +83,16 @@ impl Display for RuleTarget {
|
||||
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||
write!(f, "{}不{}", field_name, cond)
|
||||
}
|
||||
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond),
|
||||
RuleTarget::PageCount(cond) | RuleTarget::SumVideoLength(cond) => write!(f, "{}不{}", field_name, cond),
|
||||
RuleTarget::MultiUpper(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),
|
||||
RuleTarget::PageCount(cond) | RuleTarget::SumVideoLength(cond) => write!(f, "{}{}", field_name, cond),
|
||||
RuleTarget::MultiUpper(cond) => write!(f, "{}{}", field_name, cond),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/bili_sync_entity/src/custom_type/upper_vec.rs
Normal file
48
crates/bili_sync_entity/src/custom_type/upper_vec.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use sea_orm::FromJsonQueryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Upper<T, S> {
|
||||
pub mid: T,
|
||||
pub name: S,
|
||||
pub face: S,
|
||||
pub title: Option<S>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||
pub struct UpperVec(pub Vec<Upper<i64, String>>);
|
||||
|
||||
impl From<Vec<Upper<i64, String>>> for UpperVec {
|
||||
fn from(value: Vec<Upper<i64, String>>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpperVec> for Vec<Upper<i64, String>> {
|
||||
fn from(value: UpperVec) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy> Upper<T, String> {
|
||||
pub fn as_ref(&self) -> Upper<T, &str> {
|
||||
Upper {
|
||||
mid: self.mid,
|
||||
name: self.name.as_str(),
|
||||
face: self.face.as_str(),
|
||||
title: self.title.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, S: AsRef<str>> Upper<T, S> {
|
||||
pub fn role(&self) -> Cow<'_, str> {
|
||||
if let Some(title) = &self.title {
|
||||
Cow::Owned(format!("{}「{}」", self.name.as_ref(), title.as_ref()))
|
||||
} else {
|
||||
Cow::Borrowed(self.name.as_ref())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use either::Either;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::string_vec::StringVec;
|
||||
use crate::upper_vec::{Upper, UpperVec};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
|
||||
#[sea_orm(table_name = "video")]
|
||||
@@ -16,6 +18,7 @@ pub struct Model {
|
||||
pub upper_id: i64,
|
||||
pub upper_name: String,
|
||||
pub upper_face: String,
|
||||
pub staff: Option<UpperVec>,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub category: i32,
|
||||
@@ -33,6 +36,21 @@ pub struct Model {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn uppers(&self) -> Either<impl Iterator<Item = Upper<i64, &str>>, impl Iterator<Item = Upper<i64, &str>>> {
|
||||
if let Some(staff) = self.staff.as_ref() {
|
||||
Either::Left(staff.0.iter().map(|u| u.as_ref()))
|
||||
} else {
|
||||
Either::Right(std::iter::once(Upper::<i64, &str> {
|
||||
mid: self.upper_id,
|
||||
name: self.upper_name.as_str(),
|
||||
face: self.upper_face.as_str(),
|
||||
title: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::page::Entity")]
|
||||
|
||||
@@ -10,6 +10,7 @@ mod m20250613_043257_add_config;
|
||||
mod m20250712_080013_add_video_created_at_index;
|
||||
mod m20250903_094454_add_rule_and_should_download;
|
||||
mod m20251009_123713_add_use_dynamic_api;
|
||||
mod m20260324_055217_add_staff;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -27,6 +28,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250712_080013_add_video_created_at_index::Migration),
|
||||
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
|
||||
Box::new(m20251009_123713_add_use_dynamic_api::Migration),
|
||||
Box::new(m20260324_055217_add_staff::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
30
crates/bili_sync_migration/src/m20260324_055217_add_staff.rs
Normal file
30
crates/bili_sync_migration/src/m20260324_055217_add_staff.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
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(text_null(Video::Staff))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(Table::alter().table(Video::Table).drop_column(Video::Staff).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Video {
|
||||
Table,
|
||||
Staff,
|
||||
}
|
||||
BIN
dist/bili-sync-rs-windows-x64.zip
vendored
Normal file
BIN
dist/bili-sync-rs-windows-x64.zip
vendored
Normal file
Binary file not shown.
BIN
dist/bili-sync-rs.exe
vendored
Normal file
BIN
dist/bili-sync-rs.exe
vendored
Normal file
Binary file not shown.
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.10.3",
|
||||
text: "v2.11.0",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.10.4,文档将始终与最新程序版本保持一致。
|
||||
> 当前最新程序版本为 v2.11.0,文档将始终与最新程序版本保持一致。
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
bili-sync.allwens.work
|
||||
bili-sync.amto.cc
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.93.0"
|
||||
channel = "1.94.0"
|
||||
components = ["clippy"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bili-sync-web",
|
||||
"version": "2.10.4",
|
||||
"version": "2.11.0",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
Config,
|
||||
DashBoardResponse,
|
||||
FavoritesResponse,
|
||||
FullSyncVideoSourceRequest,
|
||||
FullSyncVideoSourceResponse,
|
||||
QrcodeGenerateResponse as GenerateQrcodeResponse,
|
||||
InsertCollectionRequest,
|
||||
InsertFavoriteRequest,
|
||||
@@ -253,6 +255,14 @@ class ApiClient {
|
||||
return this.post<boolean>(`/video-sources/${type}/${id}/evaluate`, null);
|
||||
}
|
||||
|
||||
async fullSyncVideoSource(
|
||||
type: string,
|
||||
id: number,
|
||||
data: FullSyncVideoSourceRequest
|
||||
): Promise<ApiResponse<FullSyncVideoSourceResponse>> {
|
||||
return this.post<FullSyncVideoSourceResponse>(`/video-sources/${type}/${id}/full-sync`, data);
|
||||
}
|
||||
|
||||
async getDefaultPath(type: string, name: string): Promise<ApiResponse<string>> {
|
||||
return this.get<string>(`/video-sources/${type}/default-path`, { name });
|
||||
}
|
||||
@@ -327,6 +337,8 @@ const api = {
|
||||
removeVideoSource: (type: string, id: number) => apiClient.removeVideoSource(type, id),
|
||||
evaluateVideoSourceRules: (type: string, id: number) =>
|
||||
apiClient.evaluateVideoSourceRules(type, id),
|
||||
fullSyncVideoSource: (type: string, id: number, data: { delete_local: boolean }) =>
|
||||
apiClient.fullSyncVideoSource(type, id, data),
|
||||
getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name),
|
||||
testNotifier: (notifier: Notifier) => apiClient.testNotifier(notifier),
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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 * as Select from '$lib/components/ui/select/index.js';
|
||||
import { PlusIcon, MinusIcon, XIcon } from '@lucide/svelte/icons';
|
||||
import type { Rule, RuleTarget, Condition } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -21,7 +22,9 @@
|
||||
{ value: 'tags', label: '标签' },
|
||||
{ value: 'favTime', label: '收藏时间' },
|
||||
{ value: 'pubTime', label: '发布时间' },
|
||||
{ value: 'pageCount', label: '视频分页数量' }
|
||||
{ value: 'pageCount', label: '视频分页数量' },
|
||||
{ value: 'sumVideoLength', label: '视频总时长' },
|
||||
{ value: 'multiUpper', label: '联合投稿' }
|
||||
];
|
||||
|
||||
const getOperatorOptions = (field: string) => {
|
||||
@@ -37,6 +40,7 @@
|
||||
{ value: 'matchesRegex', label: '匹配正则' }
|
||||
];
|
||||
case 'pageCount':
|
||||
case 'sumVideoLength':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'greaterThan', label: '大于' },
|
||||
@@ -51,6 +55,8 @@
|
||||
{ value: 'lessThan', label: '早于' },
|
||||
{ value: 'between', label: '时间范围' }
|
||||
];
|
||||
case 'multiUpper':
|
||||
return [{ value: 'equals', label: '等于' }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -80,7 +86,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition {
|
||||
function convertRuleTargetToLocal(
|
||||
target: RuleTarget<string | number | boolean | Date>
|
||||
): LocalCondition {
|
||||
if (typeof target.rule === 'object' && 'field' in target.rule) {
|
||||
// 嵌套的 not
|
||||
const innerCondition = convertRuleTargetToLocal(target.rule);
|
||||
@@ -93,10 +101,10 @@
|
||||
let value = '';
|
||||
let value2 = '';
|
||||
if (Array.isArray(condition.value)) {
|
||||
value = String(condition.value[0] || '');
|
||||
value2 = String(condition.value[1] || '');
|
||||
value = String(condition.value[0] ?? '');
|
||||
value2 = String(condition.value[1] ?? '');
|
||||
} else {
|
||||
value = String(condition.value || '');
|
||||
value = String(condition.value ?? '');
|
||||
}
|
||||
return {
|
||||
field: target.field,
|
||||
@@ -111,8 +119,8 @@
|
||||
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') {
|
||||
let value: string | number | boolean | Date | (string | number | boolean | Date)[];
|
||||
if (condition.field === 'pageCount' || condition.field === 'sumVideoLength') {
|
||||
if (condition.operator === 'between') {
|
||||
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
|
||||
} else {
|
||||
@@ -124,6 +132,8 @@
|
||||
} else {
|
||||
value = condition.value;
|
||||
}
|
||||
} else if (condition.field === 'multiUpper') {
|
||||
value = condition.value === 'true';
|
||||
} else {
|
||||
if (condition.operator === 'between') {
|
||||
value = [condition.value, condition.value2 || ''];
|
||||
@@ -131,12 +141,12 @@
|
||||
value = condition.value;
|
||||
}
|
||||
}
|
||||
const conditionObj: Condition<string | number | Date> = {
|
||||
const conditionObj: Condition<string | number | boolean | Date> = {
|
||||
operator: condition.operator,
|
||||
value
|
||||
};
|
||||
|
||||
let target: RuleTarget<string | number | Date> = {
|
||||
let target: RuleTarget<string | number | boolean | Date> = {
|
||||
field: condition.field,
|
||||
rule: conditionObj
|
||||
};
|
||||
@@ -187,7 +197,7 @@
|
||||
condition.field = value;
|
||||
const operators = getOperatorOptions(value);
|
||||
condition.operator = operators[0]?.value || 'equals';
|
||||
condition.value = '';
|
||||
condition.value = value === 'multiUpper' ? 'false' : '';
|
||||
condition.value2 = '';
|
||||
} else if (field === 'operator') {
|
||||
condition.operator = value;
|
||||
@@ -290,36 +300,43 @@
|
||||
<!-- 字段选择 -->
|
||||
<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"
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={condition.field}
|
||||
onchange={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
|
||||
onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'field', v)}
|
||||
>
|
||||
{#each FIELD_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Select.Trigger class="w-full">
|
||||
{FIELD_OPTIONS.find((o) => o.value === condition.field)?.label ??
|
||||
condition.field}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each FIELD_OPTIONS as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</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"
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={condition.operator}
|
||||
onchange={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'operator',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
onValueChange={(v) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'operator', v)}
|
||||
>
|
||||
{#each getOperatorOptions(condition.field) as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Select.Trigger class="w-full">
|
||||
{getOperatorOptions(condition.field).find(
|
||||
(o) => o.value === condition.operator
|
||||
)?.label ?? condition.operator}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each getOperatorOptions(condition.field) as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,10 +345,11 @@
|
||||
<Label class="text-muted-foreground text-xs">值</Label>
|
||||
{#if condition.operator === 'between'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#if condition.field === 'pageCount'}
|
||||
{#if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最小值"
|
||||
placeholder={'最小值' +
|
||||
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
@@ -344,7 +362,8 @@
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最大值"
|
||||
placeholder={'最大值' +
|
||||
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
@@ -411,10 +430,11 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if condition.field === 'pageCount'}
|
||||
{:else if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="输入数值"
|
||||
placeholder={'输入数值' +
|
||||
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
@@ -434,6 +454,20 @@
|
||||
e.currentTarget.value + ':00'
|
||||
)}
|
||||
/>
|
||||
{:else if condition.field === 'multiUpper'}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={condition.value}
|
||||
onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'value', v)}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{condition.value === 'true' ? 'true' : 'false'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="true" label="true" />
|
||||
<Select.Item value="false" label="false" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader class="flex-shrink-0">
|
||||
<CardHeader class="shrink-0">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- 头像或图标 - 简化设计 -->
|
||||
<div
|
||||
|
||||
95
web/src/lib/components/validation-filter.svelte
Normal file
95
web/src/lib/components/validation-filter.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
CircleCheckBigIcon,
|
||||
TriangleAlertIcon,
|
||||
SkipForwardIcon,
|
||||
ChevronDownIcon,
|
||||
TrashIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { type ValidationFilterValue } from '$lib/stores/filter';
|
||||
|
||||
interface Props {
|
||||
value: ValidationFilterValue;
|
||||
onSelect?: (value: ValidationFilterValue) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let { value = $bindable('normal'), onSelect, onRemove }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerRef = $state<HTMLButtonElement>(null!);
|
||||
|
||||
function closeAndFocusTrigger() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
const validationOptions = [
|
||||
{
|
||||
value: 'normal' as const,
|
||||
label: '有效',
|
||||
icon: CircleCheckBigIcon
|
||||
},
|
||||
{
|
||||
value: 'skipped' as const,
|
||||
label: '跳过',
|
||||
icon: SkipForwardIcon
|
||||
},
|
||||
{
|
||||
value: 'invalid' as const,
|
||||
label: '失效',
|
||||
icon: TriangleAlertIcon
|
||||
}
|
||||
];
|
||||
|
||||
function handleSelect(selectedValue: ValidationFilterValue) {
|
||||
value = selectedValue;
|
||||
onSelect?.(selectedValue);
|
||||
closeAndFocusTrigger();
|
||||
}
|
||||
|
||||
const currentOption = $derived(validationOptions.find((opt) => opt.value === value));
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<span class="bg-secondary text-secondary-foreground rounded-lg px-2 py-1 text-xs font-medium">
|
||||
{currentOption ? currentOption.label : '未应用'}
|
||||
</span>
|
||||
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger bind:ref={triggerRef}>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
|
||||
<ChevronDownIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="w-50" align="end">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label class="text-xs">有效性</DropdownMenu.Label>
|
||||
{#each validationOptions as option (option.value)}
|
||||
<DropdownMenu.Item class="text-xs" onclick={() => handleSelect(option.value)}>
|
||||
<option.icon class="mr-2 size-3" />
|
||||
<span class:font-semibold={value === option.value}>
|
||||
{option.label}
|
||||
</span>
|
||||
{#if value === option.value}
|
||||
<CircleCheckBigIcon class="ml-auto size-3" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
closeAndFocusTrigger();
|
||||
onRemove?.();
|
||||
}}
|
||||
>
|
||||
<TrashIcon class="mr-2 size-3" />
|
||||
<span class="text-xs font-medium">移除筛选</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
@@ -13,13 +13,17 @@
|
||||
BrushCleaningIcon,
|
||||
UserIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
EllipsisIcon
|
||||
EllipsisIcon,
|
||||
HeartIcon,
|
||||
FolderIcon,
|
||||
ClockIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
// 将 bvid 设置为可选属性,但保留 VideoInfo 的其它所有属性
|
||||
export let video: Omit<VideoInfo, 'bvid'> & { bvid?: string };
|
||||
export let source: { type: string; name: string } | null = null; // 视频源信息
|
||||
export let showActions: boolean = true; // 控制是否显示操作按钮
|
||||
export let mode: 'default' | 'detail' | 'page' = 'default'; // 卡片模式
|
||||
export let customTitle: string = ''; // 自定义标题
|
||||
@@ -57,11 +61,16 @@
|
||||
|
||||
function getOverallStatus(
|
||||
downloadStatus: number[],
|
||||
shouldDownload: boolean
|
||||
shouldDownload: boolean,
|
||||
valid: boolean
|
||||
): {
|
||||
text: string;
|
||||
style: string;
|
||||
} {
|
||||
if (!valid) {
|
||||
// 视频属性表明已失效,或由于各种条件判断(充电视频等)判定为无效的情况
|
||||
return { text: '失效', style: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
if (!shouldDownload) {
|
||||
// 被过滤规则排除,显示为“跳过”
|
||||
return { text: '跳过', style: 'bg-gray-100 text-gray-700' };
|
||||
@@ -90,7 +99,7 @@
|
||||
return defaultTaskNames[index] || `任务${index + 1}`;
|
||||
}
|
||||
|
||||
$: overallStatus = getOverallStatus(video.download_status, video.should_download);
|
||||
$: overallStatus = getOverallStatus(video.download_status, video.should_download, video.valid);
|
||||
$: completed = video.download_status.filter((status) => status === 7).length;
|
||||
$: total = video.download_status.length;
|
||||
|
||||
@@ -127,7 +136,7 @@
|
||||
</script>
|
||||
|
||||
<Card class={cardClasses}>
|
||||
<CardHeader class="shrink-0 pb-3">
|
||||
<CardHeader class="shrink-0 pb-1">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<CardTitle
|
||||
class="line-clamp-2 min-w-0 flex-1 cursor-default {mode === 'default'
|
||||
@@ -152,6 +161,24 @@
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if source}
|
||||
<div class="text-muted-foreground mt-2 flex min-w-0 items-center justify-end gap-1 text-sm">
|
||||
<Badge variant="outline" class="max-w-full shrink px-1.5 py-0.5">
|
||||
{#if source.type === 'favorite'}
|
||||
<HeartIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if source.type === 'collection'}
|
||||
<FolderIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if source.type === 'submission'}
|
||||
<UserIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if source.type === 'watch_later'}
|
||||
<ClockIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate" title={source.name}>
|
||||
{source.name}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0 pb-3' : 'pt-0 pb-4'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type StatusFilterValue = 'failed' | 'succeeded' | 'waiting' | null;
|
||||
export type ValidationFilterValue = 'skipped' | 'invalid' | 'normal' | null;
|
||||
|
||||
export interface AppState {
|
||||
query: string;
|
||||
@@ -10,17 +11,19 @@ export interface AppState {
|
||||
id: string;
|
||||
} | null;
|
||||
statusFilter: StatusFilterValue | null;
|
||||
validationFilter: ValidationFilterValue | null;
|
||||
}
|
||||
|
||||
export const appStateStore = writable<AppState>({
|
||||
query: '',
|
||||
currentPage: 0,
|
||||
videoSource: null,
|
||||
statusFilter: null
|
||||
statusFilter: null,
|
||||
validationFilter: 'normal'
|
||||
});
|
||||
|
||||
export const ToQuery = (state: AppState): string => {
|
||||
const { query, videoSource, currentPage, statusFilter } = state;
|
||||
const { query, videoSource, currentPage, statusFilter, validationFilter } = state;
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 0) {
|
||||
params.set('page', String(currentPage));
|
||||
@@ -34,6 +37,9 @@ export const ToQuery = (state: AppState): string => {
|
||||
if (statusFilter) {
|
||||
params.set('status_filter', statusFilter);
|
||||
}
|
||||
if (validationFilter) {
|
||||
params.set('validation_filter', validationFilter);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `videos?${queryString}` : 'videos';
|
||||
};
|
||||
@@ -48,6 +54,7 @@ export const ToFilterParams = (
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
status_filter?: Exclude<StatusFilterValue, null>;
|
||||
validation_filter?: Exclude<ValidationFilterValue, null>;
|
||||
} => {
|
||||
const params: {
|
||||
query?: string;
|
||||
@@ -56,6 +63,7 @@ export const ToFilterParams = (
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
status_filter?: Exclude<StatusFilterValue, null>;
|
||||
validation_filter?: Exclude<ValidationFilterValue, null>;
|
||||
} = {};
|
||||
|
||||
if (state.query.trim()) {
|
||||
@@ -69,12 +77,20 @@ export const ToFilterParams = (
|
||||
if (state.statusFilter) {
|
||||
params.status_filter = state.statusFilter;
|
||||
}
|
||||
if (state.validationFilter) {
|
||||
params.validation_filter = state.validationFilter;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
// 检查是否有活动的筛选条件
|
||||
export const hasActiveFilters = (state: AppState): boolean => {
|
||||
return !!(state.query.trim() || state.videoSource || state.statusFilter);
|
||||
return !!(
|
||||
state.query.trim() ||
|
||||
state.videoSource ||
|
||||
state.statusFilter ||
|
||||
state.validationFilter
|
||||
);
|
||||
};
|
||||
|
||||
export const setQuery = (query: string) => {
|
||||
@@ -98,6 +114,13 @@ export const setStatusFilter = (statusFilter: StatusFilterValue | null) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const setValidationFilter = (validationFilter: ValidationFilterValue | null) => {
|
||||
appStateStore.update((state) => ({
|
||||
...state,
|
||||
validationFilter
|
||||
}));
|
||||
};
|
||||
|
||||
export const resetCurrentPage = () => {
|
||||
appStateStore.update((state) => ({
|
||||
...state,
|
||||
@@ -109,12 +132,14 @@ export const setAll = (
|
||||
query: string,
|
||||
currentPage: number,
|
||||
videoSource: { type: string; id: string } | null,
|
||||
statusFilter: StatusFilterValue | null
|
||||
statusFilter: StatusFilterValue | null,
|
||||
validationFilter: ValidationFilterValue | null = 'normal'
|
||||
) => {
|
||||
appStateStore.set({
|
||||
query,
|
||||
currentPage,
|
||||
videoSource,
|
||||
statusFilter
|
||||
statusFilter,
|
||||
validationFilter
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface VideosRequest {
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
failed_only?: boolean;
|
||||
status_filter?: 'failed' | 'succeeded' | 'waiting';
|
||||
validation_filter?: 'skipped' | 'invalid' | 'normal';
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
@@ -31,8 +32,13 @@ export interface VideoInfo {
|
||||
bvid: string;
|
||||
name: string;
|
||||
upper_name: string;
|
||||
valid: boolean;
|
||||
should_download: boolean;
|
||||
download_status: [number, number, number, number, number];
|
||||
collection_id?: number;
|
||||
favorite_id?: number;
|
||||
submission_id?: number;
|
||||
watch_later_id?: number;
|
||||
}
|
||||
|
||||
export interface VideosResponse {
|
||||
@@ -83,7 +89,16 @@ export interface UpdateFilteredVideoStatusResponse {
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status?: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface FullSyncVideoSourceRequest {
|
||||
delete_local: boolean;
|
||||
}
|
||||
|
||||
export interface FullSyncVideoSourceResponse {
|
||||
removed_count: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface StatusUpdate {
|
||||
@@ -107,8 +122,8 @@ export interface UpdateFilteredVideoStatusRequest {
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
// 仅更新下载失败
|
||||
failed_only?: boolean;
|
||||
status_filter?: 'failed' | 'succeeded' | 'waiting';
|
||||
validation_filter?: 'skipped' | 'invalid' | 'normal';
|
||||
video_updates?: StatusUpdate[];
|
||||
page_updates?: StatusUpdate[];
|
||||
}
|
||||
@@ -123,8 +138,8 @@ export interface ResetFilteredVideoStatusRequest {
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
// 仅重置下载失败
|
||||
failed_only?: boolean;
|
||||
status_filter?: 'failed' | 'succeeded' | 'waiting';
|
||||
validation_filter?: 'skipped' | 'invalid' | 'normal';
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
@@ -195,7 +210,7 @@ export interface RuleTarget<T> {
|
||||
rule: Condition<T> | RuleTarget<T>;
|
||||
}
|
||||
|
||||
export type AndGroup = RuleTarget<string | number | Date>[];
|
||||
export type AndGroup = RuleTarget<string | number | boolean | Date>[];
|
||||
export type Rule = AndGroup[];
|
||||
|
||||
export interface VideoSourceDetail {
|
||||
@@ -293,6 +308,7 @@ export interface WebhookNotifier {
|
||||
type: 'webhook';
|
||||
url: string;
|
||||
template?: string | null;
|
||||
headers?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export type Notifier = TelegramNotifier | WebhookNotifier;
|
||||
@@ -319,6 +335,7 @@ export interface Config {
|
||||
concurrent_limit: ConcurrentLimit;
|
||||
time_format: string;
|
||||
cdn_sorting: boolean;
|
||||
try_upower_anyway: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,11 @@ interface ClientEvent {
|
||||
type LogsCallback = (data: string) => void;
|
||||
type TasksCallback = (data: TaskStatus) => void;
|
||||
type SysInfoCallback = (data: SysInfo) => void;
|
||||
type ErrorCallback = (error: Event) => void;
|
||||
|
||||
export class WebSocketManager {
|
||||
private static instance: WebSocketManager;
|
||||
private socket: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private connecting = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
@@ -41,7 +39,6 @@ export class WebSocketManager {
|
||||
private logsSubscribers: Set<LogsCallback> = new Set();
|
||||
private tasksSubscribers: Set<TasksCallback> = new Set();
|
||||
private sysInfoSubscribers: Set<SysInfoCallback> = new Set();
|
||||
private errorSubscribers: Set<ErrorCallback> = new Set();
|
||||
|
||||
private subscribedEvents: Set<EventType> = new Set();
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
@@ -61,7 +58,6 @@ export class WebSocketManager {
|
||||
if (this.connectionPromise) return this.connectionPromise;
|
||||
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connecting = true;
|
||||
const token = api.getAuthToken() || '';
|
||||
|
||||
try {
|
||||
@@ -73,7 +69,6 @@ export class WebSocketManager {
|
||||
);
|
||||
this.socket.onopen = () => {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connectionPromise = null;
|
||||
this.resubscribeEvents();
|
||||
@@ -84,20 +79,17 @@ export class WebSocketManager {
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
reject(error);
|
||||
toast.error('WebSocket 连接发生错误,请检查网络或稍后重试');
|
||||
};
|
||||
} catch (error) {
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
reject(error);
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
@@ -273,7 +265,6 @@ export class WebSocketManager {
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
this.subscribedEvents.clear();
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
DownloadIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
|
||||
let dashboardData: DashBoardResponse | null = null;
|
||||
let sysInfo: SysInfo | null = null;
|
||||
let taskStatus: TaskStatus | null = null;
|
||||
let loading = false;
|
||||
let triggering = false;
|
||||
let dashboardData = $state<DashBoardResponse | null>(null);
|
||||
let sysInfo = $state<SysInfo | null>(null);
|
||||
let taskStatus = $state<TaskStatus | null>(null);
|
||||
let loading = $state(false);
|
||||
let triggering = $state(false);
|
||||
let memoryHistory = $state<Array<{ time: number; used: number; process: number }>>([]);
|
||||
let cpuHistory = $state<Array<{ time: number; used: number; process: number }>>([]);
|
||||
let unsubscribeSysInfo: (() => void) | null = null;
|
||||
let unsubscribeTasks: (() => void) | null = null;
|
||||
|
||||
@@ -90,29 +92,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setBreadcrumb([{ label: '仪表盘' }]);
|
||||
|
||||
unsubscribeSysInfo = api.subscribeToSysInfo((data) => {
|
||||
sysInfo = data;
|
||||
});
|
||||
unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => {
|
||||
taskStatus = data;
|
||||
});
|
||||
loadDashboard();
|
||||
return () => {
|
||||
if (unsubscribeSysInfo) {
|
||||
unsubscribeSysInfo();
|
||||
unsubscribeSysInfo = null;
|
||||
}
|
||||
if (unsubscribeTasks) {
|
||||
unsubscribeTasks();
|
||||
unsubscribeTasks = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 图表配置
|
||||
const videoChartConfig = {
|
||||
videos: {
|
||||
label: '视频数量',
|
||||
@@ -142,32 +121,51 @@
|
||||
}
|
||||
} satisfies Chart.ChartConfig;
|
||||
|
||||
let memoryHistory: Array<{ time: number; used: number; process: number }> = [];
|
||||
let cpuHistory: Array<{ time: number; used: number; process: number }> = [];
|
||||
|
||||
$: if (sysInfo) {
|
||||
function pushSysInfo(data: SysInfo) {
|
||||
memoryHistory = [
|
||||
...memoryHistory.slice(-14),
|
||||
{
|
||||
time: sysInfo.timestamp,
|
||||
used: sysInfo.used_memory,
|
||||
process: sysInfo.process_memory
|
||||
time: data.timestamp,
|
||||
used: data.used_memory,
|
||||
process: data.process_memory
|
||||
}
|
||||
];
|
||||
cpuHistory = [
|
||||
...cpuHistory.slice(-14),
|
||||
{
|
||||
time: sysInfo.timestamp,
|
||||
used: sysInfo.used_cpu,
|
||||
process: sysInfo.process_cpu
|
||||
time: data.timestamp,
|
||||
used: data.used_cpu,
|
||||
process: data.process_cpu
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// 计算磁盘使用率
|
||||
$: diskUsagePercent = sysInfo
|
||||
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
|
||||
: 0;
|
||||
const diskUsagePercent = $derived(
|
||||
sysInfo ? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100 : 0
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
setBreadcrumb([{ label: '仪表盘' }]);
|
||||
|
||||
unsubscribeSysInfo = api.subscribeToSysInfo((data) => {
|
||||
sysInfo = data;
|
||||
pushSysInfo(data);
|
||||
});
|
||||
unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => {
|
||||
taskStatus = data;
|
||||
});
|
||||
loadDashboard();
|
||||
return () => {
|
||||
if (unsubscribeSysInfo) {
|
||||
unsubscribeSysInfo();
|
||||
unsubscribeSysInfo = null;
|
||||
}
|
||||
if (unsubscribeTasks) {
|
||||
unsubscribeTasks();
|
||||
unsubscribeTasks = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script lang="ts">
|
||||
import api from '$lib/api';
|
||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
let unsubscribeLog: (() => void) | null = null;
|
||||
let logs: Array<{ timestamp: string; level: string; message: string }> = [];
|
||||
let shouldAutoScroll = true;
|
||||
let main: HTMLElement | null = null;
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function checkScrollPosition() {
|
||||
if (main) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = main;
|
||||
shouldAutoScroll = scrollTop + clientHeight >= scrollHeight - 5;
|
||||
shouldAutoScroll = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
if (shouldAutoScroll && main) {
|
||||
main.scrollTop = main.scrollHeight;
|
||||
}
|
||||
@@ -28,9 +30,11 @@
|
||||
main?.addEventListener('scroll', checkScrollPosition);
|
||||
unsubscribeLog = api.subscribeToLogs((data: string) => {
|
||||
logs = [...logs.slice(-499), JSON.parse(data)];
|
||||
setTimeout(scrollToBottom, 0);
|
||||
if (scrollTimer) clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(scrollToBottom, 20);
|
||||
});
|
||||
return () => {
|
||||
if (scrollTimer) clearTimeout(scrollTimer);
|
||||
main?.removeEventListener('scroll', checkScrollPosition);
|
||||
if (unsubscribeLog) {
|
||||
unsubscribeLog();
|
||||
|
||||
@@ -366,6 +366,24 @@
|
||||
<Switch id="cdn-sorting" bind:checked={formData.cdn_sorting} />
|
||||
<Label for="cdn-sorting">启用CDN排序</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="try-upower-anyway" bind:checked={formData.try_upower_anyway} />
|
||||
<div class="flex items-center gap-1">
|
||||
<Label for="try-upower-anyway">尝试下载未充电视频</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<InfoIcon class="text-muted-foreground h-3.5 w-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-xs">
|
||||
当关闭该开关时,程序仅会下载已充电的视频,未充电的视频直接跳过;开启后不再检查充电状态,一律尝试下载。<br
|
||||
/>
|
||||
这可以帮助下载未充电视频的封面等元数据,也应该可以下载未充电视频的试看部分(如果存在的话)。
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Notifier } from '$lib/types';
|
||||
|
||||
const jsonExample = '{"text": "您的消息内容"}';
|
||||
|
||||
export let notifier: Notifier | null = null;
|
||||
export let onSave: (notifier: Notifier) => void;
|
||||
export let onCancel: () => void;
|
||||
@@ -16,6 +14,7 @@
|
||||
let chatId = '';
|
||||
let webhookUrl = '';
|
||||
let webhookTemplate = '';
|
||||
let webhookHeaders: { key: string; value: string }[] = [];
|
||||
|
||||
// 初始化表单
|
||||
$: {
|
||||
@@ -28,6 +27,11 @@
|
||||
type = 'webhook';
|
||||
webhookUrl = notifier.url;
|
||||
webhookTemplate = notifier.template || '';
|
||||
if (notifier.headers) {
|
||||
webhookHeaders = Object.entries(notifier.headers).map(([key, value]) => ({ key, value }));
|
||||
} else {
|
||||
webhookHeaders = [];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
type = 'telegram';
|
||||
@@ -35,11 +39,11 @@
|
||||
chatId = '';
|
||||
webhookUrl = '';
|
||||
webhookTemplate = '';
|
||||
webhookHeaders = [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
// 验证表单
|
||||
if (type === 'telegram') {
|
||||
if (!botToken.trim()) {
|
||||
toast.error('请输入 Bot Token');
|
||||
@@ -62,7 +66,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单的 URL 验证
|
||||
try {
|
||||
new URL(webhookUrl.trim());
|
||||
} catch {
|
||||
@@ -70,10 +73,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const { key, value } of webhookHeaders) {
|
||||
const trimmedKey = key.trim();
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedKey && trimmedValue) {
|
||||
headers[trimmedKey] = trimmedValue;
|
||||
}
|
||||
}
|
||||
|
||||
const newNotifier: Notifier = {
|
||||
type: 'webhook',
|
||||
url: webhookUrl.trim(),
|
||||
template: webhookTemplate.trim() || null
|
||||
template: webhookTemplate.trim() || null,
|
||||
headers: Object.keys(headers).length > 0 ? headers : null
|
||||
};
|
||||
onSave(newNotifier);
|
||||
}
|
||||
@@ -111,11 +124,7 @@
|
||||
{:else if type === 'webhook'}
|
||||
<div class="space-y-2">
|
||||
<Label for="webhook-url">Webhook URL</Label>
|
||||
<Input id="webhook-url" placeholder="https://example.com/webhook" bind:value={webhookUrl} />
|
||||
<p class="text-muted-foreground text-xs">
|
||||
接收通知的 Webhook 地址<br />
|
||||
格式示例:{jsonExample}
|
||||
</p>
|
||||
<Input id="webhook-url" placeholder="请输入 Webhook 地址" bind:value={webhookUrl} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="webhook-template">模板(可选)</Label>
|
||||
@@ -127,7 +136,48 @@
|
||||
></textarea>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
|
||||
可用变量:<code class="text-xs">message</code>(通知内容)
|
||||
可用变量:<code class="text-xs">message</code>(通知内容)、<code class="text-xs"
|
||||
>image_url</code
|
||||
>(封面图片地址,无图时为 null)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>自定义请求头(可选)</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (webhookHeaders = [...webhookHeaders, { key: '', value: '' }])}
|
||||
>
|
||||
+ 添加请求头
|
||||
</Button>
|
||||
</div>
|
||||
{#each webhookHeaders as header, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Header 名称(例如 Authorization)"
|
||||
bind:value={header.key}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header 值"
|
||||
bind:value={header.value}
|
||||
class="flex-1"
|
||||
type={header.key.toLowerCase() === 'authorization' ? 'password' : 'text'}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (webhookHeaders = webhookHeaders.filter((_, i) => i !== index))}
|
||||
class="h-10 px-2"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
添加自定义请求头,例如:Authorization: Bearer your_token
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Switch } from '$lib/components/ui/switch/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 { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
@@ -18,7 +19,8 @@
|
||||
InfoIcon,
|
||||
Trash2Icon,
|
||||
CircleCheckBigIcon,
|
||||
CircleXIcon
|
||||
CircleXIcon,
|
||||
RefreshCwIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -59,6 +61,13 @@
|
||||
let removeIdx: number = 0;
|
||||
let removing = false;
|
||||
|
||||
// 全量更新对话框状态
|
||||
let showFullSyncDialog = false;
|
||||
let fullSyncSource: VideoSourceDetail | null = null;
|
||||
let fullSyncType = '';
|
||||
let fullSyncDeleteLocal = false;
|
||||
let fullSyncing = false;
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = {
|
||||
path: '',
|
||||
@@ -71,6 +80,8 @@
|
||||
let favoriteForm = { fid: '' };
|
||||
let collectionForm = { sid: '', mid: '', collection_type: '2' }; // 默认为合集
|
||||
let submissionForm = { upper_id: '' };
|
||||
let importingSubmissions = false;
|
||||
let submissionImportInput: HTMLInputElement | null = null;
|
||||
let selectedIds: Record<string, number[]> = {
|
||||
favorites: [],
|
||||
collections: [],
|
||||
@@ -128,6 +139,44 @@
|
||||
showRemoveDialog = true;
|
||||
}
|
||||
|
||||
function openFullSyncDialog(type: string, source: VideoSourceDetail) {
|
||||
fullSyncSource = source;
|
||||
fullSyncType = type;
|
||||
fullSyncDeleteLocal = false;
|
||||
showFullSyncDialog = true;
|
||||
}
|
||||
|
||||
async function fullSyncVideoSource() {
|
||||
if (!fullSyncSource) return;
|
||||
fullSyncing = true;
|
||||
try {
|
||||
let response = await api.fullSyncVideoSource(fullSyncType, fullSyncSource.id, {
|
||||
delete_local: fullSyncDeleteLocal
|
||||
});
|
||||
if (response && response.data) {
|
||||
showFullSyncDialog = false;
|
||||
toast.success('全量更新成功', {
|
||||
description: `已移除 ${response.data.removed_count} 个不存在的视频`
|
||||
});
|
||||
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||
toast.warning('部分本地文件夹删除失败', {
|
||||
description: response.data.warnings.join('\n'),
|
||||
duration: 10000,
|
||||
descriptionClass: 'whitespace-pre-line'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error('全量更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('全量更新失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
fullSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
async function saveEdit() {
|
||||
if (!editingSource) return;
|
||||
@@ -383,6 +432,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
function parseUidLines(content: string): number[] {
|
||||
return [...new Set(content.split(/\r?\n/).map((line) => line.trim()).filter((line) => /^\d+$/.test(line)))].map(
|
||||
(uid) => parseInt(uid)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleImportSubmissionUids(file: File | null) {
|
||||
if (!file) return;
|
||||
importingSubmissions = true;
|
||||
try {
|
||||
const content = await file.text();
|
||||
const uids = parseUidLines(content);
|
||||
if (uids.length === 0) {
|
||||
toast.error('未在文件中识别到有效 UID');
|
||||
return;
|
||||
}
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
await api.insertSubmission({ upper_id: uid });
|
||||
success += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.warning('UID 文件导入完成(部分失败)', {
|
||||
description: `成功 ${success} 条,失败 ${failed} 条`
|
||||
});
|
||||
} else {
|
||||
toast.success(`UID 文件导入成功,共 ${success} 条`);
|
||||
}
|
||||
loadVideoSources();
|
||||
} catch (error) {
|
||||
toast.error('导入 UID 文件失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
importingSubmissions = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSubmissionUidImport() {
|
||||
submissionImportInput?.click();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMount(() => {
|
||||
setBreadcrumb([{ label: '视频源' }]);
|
||||
@@ -451,10 +547,34 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
|
||||
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
手动添加
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if key === 'submissions'}
|
||||
<input
|
||||
bind:this={submissionImportInput}
|
||||
type="file"
|
||||
accept=".txt,text/plain"
|
||||
class="hidden"
|
||||
disabled={importingSubmissions}
|
||||
onchange={(event) => {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null;
|
||||
handleImportSubmissionUids(file);
|
||||
(event.currentTarget as HTMLInputElement).value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={openSubmissionUidImport}
|
||||
disabled={importingSubmissions}
|
||||
>
|
||||
{importingSubmissions ? '导入中...' : '导入 UID.txt'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
手动添加
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sources.length > 0}
|
||||
@@ -581,6 +701,21 @@
|
||||
<p class="text-xs">重新评估规则</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root disableHoverableContent={true}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openFullSyncDialog(key, source)}
|
||||
class="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCwIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-xs">全量更新视频</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{#if activeTab !== 'watch_later'}
|
||||
<Tooltip.Root disableHoverableContent={true}>
|
||||
<Tooltip.Trigger>
|
||||
@@ -620,10 +755,21 @@
|
||||
{/if}
|
||||
</p>
|
||||
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
|
||||
<Button onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
手动添加
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if key === 'submissions'}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={openSubmissionUidImport}
|
||||
disabled={importingSubmissions}
|
||||
>
|
||||
{importingSubmissions ? '导入中...' : '导入 UID.txt'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
手动添加
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -758,6 +904,48 @@
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showFullSyncDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>全量更新视频</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
确定要全量更新视频源 <strong>"{fullSyncSource?.name}"</strong> 吗?<br />
|
||||
该操作会获取该视频源下所有当前存在的视频,移除数据库中已不存在于该源的视频及其分页数据,<span
|
||||
class="text-destructive font-medium">无法撤销</span
|
||||
>。<br /><br />
|
||||
请谨慎对“稍后再看”执行全量更新操作,因为其视频源本身就具有较强的时效性,执行全量更新可能导致大量视频被移除。<br
|
||||
/>
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<div class="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<div class="mb-2 flex items-center space-x-2">
|
||||
<Checkbox id="delete-local" bind:checked={fullSyncDeleteLocal} />
|
||||
<Label for="delete-local" class="text-sm font-medium text-orange-700">
|
||||
⚠️ 同时删除本地视频文件夹
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-orange-700">
|
||||
删除多余视频时同时删除视频对应的本地文件夹,请谨慎勾选
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
disabled={fullSyncing}
|
||||
onclick={() => {
|
||||
showFullSyncDialog = false;
|
||||
}}>取消</AlertDialog.Cancel
|
||||
>
|
||||
<AlertDialog.Action
|
||||
onclick={fullSyncVideoSource}
|
||||
disabled={fullSyncing}
|
||||
class="bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
{fullSyncing ? '全量更新中' : '确认全量更新'}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- 添加对话框 -->
|
||||
<Dialog.Root bind:open={showAddDialog}>
|
||||
<Dialog.Content>
|
||||
@@ -839,6 +1027,24 @@
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="uid-file" class="text-sm font-medium">导入 UID.txt</Label>
|
||||
<Input
|
||||
id="uid-file"
|
||||
type="file"
|
||||
accept=".txt,text/plain"
|
||||
class="mt-1"
|
||||
disabled={importingSubmissions}
|
||||
onchange={(event) => {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null;
|
||||
handleImportSubmissionUids(file);
|
||||
(event.currentTarget as HTMLInputElement).value = '';
|
||||
}}
|
||||
/>
|
||||
<p class="text-muted-foreground mt-1 text-xs">
|
||||
每行一个 UID,例如:2122895527
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-4 space-y-1.5">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
let statusEditorLoading = false;
|
||||
|
||||
async function loadVideoDetail() {
|
||||
const videoId = parseInt($page.params.id);
|
||||
const videoId = parseInt($page.params.id!);
|
||||
if (isNaN(videoId)) {
|
||||
error = '无效的视频 ID';
|
||||
toast.error('无效的视频 ID');
|
||||
@@ -212,14 +212,7 @@
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<VideoCard
|
||||
video={{
|
||||
id: videoData.video.id,
|
||||
bvid: videoData.video.bvid,
|
||||
name: videoData.video.name,
|
||||
upper_name: videoData.video.upper_name,
|
||||
download_status: videoData.video.download_status,
|
||||
should_download: videoData.video.should_download
|
||||
}}
|
||||
video={videoData.video}
|
||||
mode="detail"
|
||||
showActions={false}
|
||||
taskNames={['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载']}
|
||||
@@ -254,7 +247,8 @@
|
||||
name: `P${pageInfo.pid}: ${pageInfo.name}`,
|
||||
upper_name: '',
|
||||
download_status: pageInfo.download_status,
|
||||
should_download: videoData.video.should_download
|
||||
should_download: videoData.video.should_download,
|
||||
valid: videoData.video.valid
|
||||
}}
|
||||
mode="page"
|
||||
showActions={false}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
VideoSourcesResponse,
|
||||
ApiError,
|
||||
VideoSource,
|
||||
UpdateFilteredVideoStatusRequest
|
||||
UpdateFilteredVideoStatusRequest,
|
||||
VideoInfo
|
||||
} from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -26,16 +27,20 @@
|
||||
setCurrentPage,
|
||||
setQuery,
|
||||
setStatusFilter,
|
||||
setValidationFilter,
|
||||
ToQuery,
|
||||
ToFilterParams,
|
||||
hasActiveFilters,
|
||||
type StatusFilterValue
|
||||
type StatusFilterValue,
|
||||
type ValidationFilterValue
|
||||
} from '$lib/stores/filter';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte';
|
||||
import SearchBar from '$lib/components/search-bar.svelte';
|
||||
import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte';
|
||||
import StatusFilter from '$lib/components/status-filter.svelte';
|
||||
import ValidationFilter from '$lib/components/validation-filter.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -53,7 +58,9 @@
|
||||
let updatingAll = false;
|
||||
|
||||
let videoSources: VideoSourcesResponse | null = null;
|
||||
let videoSourcesLoaded = false;
|
||||
let filters: Record<string, Filter> | null = null;
|
||||
let sourceMap: SvelteMap<string, { type: string; name: string }> = new SvelteMap();
|
||||
|
||||
function getApiParams(searchParams: URLSearchParams) {
|
||||
let videoSource = null;
|
||||
@@ -71,10 +78,18 @@
|
||||
statusFilterParam === 'waiting'
|
||||
? statusFilterParam
|
||||
: null;
|
||||
const validationFilterParam = searchParams.get('validation_filter');
|
||||
const validationFilter: ValidationFilterValue =
|
||||
validationFilterParam === 'skipped' ||
|
||||
validationFilterParam === 'invalid' ||
|
||||
validationFilterParam === 'normal'
|
||||
? validationFilterParam
|
||||
: null;
|
||||
return {
|
||||
query: searchParams.get('query') || '',
|
||||
videoSource,
|
||||
statusFilter,
|
||||
validationFilter,
|
||||
pageNum: parseInt(searchParams.get('page') || '0')
|
||||
};
|
||||
}
|
||||
@@ -83,7 +98,8 @@
|
||||
query: string,
|
||||
pageNum: number = 0,
|
||||
filter?: { type: string; id: string } | null,
|
||||
statusFilter: StatusFilterValue | null = null
|
||||
statusFilter: StatusFilterValue | null = null,
|
||||
validationFilter: ValidationFilterValue | null = null
|
||||
) {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -100,6 +116,9 @@
|
||||
if (statusFilter) {
|
||||
params.status_filter = statusFilter;
|
||||
}
|
||||
if (validationFilter) {
|
||||
params.validation_filter = validationFilter;
|
||||
}
|
||||
const result = await api.getVideos(params);
|
||||
videosData = result.data;
|
||||
} catch (error) {
|
||||
@@ -118,9 +137,10 @@
|
||||
}
|
||||
|
||||
async function handleSearchParamsChange(searchParams: URLSearchParams) {
|
||||
const { query, videoSource, pageNum, statusFilter } = getApiParams(searchParams);
|
||||
setAll(query, pageNum, videoSource, statusFilter);
|
||||
loadVideos(query, pageNum, videoSource, statusFilter);
|
||||
const { query, videoSource, pageNum, statusFilter, validationFilter } =
|
||||
getApiParams(searchParams);
|
||||
setAll(query, pageNum, videoSource, statusFilter, validationFilter);
|
||||
loadVideos(query, pageNum, videoSource, statusFilter, validationFilter);
|
||||
}
|
||||
|
||||
async function handleResetVideo(id: number, forceReset: boolean) {
|
||||
@@ -131,8 +151,8 @@
|
||||
toast.success('重置成功', {
|
||||
description: `视频「${data.video.name}」已重置`
|
||||
});
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} else {
|
||||
toast.info('重置无效', {
|
||||
description: `视频「${data.video.name}」没有失败的状态,无需重置`
|
||||
@@ -159,8 +179,8 @@
|
||||
description: `视频「${data.video.name}」已清空重置`
|
||||
});
|
||||
}
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} catch (error) {
|
||||
console.error('清空重置失败:', error);
|
||||
toast.error('清空重置失败', {
|
||||
@@ -183,8 +203,8 @@
|
||||
toast.success('重置成功', {
|
||||
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
|
||||
});
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} else {
|
||||
toast.info('没有需要重置的视频');
|
||||
}
|
||||
@@ -214,8 +234,8 @@
|
||||
toast.success('更新成功', {
|
||||
description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页`
|
||||
});
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} else {
|
||||
toast.info('没有视频被更新');
|
||||
}
|
||||
@@ -230,6 +250,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoSource(video: VideoInfo): { type: string; name: string } | null {
|
||||
if (video.collection_id != null) {
|
||||
return sourceMap.get(`collection:${video.collection_id}`) || null;
|
||||
}
|
||||
if (video.favorite_id != null) {
|
||||
return sourceMap.get(`favorite:${video.favorite_id}`) || null;
|
||||
}
|
||||
if (video.submission_id != null) {
|
||||
return sourceMap.get(`submission:${video.submission_id}`) || null;
|
||||
}
|
||||
if (video.watch_later_id != null) {
|
||||
return sourceMap.get(`watch_later:${video.watch_later_id}`) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取筛选条件的显示数组
|
||||
function getFilterDescriptionParts(): string[] {
|
||||
const state = $appStateStore;
|
||||
@@ -257,10 +293,18 @@
|
||||
};
|
||||
parts.push(`状态:${statusLabels[state.statusFilter]}`);
|
||||
}
|
||||
if (state.validationFilter) {
|
||||
const validationLabels = {
|
||||
skipped: '跳过',
|
||||
invalid: '失效',
|
||||
normal: '有效'
|
||||
};
|
||||
parts.push(`有效性:${validationLabels[state.validationFilter]}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
$: if ($page.url.search !== lastSearch) {
|
||||
$: if (videoSourcesLoaded && $page.url.search !== lastSearch) {
|
||||
lastSearch = $page.url.search;
|
||||
handleSearchParamsChange($page.url.searchParams);
|
||||
}
|
||||
@@ -280,8 +324,19 @@
|
||||
}
|
||||
])
|
||||
);
|
||||
sourceMap.clear();
|
||||
for (const source of Object.values(VIDEO_SOURCES)) {
|
||||
const sourceList = videoSources[source.type as keyof VideoSourcesResponse] as VideoSource[];
|
||||
for (const item of sourceList) {
|
||||
sourceMap.set(`${source.type}:${item.id}`, {
|
||||
type: source.type,
|
||||
name: item.name
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filters = null;
|
||||
sourceMap.clear();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -291,6 +346,7 @@
|
||||
}
|
||||
]);
|
||||
videoSources = (await api.getVideoSources()).data;
|
||||
videoSourcesLoaded = true;
|
||||
});
|
||||
|
||||
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
|
||||
@@ -313,6 +369,22 @@
|
||||
}}
|
||||
></SearchBar>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-muted-foreground text-xs">有效性:</span>
|
||||
<ValidationFilter
|
||||
value={$appStateStore.validationFilter}
|
||||
onSelect={(value) => {
|
||||
setValidationFilter(value);
|
||||
resetCurrentPage();
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setValidationFilter(null);
|
||||
resetCurrentPage();
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<!-- 状态筛选 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-muted-foreground text-xs">状态:</span>
|
||||
@@ -337,11 +409,11 @@
|
||||
{filters}
|
||||
selectedLabel={$appStateStore.videoSource}
|
||||
onSelect={(type, id) => {
|
||||
setAll('', 0, { type, id }, $appStateStore.statusFilter);
|
||||
setAll('', 0, { type, id }, $appStateStore.statusFilter, $appStateStore.validationFilter);
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setAll('', 0, null, $appStateStore.statusFilter);
|
||||
setAll('', 0, null, $appStateStore.statusFilter, $appStateStore.validationFilter);
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
/>
|
||||
@@ -395,6 +467,7 @@
|
||||
{#each videosData.videos as video (video.id)}
|
||||
<VideoCard
|
||||
{video}
|
||||
source={getVideoSource(video)}
|
||||
onReset={async (forceReset: boolean) => {
|
||||
await handleResetVideo(video.id, forceReset);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user