Compare commits

...

23 Commits

Author SHA1 Message Date
f44ec797b8 uu
Some checks failed
Build Main Binary / build-binary (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled
2026-03-20 02:03:02 +08:00
5f8d4450cc chore: stabilize local build environment
Some checks failed
Build Main Binary / build-binary (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled
2026-03-20 01:52:42 +08:00
315f00d654 Merge remote-tracking branch 'upstream/main'
Some checks failed
Build Main Binary / build-binary (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled
Build Main Docs / Build documentation (push) Has been cancelled
2026-03-19 21:55:06 +08:00
ᴀᴍᴛᴏᴀᴇʀ
09604fd283 fix: 清空重置、全量刷新时跳过空路径的删除,微调前端样式 (#679) 2026-03-17 00:35:19 +08:00
ᴀᴍᴛᴏᴀᴇʀ
29f36238e3 feat: 支持手动触发全量更新,清除本地多余的视频条目与文件 (#678) 2026-03-16 02:50:55 +08:00
ᴀᴍᴛᴏᴀᴇʀ
980779d5c5 fix: 视频源第一页视频为空不再视为错误 (#677) 2026-03-15 22:38:01 +08:00
ᴀᴍᴛᴏᴀᴇʀ
dd96a32b35 feat: 在视频页显示视频属于哪个视频源 (#676) 2026-03-15 21:53:15 +08:00
ᴀᴍᴛᴏᴀᴇʀ
d39cce043c feat: 支持筛选视频的有效性 (#673) 2026-03-15 16:44:48 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e97fa73542 feat: 修改通知器,支持提示成功任务数量 (#672) 2026-03-15 03:31:41 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2bd660efc9 feat: 添加开关,允许尝试下载未充电的视频 (#666) 2026-02-28 22:55:01 +08:00
amtoaer
fe13029e84 chore: 发布 bili-sync 2.10.4 2026-02-25 11:11:50 +08:00
ᴀᴍᴛᴏᴀᴇʀ
bdf4ab58f2 docs: 更新截图和文档链接,修改前端域名 (#659) 2026-02-25 10:51:53 +08:00
ᴀᴍᴛᴏᴀᴇʀ
681617cf02 fix: 引入 dunce 库规范化路径,移除手写的规范化逻辑 (#658) 2026-02-24 23:24:51 +08:00
ᴀᴍᴛᴏᴀᴇʀ
b6c5b547a3 fix: 处理 windows 下的文件夹路径,确保不以空格结尾 (#657) 2026-02-24 22:04:22 +08:00
ᴀᴍᴛᴏᴀᴇʀ
8aba906904 fix: 尝试修复浏览器从休眠中恢复时的图表乱序问题 (#656) 2026-02-24 01:54:04 +08:00
ᴀᴍᴛᴏᴀᴇʀ
3e465d9b71 fix: 兼容 flac/audio 字段存在但为 null 的情况 (#655) 2026-02-23 12:34:12 +08:00
ᴀᴍᴛᴏᴀᴇʀ
1930a57edd feat: 添加防抖,优化日志页的自动滚动体验 (#654) 2026-02-21 23:37:30 +08:00
ᴀᴍᴛᴏᴀᴇʀ
bb1576a0df perf: 使用 itertools 提供的 join,避免 collect 到 Vec 的额外分配 (#652) 2026-02-19 19:04:10 +08:00
ᴀᴍᴛᴏᴀᴇʀ
5350d3491b chore: 升级 rust 到 1.93.1,移除 ws 中的一些无用变量 (#650) 2026-02-15 16:31:31 +08:00
ᴀᴍᴛᴏᴀᴇʀ
e130f14c13 fix: 修复 detail 页面状态显示错误 (#649) 2026-02-15 16:28:41 +08:00
ᴀᴍᴛᴏᴀᴇʀ
980f74a242 fix: 修复某些收藏夹视频的 valid 判断 (#648) 2026-02-15 15:09:22 +08:00
fc63c64a97 update workflow
Some checks failed
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled
Build Main Binary / build-binary (push) Has been cancelled
2026-02-10 20:41:21 +08:00
47909dd6bf feat(video-sources): add visible UID import entry for submissions
fix(build): rerun rust build when web/build changes
2026-02-10 18:04:28 +08:00
47 changed files with 1014 additions and 197 deletions

8
.cargo/config.toml Normal file
View 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

View File

@@ -12,15 +12,15 @@ jobs:
working-directory: web
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: bf1942/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
uses: bf1942/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: actions/cache@v4
uses: bf1942/cache@v4
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: bf1942/upload-artifact@v4
with:
name: web-build
path: web/build
@@ -67,22 +67,22 @@ jobs:
name: bili-sync-rs-Windows-x86_64.zip
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: bf1942/checkout@v4
with:
fetch-depth: 0
- name: Download Web Build Artifact
uses: actions/download-artifact@v4
uses: bf1942/download-artifact@v4
with:
name: web-build
path: web/build
- name: Read Toolchain Version
uses: SebRollen/toml-action@v1.2.0
uses: bf1942/toml-action@v1.2.0
id: read_rust_toolchain
with:
file: rust-toolchain.toml
field: toolchain.channel
- name: Build binary
uses: houseabsolute/actions-rust-cross@v1
uses: bf1942/actions-rust-cross@v1
with:
command: build
target: ${{ matrix.platform.target }}
@@ -99,7 +99,7 @@ jobs:
tar czvf ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }}
fi
- name: Upload release artifact
uses: actions/upload-artifact@v4
uses: bf1942/upload-artifact@v4
with:
name: bili-sync-rs-${{ matrix.platform.release_for }}
path: |

View File

@@ -16,15 +16,15 @@ jobs:
working-directory: docs
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: bf1942/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
uses: bf1942/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: actions/cache@v4
uses: bf1942/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
@@ -33,7 +33,7 @@ jobs:
- name: Build documentation
run: bun run docs:build
- name: Deploy Github Pages
uses: peaceiris/actions-gh-pages@v4
uses: bf1942/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/.vitepress/dist

View File

@@ -29,7 +29,7 @@ jobs:
- run: rustup install && rustup component add rustfmt --toolchain nightly
- name: Cache dependencies
uses: swatinem/rust-cache@v2
uses: bf1942/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -50,15 +50,15 @@ jobs:
working-directory: web
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: bf1942/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
uses: bf1942/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: actions/cache@v4
uses: bf1942/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}

View File

@@ -16,13 +16,13 @@ jobs:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: bf1942/checkout@v4
- name: Download release artifact
uses: actions/download-artifact@v4
uses: bf1942/download-artifact@v4
with:
merge-multiple: true
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
uses: bf1942/action-gh-release@v2
with:
files: bili-sync-rs*
tag_name: ${{ github.ref_name }}
@@ -35,30 +35,30 @@ jobs:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: bf1942/checkout@v4
- name: Download release artifact
uses: actions/download-artifact@v4
uses: bf1942/download-artifact@v4
with:
merge-multiple: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
uses: bf1942/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync-rs
tags: |
type=raw,value=latest
type=raw,value=${{ github.ref_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: bf1942/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: bf1942/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
uses: bf1942/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: bf1942/build-push-action@v5
with:
context: .
file: Dockerfile
@@ -71,7 +71,7 @@ jobs:
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
- name: Update DockerHub description
uses: peter-evans/dockerhub-description@v3
uses: bf1942/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

7
Cargo.lock generated
View File

@@ -370,6 +370,7 @@ dependencies = [
"croner",
"dashmap",
"dirs",
"dunce",
"enum_dispatch",
"float-ord",
"futures",
@@ -1087,6 +1088,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"

View File

@@ -30,6 +30,7 @@ croner = "3.0.1"
dashmap = "6.1.0"
derivative = "2.2.0"
dirs = "6.0.0"
dunce = "1.0.5"
enum_dispatch = "0.3.13"
float-ord = "0.3.2"
futures = "0.3.31"

View File

@@ -1,16 +1,16 @@
![bili-sync](https://socialify.git.ci/amtoaer/bili-sync/image?description=1&font=KoHo&issues=1&language=1&logo=https%3A%2F%2Fs2.loli.net%2F2023%2F12%2F02%2F9EwT2yInOu1d3zm.png&name=1&owner=1&pattern=Signal&pulls=1&stargazers=1&theme=Light)
## 简介
## 简介
> [!NOTE]
> [点击此处](https://bili-sync.allwens.work/)查看文档
> [查看文档](https://bili-sync.amto.cc/) [加入 Telegram 交流群](https://t.me/+nuYrt8q6uEo4MWI1)
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具,由 Rust & Tokio 驱动。
## 效果演示
### 管理页
![管理页](/assets/webui.webp)
![管理页](./assets/webui.webp)
### 媒体库概览
![媒体库概览](./assets/overview.webp)
### 媒体库详情

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

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

View File

@@ -1,3 +1,4 @@
fn main() {
println!("cargo:rerun-if-changed=../../web/build");
built::write_built_file().expect("Failed to acquire build-time information");
}

View File

@@ -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(());
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,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())

View File

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

View File

@@ -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")?;

View File

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

View 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
}
}

View 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,
},
}
}
}

View File

@@ -1,5 +1,10 @@
mod info;
mod message;
use anyhow::Result;
use futures::future;
pub use info::DownloadNotifyInfo;
pub use message::Message;
use reqwest::header;
use serde::{Deserialize, Serialize};
@@ -33,23 +38,38 @@ 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(&params).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(&params).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(&params).send().await?;
}
}
Notifier::Webhook {
url,
@@ -57,15 +77,10 @@ impl Notifier {
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)

View File

@@ -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,
@@ -153,7 +159,9 @@ 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),
@@ -174,6 +182,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 {

View File

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

View File

@@ -17,6 +17,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 +25,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 +51,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(())
}
@@ -150,7 +156,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()));
@@ -176,7 +182,7 @@ 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());
@@ -207,14 +213,16 @@ 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(
@@ -236,6 +244,8 @@ pub async fn download_video_pages(
.path_safe_render("video", &video_format_args(&video_model, &cx.config.time_format))?,
)
};
fs::create_dir_all(&base_path).await?;
let base_path = dunce::canonicalize(base_path).context("canonicalize video path failed")?;
let upper_id = video_model.upper_id.to_string();
let base_upper_path = cx
.config
@@ -416,6 +426,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)),

BIN
dist/bili-sync-rs-windows-x64.zip vendored Normal file

Binary file not shown.

BIN
dist/bili-sync-rs.exe vendored Normal file

Binary file not shown.

View File

@@ -21,7 +21,7 @@ export default defineConfig({
nav: [
{ text: "主页", link: "/" },
{
text: "v2.10.3",
text: "v2.10.4",
items: [
{
text: "程序更新",

View File

@@ -1 +1 @@
bili-sync.allwens.work
bili-sync.amto.cc

View File

@@ -1,3 +1,3 @@
[toolchain]
channel = "1.93.0"
channel = "stable"
components = ["clippy"]

View File

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

View File

@@ -148,7 +148,7 @@
? 'opacity-60'
: ''}"
>
<CardHeader class="flex-shrink-0">
<CardHeader class="shrink-0">
<div class="flex items-start gap-3">
<!-- 头像或图标 - 简化设计 -->
<div

View 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>

View File

@@ -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'}

View File

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

View File

@@ -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;
}
@@ -319,6 +334,7 @@ export interface Config {
concurrent_limit: ConcurrentLimit;
time_format: string;
cdn_sorting: boolean;
try_upower_anyway: boolean;
version: number;
}

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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