Compare commits

..

36 Commits

Author SHA1 Message Date
amtoaer
eadb1565c3 后端 format 2026-03-31 01:40:00 +08:00
amtoaer
c456cea5bc format + lint 2026-03-31 01:37:43 +08:00
amtoaer
2e49d31b15 feat: 支持自定义 webhook 请求的 headers 2026-03-31 01:13:12 +08:00
amtoaer
55dde84f96 chore: 发布 bili-sync 2.11.0 2026-03-26 20:39:41 +08:00
ᴀᴍᴛᴏᴀᴇʀ
eea233e576 ci: 修复 ci 在 windows 上运行失败的错误 (#690) 2026-03-25 16:57:58 +08:00
ᴀᴍᴛᴏᴀᴇʀ
72bf2b6a4d ci: 更新 workflows 中使用的 action,避免 node 版本低于 24 的 warning (#689) 2026-03-25 16:50:47 +08:00
wanlala
47ce8f148b 添加 armv7l 版本构建 (#688)
* Add workflow_dispatch trigger for build binary

* Ready for pull request from build-binary.yaml

* Add support for armv7l architecture in Dockerfile

* Add support for linux/armv7l platform in release build

* Update build configuration for Linux-armv7 target

* Change armv7l to armv7 in release build workflow

* Update ARM platform tarball extraction in Dockerfile

* 修正 platform

---------

Co-authored-by: amtoaer <amtoaer@gmail.com>
2026-03-25 14:29:02 +08:00
ᴀᴍᴛᴏᴀᴇʀ
1c68f13c54 perf: 避免一些常见场景的字符串拷贝,略微提升性能 (#687) 2026-03-25 12:21:11 +08:00
ᴀᴍᴛᴏᴀᴇʀ
2a4c1313b0 chore: 升级 rust 到 1.94.0 (#685) 2026-03-24 23:08:31 +08:00
amtoaer
ec44798523 chore: 微调 placeholder 的提示文本 2026-03-24 22:59:54 +08:00
ᴀᴍᴛᴏᴀᴇʀ
8cb59d6b2a feat: 过滤规则引入视频总长度和联合投稿 (#684) 2026-03-24 22:58:20 +08:00
ᴀᴍᴛᴏᴀᴇʀ
3a2df55314 perf: 移除不必要的 Vec,略微提升性能 (#682) 2026-03-24 17:15:11 +08:00
ᴀᴍᴛᴏᴀᴇʀ
04448c6d8f feat: 支持解析联合投稿 (#681) 2026-03-24 16:25:42 +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
ᴀᴍᴛᴏᴀᴇʀ
8c04dc6564 chore: 前端自动排序 imports,合并 icon 导入并替换掉 deprecated (#642) 2026-02-07 09:27:20 +08:00
ᴀᴍᴛᴏᴀᴇʀ
c49ec81d51 fix: 修复一些前端的小问题 (#641) 2026-02-06 14:12:18 +08:00
ᴀᴍᴛᴏᴀᴇʀ
580a66eb17 feat: 扩大风控检测,当 http 返回 403 或 412 时认为是风控 (#640) 2026-02-05 17:13:25 +08:00
ᴀᴍᴛᴏᴀᴇʀ
295d4105aa feat: 支持自定义 ffmpeg 路径 (#639) 2026-02-05 15:58:33 +08:00
ApliNi
151251719b feat: 添加配置目录环境变量 (#632)
* feat: 添加配置目录环境变量

* feat: 添加配置目录命令行参数

* feat: 添加配置目录短参数

* refactor: 调整一下写法

---------

Co-authored-by: amtoaer <amtoaer@gmail.com>
2026-02-03 13:42:16 +08:00
96 changed files with 1734 additions and 689 deletions

View File

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

View File

@@ -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 的最新文档变更:

View File

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

View File

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

@@ -353,7 +353,7 @@ dependencies = [
[[package]]
name = "bili_sync"
version = "2.10.3"
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.3"
version = "2.11.0"
dependencies = [
"derivative",
"either",
"regex",
"sea-orm",
"serde",
@@ -423,7 +425,7 @@ dependencies = [
[[package]]
name = "bili_sync_migration"
version = "2.10.3"
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"

View File

@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
resolver = "2"
[workspace.package]
version = "2.10.3"
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"

View File

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

View File

@@ -3,14 +3,14 @@
## 简介
> [!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,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>,
@@ -139,3 +150,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};
@@ -14,11 +18,12 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTr
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};
@@ -38,6 +43,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))
@@ -347,11 +353,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?;
}
@@ -359,6 +361,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 {
@@ -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

View File

@@ -31,7 +31,7 @@ impl Client {
);
headers.insert(
header::REFERER,
header::HeaderValue::from_static("https://www.bilibili.com"),
header::HeaderValue::from_static("https://www.bilibili.com/"),
);
Self(
reqwest::Client::builder()

View File

@@ -7,7 +7,7 @@ use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
use crate::bilibili::{BiliClient, Credential, Validate, VideoInfo};
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate, VideoInfo};
#[derive(PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
pub enum CollectionType {
@@ -136,7 +136,7 @@ impl<'a> Collection<'a> {
.query(&[("series_id", self.collection.sid.as_str())])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<Value>()
.await?
.validate()
@@ -176,7 +176,12 @@ impl<'a> Collection<'a> {
("page_size", "30"),
]),
};
req.send().await?.error_for_status()?.json::<Value>().await?.validate()
req.send()
.await?
.error_for_status_ext()?
.json::<Value>()
.await?
.validate()
}
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
@@ -191,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

@@ -9,7 +9,7 @@ use rsa::sha2::Sha256;
use rsa::{Oaep, RsaPublicKey};
use serde::{Deserialize, Serialize};
use crate::bilibili::{BiliError, Client, Validate};
use crate::bilibili::{BiliError, Client, ErrorForStatusExt, Validate};
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38,
@@ -78,7 +78,7 @@ impl Credential {
.request(Method::GET, "https://api.bilibili.com/x/web-interface/nav", Some(self))
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -94,7 +94,7 @@ impl Credential {
)
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -111,7 +111,7 @@ impl Credential {
.query(&[("qrcode_key", qrcode_key)])
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
let headers = std::mem::take(resp.headers_mut());
let json = resp.json::<serde_json::Value>().await?.validate()?;
let code = json["data"]["code"].as_i64().context("missing 'code' field in data")?;
@@ -147,7 +147,7 @@ impl Credential {
.request(Method::GET, "https://api.bilibili.com/x/web-frontend/getbuvid", None)
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -167,7 +167,7 @@ impl Credential {
)
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -220,7 +220,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
.header(header::COOKIE, "Domain=.bilibili.com")
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
regex_find(r#"<div id="1-name">(.+?)</div>"#, res.text().await?.as_str())
}
@@ -241,7 +241,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
])
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
let headers = std::mem::take(resp.headers_mut());
let json = resp.json::<serde_json::Value>().await?.validate()?;
let mut credential = Self::extract(headers, json)?;
@@ -263,7 +263,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;

View File

@@ -5,7 +5,7 @@ use futures::Stream;
use reqwest::Method;
use serde_json::Value;
use crate::bilibili::{BiliClient, Credential, MIXIN_KEY, Validate, VideoInfo, WbiSign};
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
pub struct Dynamic<'a> {
client: &'a BiliClient,
@@ -38,7 +38,7 @@ impl<'a> Dynamic<'a> {
.wbi_sign(MIXIN_KEY.load().as_deref())?
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()
@@ -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

@@ -8,12 +8,17 @@ pub enum BiliError {
ErrorResponse(i64, String),
#[error("risk control triggered by server, full response: {0}")]
RiskControlOccurred(String),
#[error("invalid HTTP response code {0}, reason: {1}")]
InvalidStatusCode(u16, &'static str),
#[error("no video streams available (may indicate risk control)")]
VideoStreamsEmpty,
}
impl BiliError {
pub fn is_risk_control_related(&self) -> bool {
matches!(self, BiliError::RiskControlOccurred(_) | BiliError::VideoStreamsEmpty)
matches!(
self,
BiliError::RiskControlOccurred(_) | BiliError::VideoStreamsEmpty | BiliError::InvalidStatusCode(_, _)
)
}
}

View File

@@ -3,7 +3,7 @@ use async_stream::try_stream;
use futures::Stream;
use serde_json::Value;
use crate::bilibili::{BiliClient, Credential, Validate, VideoInfo};
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate, VideoInfo};
pub struct FavoriteList<'a> {
client: &'a BiliClient,
fid: String,
@@ -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 {
@@ -43,7 +37,7 @@ impl<'a> FavoriteList<'a> {
.query(&[("media_id", &self.fid)])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -68,7 +62,7 @@ impl<'a> FavoriteList<'a> {
])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()
@@ -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())

View File

@@ -1,7 +1,7 @@
use anyhow::{Result, ensure};
use reqwest::Method;
use crate::bilibili::{BiliClient, Credential, Validate};
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate};
pub struct Me<'a> {
client: &'a BiliClient,
@@ -29,7 +29,7 @@ impl<'a> Me<'a> {
.query(&[("up_mid", &self.mid())])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -53,7 +53,7 @@ impl<'a> Me<'a> {
.query(&[("pn", page_num), ("ps", page_size)])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -87,7 +87,7 @@ impl<'a> Me<'a> {
let mut resp = request
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;

View File

@@ -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,10 +14,9 @@ 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;
use reqwest::{RequestBuilder, StatusCode};
pub use submission::Submission;
pub use video::{Dimension, PageInfo, Video};
pub use watch_later::WatchLater;
@@ -47,6 +47,12 @@ pub(crate) trait Validate {
fn validate(self) -> Result<Self::Output>;
}
pub(crate) trait ErrorForStatusExt {
type Output;
fn error_for_status_ext(self) -> Result<Self::Output>;
}
impl Validate for serde_json::Value {
type Output = serde_json::Value;
@@ -62,6 +68,23 @@ impl Validate for serde_json::Value {
}
}
impl ErrorForStatusExt for reqwest::Response {
type Output = reqwest::Response;
fn error_for_status_ext(self) -> Result<Self::Output> {
let status = self.status();
// 412 是由于请求频率过高导致的,确定是风控问题
// 403 目前偶尔出现在下载视频音频流时,由于是偶尔出现且过一段时间消失,暂时也当成风控问题处理
if status == StatusCode::PRECONDITION_FAILED || status == StatusCode::FORBIDDEN {
bail!(BiliError::InvalidStatusCode(
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown")
));
}
Ok(self.error_for_status()?)
}
}
pub(crate) trait WbiSign {
type Output;
@@ -110,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")]
@@ -129,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")]
@@ -147,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")]
@@ -288,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?;
@@ -319,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,
@@ -352,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!();

View File

@@ -1,11 +1,11 @@
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, MIXIN_KEY, Validate, VideoInfo, WbiSign};
use crate::bilibili::{BiliClient, Credential, Dynamic, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
pub struct Submission<'a> {
client: &'a BiliClient,
pub upper_id: String,
@@ -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(
@@ -39,7 +39,7 @@ impl<'a> Submission<'a> {
.query(&[("mid", self.upper_id.as_str())])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -66,7 +66,7 @@ impl<'a> Submission<'a> {
.wbi_sign(MIXIN_KEY.load().as_deref())?
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()
@@ -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

@@ -3,16 +3,17 @@ 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;
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
use crate::bilibili::subtitle::{SubTitle, SubTitleBody, SubTitleInfo, SubTitlesInfo};
use crate::bilibili::{Credential, MIXIN_KEY, Validate, VideoInfo, WbiSign};
use crate::bilibili::{Credential, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
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,
@@ -57,7 +58,7 @@ impl<'a> Video<'a> {
.wbi_sign(MIXIN_KEY.load().as_deref())?
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -77,7 +78,7 @@ impl<'a> Video<'a> {
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -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,
@@ -96,15 +97,15 @@ impl<'a> Video<'a> {
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.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())
}
@@ -132,7 +133,7 @@ impl<'a> Video<'a> {
.wbi_sign(MIXIN_KEY.load().as_deref())?
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
let headers = std::mem::take(res.headers_mut());
let content_type = headers.get("content-type");
ensure!(
@@ -154,7 +155,7 @@ impl<'a> Video<'a> {
)
.await
.query(&[
("bvid", self.bvid.as_str()),
("bvid", self.bvid),
("qn", "127"),
("otype", "json"),
("fnval", "4048"),
@@ -164,7 +165,7 @@ impl<'a> Video<'a> {
.wbi_sign(MIXIN_KEY.load().as_deref())?
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -176,12 +177,12 @@ 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()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -207,7 +208,7 @@ impl<'a> Video<'a> {
.request(Method::GET, format!("https:{}", &info.subtitle_url).as_str(), None)
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?;
let body: SubTitleBody = serde_json::from_value(res["body"].take())?;

View File

@@ -1,9 +1,9 @@
use anyhow::{Context, Result, anyhow};
use anyhow::{Context, Result};
use async_stream::try_stream;
use futures::Stream;
use serde_json::Value;
use crate::bilibili::{BiliClient, Credential, Validate, VideoInfo};
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate, VideoInfo};
pub struct WatchLater<'a> {
client: &'a BiliClient,
credential: &'a Credential,
@@ -24,7 +24,7 @@ impl<'a> WatchLater<'a> {
.await
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()
@@ -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

@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::LazyLock;
use clap::Parser;
@@ -16,6 +17,12 @@ pub struct Args {
#[arg(short, long, env = "DISABLE_CREDENTIAL_REFRESH")]
pub disable_credential_refresh: bool,
#[arg(short, long, env = "BILI_SYNC_CONFIG_DIR")]
pub config_dir: Option<PathBuf>,
#[arg(short, long, env = "BILI_SYNC_FFMPEG_PATH")]
pub ffmpeg_path: Option<String>,
}
mod built_info {

View File

@@ -3,11 +3,13 @@ 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;
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
use crate::config::args::ARGS;
use crate::config::default::{
default_auth_token, default_bind_address, default_collection_path, default_favorite_path, default_submission_path,
default_time_format,
@@ -16,8 +18,12 @@ use crate::config::item::{ConcurrentLimit, NFOTimeType, SkipOption, Trigger};
use crate::notifier::Notifier;
use crate::utils::model::{load_db_config, save_db_config};
pub static CONFIG_DIR: LazyLock<PathBuf> =
LazyLock::new(|| dirs::config_dir().expect("No config path found").join("bili-sync"));
pub static CONFIG_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
ARGS.config_dir
.clone()
.or_else(|| dirs::config_dir().map(|dir| dir.join("bili-sync")))
.expect("No config path found")
});
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct Config {
@@ -44,6 +50,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,
}
@@ -98,13 +106,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(())
}
@@ -131,6 +133,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

@@ -13,8 +13,8 @@ use tokio::process::Command;
use tokio::task::JoinSet;
use tokio_util::io::StreamReader;
use crate::bilibili::Client;
use crate::config::ConcurrentDownloadLimit;
use crate::bilibili::{Client, ErrorForStatusExt};
use crate::config::{ARGS, ConcurrentDownloadLimit};
pub struct Downloader {
client: Client,
@@ -70,7 +70,7 @@ impl Downloader {
self.multi_fetch_internal(audio_urls, true, concurrent_download)
)?;
let final_temp_file = TempFile::new().await?;
let output = Command::new("ffmpeg")
let output = Command::new(ARGS.ffmpeg_path.as_deref().unwrap_or("ffmpeg"))
.args([
"-i",
video_temp_file.file_path().to_string_lossy().as_ref(),
@@ -152,7 +152,7 @@ impl Downloader {
.request(Method::GET, url, None)
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
let expected = resp.header_content_length();
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
let received = tokio::io::copy(&mut stream_reader, file).await?;
@@ -184,7 +184,7 @@ impl Downloader {
.header(header::RANGE, "bytes=0-0")
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
if resp.status() != StatusCode::PARTIAL_CONTENT {
return self.fetch_serial(url, file).await;
}
@@ -196,7 +196,7 @@ impl Downloader {
.request(Method::HEAD, url, None)
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
if resp
.headers()
.get(header::ACCEPT_RANGES)
@@ -234,7 +234,7 @@ impl Downloader {
.header(header::RANGE, &range_header)
.send()
.await?
.error_for_status()?;
.error_for_status_ext()?;
if let Some(content_length) = resp.header_content_length() {
ensure!(
content_length == end - start + 1,
@@ -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

View File

@@ -18,10 +18,12 @@ use std::fmt::Debug;
use std::future::Future;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use bilibili::BiliClient;
use parking_lot::RwLock;
use sea_orm::DatabaseConnection;
use task::{http_server, video_downloader};
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
@@ -33,8 +35,13 @@ use crate::utils::signal::terminate;
#[tokio::main]
async fn main() {
let (connection, log_writer) = init().await;
let bili_client = Arc::new(BiliClient::new());
let (bili_client, connection, log_writer) = match init().await {
Ok(res) => res,
Err(e) => {
error!("初始化失败:{:#}", e);
return;
}
};
let token = CancellationToken::new();
let tracker = TaskTracker::new();
@@ -77,7 +84,7 @@ fn spawn_task(
}
/// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置
async fn init() -> (DatabaseConnection, LogHelper) {
async fn init() -> Result<(Arc<BiliClient>, DatabaseConnection, LogHelper)> {
let (tx, _rx) = tokio::sync::broadcast::channel(30);
let log_history = Arc::new(RwLock::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
let log_writer = LogHelper::new(tx, log_history.clone());
@@ -85,14 +92,26 @@ async fn init() -> (DatabaseConnection, LogHelper) {
init_logger(&ARGS.log_level, Some(log_writer.clone()));
info!("欢迎使用 Bili-Sync当前程序版本{}", config::version());
info!("项目地址https://github.com/amtoaer/bili-sync");
let ffmpeg_path = ARGS.ffmpeg_path.as_deref().unwrap_or("ffmpeg");
let ffmpeg_exists = Command::new(ffmpeg_path)
.arg("-version")
.output()
.await
.map(|output| output.status.success())
.unwrap_or(false);
if !ffmpeg_exists {
bail!("ffmpeg 不存在或无法执行,请确保已正确安装 ffmpeg并且 {ffmpeg_path} 命令可用");
}
let connection = setup_database(&CONFIG_DIR.join("data.sqlite"))
.await
.expect("数据库初始化失败");
.context("数据库初始化失败")?;
info!("数据库初始化完成");
VersionedConfig::init(&connection).await.expect("配置初始化失败");
VersionedConfig::init(&connection).await.context("配置初始化失败")?;
info!("配置初始化完成");
(connection, log_writer)
Ok((Arc::new(BiliClient::new()), connection, log_writer))
}
async fn handle_shutdown(connection: DatabaseConnection, tracker: TaskTracker, token: CancellationToken) {

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

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

View File

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

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

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

View File

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

View File

@@ -6,6 +6,7 @@ publish = { workspace = true }
[dependencies]
derivative = { workspace = true }
either = { workspace = true }
regex = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }

View File

@@ -1,2 +1,3 @@
pub mod rule;
pub mod string_vec;
pub mod upper_vec;

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -1,7 +1,7 @@
# bili-sync 是什么?
> [!TIP]
> 当前最新程序版本为 v2.10.3,文档将始终与最新程序版本保持一致。
> 当前最新程序版本为 v2.11.0,文档将始终与最新程序版本保持一致。
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。

View File

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

View File

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

View File

@@ -3,7 +3,11 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"overrides": [
{
"files": "*.svelte",

View File

@@ -30,6 +30,7 @@
"layerchart": "^2.0.0-next.43",
"mode-watcher": "^1.1.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.1",
@@ -598,6 +599,8 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"prettier-plugin-organize-imports": ["prettier-plugin-organize-imports@4.3.0", "", { "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", "vue-tsc": "^2.1.0 || 3" }, "optionalPeers": ["vue-tsc"] }, "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg=="],
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],

View File

@@ -1,10 +1,10 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'eslint/config';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
@@ -39,6 +39,9 @@ export default defineConfig(
parser: ts.parser,
svelteConfig
}
},
rules: {
'@typescript-eslint/no-deprecated': 'error'
}
}
);

View File

@@ -1,6 +1,6 @@
{
"name": "bili-sync-web",
"version": "2.10.3",
"version": "2.11.0",
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.2",
@@ -23,6 +23,7 @@
"layerchart": "^2.0.0-next.43",
"mode-watcher": "^1.1.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.1",

View File

@@ -1,35 +1,37 @@
import type {
ApiResponse,
VideoSourcesResponse,
VideosRequest,
VideosResponse,
VideoResponse,
ResetVideoResponse,
ClearAndResetVideoResponse,
ResetFilteredVideosResponse,
UpdateVideoStatusRequest,
UpdateVideoStatusResponse,
ApiError,
FavoritesResponse,
ApiResponse,
ClearAndResetVideoResponse,
CollectionsResponse,
UppersResponse,
InsertFavoriteRequest,
InsertCollectionRequest,
InsertSubmissionRequest,
VideoSourcesDetailsResponse,
UpdateVideoSourceRequest,
Config,
DashBoardResponse,
FavoritesResponse,
FullSyncVideoSourceRequest,
FullSyncVideoSourceResponse,
QrcodeGenerateResponse as GenerateQrcodeResponse,
InsertCollectionRequest,
InsertFavoriteRequest,
InsertSubmissionRequest,
Notifier,
QrcodePollResponse as PollQrcodeResponse,
ResetFilteredVideosResponse,
ResetFilteredVideoStatusRequest,
ResetVideoResponse,
ResetVideoStatusRequest,
SysInfo,
TaskStatus,
ResetVideoStatusRequest,
UpdateVideoSourceResponse,
Notifier,
UpdateFilteredVideoStatusRequest,
UpdateFilteredVideoStatusResponse,
ResetFilteredVideoStatusRequest,
QrcodeGenerateResponse as GenerateQrcodeResponse,
QrcodePollResponse as PollQrcodeResponse
UpdateVideoSourceRequest,
UpdateVideoSourceResponse,
UpdateVideoStatusRequest,
UpdateVideoStatusResponse,
UppersResponse,
VideoResponse,
VideoSourcesDetailsResponse,
VideoSourcesResponse,
VideosRequest,
VideosResponse
} from './types';
import { wsManager } from './ws';
@@ -63,6 +65,10 @@ class ApiClient {
}
}
getAuthToken(): string | null {
return this.defaultHeaders['Authorization'] || localStorage.getItem('authToken');
}
// 清除认证 token
clearAuthToken() {
delete this.defaultHeaders['Authorization'];
@@ -249,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 });
}
@@ -323,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(),
@@ -340,6 +356,7 @@ const api = {
apiClient.subscribeToTasks(onMessage),
setAuthToken: (token: string) => apiClient.setAuthToken(token),
getAuthToken: () => apiClient.getAuthToken(),
clearAuthToken: () => apiClient.clearAuthToken()
};

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import DatabaseIcon from '@lucide/svelte/icons/database';
import FileVideoIcon from '@lucide/svelte/icons/file-video';
import BotIcon from '@lucide/svelte/icons/bot';
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
import HeartIcon from '@lucide/svelte/icons/heart';
import FoldersIcon from '@lucide/svelte/icons/folders';
import UserIcon from '@lucide/svelte/icons/user';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import SquareTerminalIcon from '@lucide/svelte/icons/square-terminal';
import PaletteIcon from '@lucide/svelte/icons/palette';
import {
DatabaseIcon,
FilePlayIcon,
BotIcon,
ChartPieIcon,
HeartIcon,
FoldersIcon,
UserIcon,
Settings2Icon,
SquareTerminalIcon,
PaletteIcon
} from '@lucide/svelte/icons';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { mode, toggleMode } from 'mode-watcher';
import type { ComponentProps } from 'svelte';
@@ -45,7 +47,7 @@
items: [
{
title: '视频',
icon: FileVideoIcon,
icon: FilePlayIcon,
href: '/videos'
},
{

View File

@@ -5,8 +5,7 @@
import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type { Credential, ApiError } from '$lib/types';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import { RefreshCw, LoaderCircle } from '@lucide/svelte/icons';
import QRCode from 'qrcode';
/**

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import TrashIcon from '@lucide/svelte/icons/trash';
import { EllipsisIcon, TrashIcon } from '@lucide/svelte/icons';
import { tick } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Command from '$lib/components/ui/command/index.js';

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ChevronsLeftIcon from '@lucide/svelte/icons/chevrons-left';
import ChevronsRightIcon from '@lucide/svelte/icons/chevrons-right';
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon
} from '@lucide/svelte/icons';
export let currentPage: number = 0;
export let totalPages: number = 0;

View File

@@ -5,9 +5,8 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import PlusIcon from '@lucide/svelte/icons/plus';
import MinusIcon from '@lucide/svelte/icons/minus';
import XIcon from '@lucide/svelte/icons/x';
import * 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';
@@ -23,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) => {
@@ -39,6 +40,7 @@
{ value: 'matchesRegex', label: '匹配正则' }
];
case 'pageCount':
case 'sumVideoLength':
return [
{ value: 'equals', label: '等于' },
{ value: 'greaterThan', label: '大于' },
@@ -53,6 +55,8 @@
{ value: 'lessThan', label: '早于' },
{ value: 'between', label: '时间范围' }
];
case 'multiUpper':
return [{ value: 'equals', label: '等于' }];
default:
return [];
}
@@ -82,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);
@@ -95,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,
@@ -113,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 {
@@ -126,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 || ''];
@@ -133,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
};
@@ -189,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;
@@ -292,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>
@@ -330,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) =>
@@ -346,7 +362,8 @@
/>
<Input
type="number"
placeholder="最大值"
placeholder={'最大值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value2 || ''}
oninput={(e) =>
@@ -413,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) =>
@@ -436,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"

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import SearchIcon from '@lucide/svelte/icons/search';
import { SearchIcon } from '@lucide/svelte/icons';
import * as Input from '$lib/components/ui/input/index.js';
export let placeholder: string = '搜索视频..';

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import CheckCircleIcon from '@lucide/svelte/icons/check-circle';
import XCircleIcon from '@lucide/svelte/icons/x-circle';
import ClockIcon from '@lucide/svelte/icons/clock';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import TrashIcon from '@lucide/svelte/icons/trash';
import {
CircleCheckBigIcon,
CircleXIcon,
ClockIcon,
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 StatusFilterValue } from '$lib/stores/filter';
@@ -27,12 +29,12 @@
{
value: 'failed' as const,
label: '仅失败',
icon: XCircleIcon
icon: CircleXIcon
},
{
value: 'succeeded' as const,
label: '仅成功',
icon: CheckCircleIcon
icon: CircleCheckBigIcon
},
{
value: 'waiting' as const,
@@ -73,7 +75,7 @@
{option.label}
</span>
{#if value === option.value}
<CheckCircleIcon class="ml-auto size-3" />
<CircleCheckBigIcon class="ml-auto size-3" />
{/if}
</DropdownMenu.Item>
{/each}

View File

@@ -3,13 +3,15 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import SubscriptionDialog from './subscription-dialog.svelte';
import UserIcon from '@lucide/svelte/icons/user';
import VideoIcon from '@lucide/svelte/icons/video';
import FolderIcon from '@lucide/svelte/icons/folder';
import HeartIcon from '@lucide/svelte/icons/heart';
import CheckIcon from '@lucide/svelte/icons/check';
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
import {
UserIcon,
VideoIcon,
FolderIcon,
HeartIcon,
CheckIcon,
PlusIcon,
XIcon
} from '@lucide/svelte/icons';
import type { Followed } from '$lib/types';
export let item: Followed;
@@ -146,7 +148,7 @@
? 'opacity-60'
: ''}"
>
<CardHeader class="flex-shrink-0">
<CardHeader class="shrink-0">
<div class="flex items-start gap-3">
<!-- 头像或图标 - 简化设计 -->
<div

View File

@@ -1,39 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Trigger from './alert-dialog-trigger.svelte';
import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription
Portal as AlertDialogPortal,
Title as AlertDialogTitle,
Trigger as AlertDialogTrigger,
Cancel,
Content,
Description,
Footer,
Header,
Overlay,
Portal,
Root,
Title,
Trigger
};

View File

@@ -1,2 +1 @@
export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from './badge.svelte';
export { default as Badge, badgeVariants, type BadgeVariant } from './badge.svelte';

View File

@@ -1,25 +1,25 @@
import Root from './breadcrumb.svelte';
import Ellipsis from './breadcrumb-ellipsis.svelte';
import Item from './breadcrumb-item.svelte';
import Separator from './breadcrumb-separator.svelte';
import Link from './breadcrumb-link.svelte';
import List from './breadcrumb-list.svelte';
import Page from './breadcrumb-page.svelte';
import Separator from './breadcrumb-separator.svelte';
import Root from './breadcrumb.svelte';
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage
Page as BreadcrumbPage,
Separator as BreadcrumbSeparator,
Ellipsis,
Item,
Link,
List,
Page,
Root,
Separator
};

View File

@@ -6,12 +6,12 @@ import Root, {
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
Root,
type ButtonProps,
type ButtonSize,
type ButtonVariant
type ButtonVariant,
type ButtonProps as Props
};

View File

@@ -1,25 +1,25 @@
import Root from './card.svelte';
import Action from './card-action.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Action from './card-action.svelte';
import Root from './card.svelte';
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Action as CardAction,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction
Content,
Description,
Footer,
Header,
Root,
Title
};

View File

@@ -1,6 +1,6 @@
import Root from './checkbox.svelte';
export {
Root,
//
Root as Checkbox
Root as Checkbox,
Root
};

View File

@@ -1,13 +1,13 @@
import Root from './collapsible.svelte';
import Trigger from './collapsible-trigger.svelte';
import Content from './collapsible-content.svelte';
import Trigger from './collapsible-trigger.svelte';
import Root from './collapsible.svelte';
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger
Trigger as CollapsibleTrigger,
Content,
Root,
Trigger
};

View File

@@ -1,40 +1,40 @@
import { Command as CommandPrimitive } from 'bits-ui';
import Root from './command.svelte';
import Dialog from './command-dialog.svelte';
import Empty from './command-empty.svelte';
import Group from './command-group.svelte';
import Item from './command-item.svelte';
import Input from './command-input.svelte';
import Item from './command-item.svelte';
import LinkItem from './command-link-item.svelte';
import List from './command-list.svelte';
import Separator from './command-separator.svelte';
import Shortcut from './command-shortcut.svelte';
import LinkItem from './command-link-item.svelte';
import Root from './command.svelte';
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Input as CommandInput,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Loading as CommandLoading,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading
Dialog,
Empty,
Group,
Input,
Item,
LinkItem,
List,
Loading,
Root,
Separator,
Shortcut
};

View File

@@ -1,37 +1,37 @@
import { Dialog as DialogPrimitive } from 'bits-ui';
import Title from './dialog-title.svelte';
import Close from './dialog-close.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Title from './dialog-title.svelte';
import Trigger from './dialog-trigger.svelte';
import Close from './dialog-close.svelte';
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Close,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Close as DialogClose,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose
Footer as DialogFooter,
Header as DialogHeader,
Overlay as DialogOverlay,
Portal as DialogPortal,
Title as DialogTitle,
Trigger as DialogTrigger,
Footer,
Header,
Overlay,
Portal,
Root,
Title,
Trigger
};

View File

@@ -1,6 +1,7 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
@@ -8,10 +9,9 @@ import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
@@ -22,6 +22,7 @@ export {
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
GroupHeading as DropdownMenuGroupHeading,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
@@ -32,7 +33,6 @@ export {
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,

View File

@@ -1,7 +1,7 @@
import Root from './input.svelte';
export {
Root,
//
Root as Input
Root as Input,
Root
};

View File

@@ -1,7 +1,7 @@
import Root from './label.svelte';
export {
Root,
//
Root as Label
Root as Label,
Root
};

View File

@@ -5,13 +5,13 @@ const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
Content,
//
Root as Popover,
Close as PopoverClose,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose
Root,
Trigger
};

View File

@@ -1,7 +1,7 @@
import Root from './progress.svelte';
export {
Root,
//
Root as Progress
Root as Progress,
Root
};

View File

@@ -1,37 +1,37 @@
import { Select as SelectPrimitive } from 'bits-ui';
import Group from './select-group.svelte';
import Label from './select-label.svelte';
import Item from './select-item.svelte';
import Content from './select-content.svelte';
import Trigger from './select-trigger.svelte';
import Separator from './select-separator.svelte';
import GroupHeading from './select-group-heading.svelte';
import Group from './select-group.svelte';
import Item from './select-item.svelte';
import Label from './select-label.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import GroupHeading from './select-group-heading.svelte';
import Separator from './select-separator.svelte';
import Trigger from './select-trigger.svelte';
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
Group,
GroupHeading,
Item,
Label,
Root,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
Group as SelectGroup,
GroupHeading as SelectGroupHeading,
Item as SelectItem,
Label as SelectLabel,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading
Separator as SelectSeparator,
Trigger as SelectTrigger,
Separator,
Trigger
};

View File

@@ -1,36 +1,36 @@
import { Dialog as SheetPrimitive } from 'bits-ui';
import Trigger from './sheet-trigger.svelte';
import Close from './sheet-close.svelte';
import Overlay from './sheet-overlay.svelte';
import Content from './sheet-content.svelte';
import Header from './sheet-header.svelte';
import Footer from './sheet-footer.svelte';
import Title from './sheet-title.svelte';
import Description from './sheet-description.svelte';
import Footer from './sheet-footer.svelte';
import Header from './sheet-header.svelte';
import Overlay from './sheet-overlay.svelte';
import Title from './sheet-title.svelte';
import Trigger from './sheet-trigger.svelte';
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
Footer,
Header,
Overlay,
Portal,
Root,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Description as SheetDescription,
Footer as SheetFooter,
Header as SheetHeader,
Overlay as SheetOverlay,
Portal as SheetPortal,
Title as SheetTitle,
Description as SheetDescription
Trigger as SheetTrigger,
Title,
Trigger
};

View File

@@ -1,4 +1,3 @@
import Root from './table.svelte';
import Body from './table-body.svelte';
import Caption from './table-caption.svelte';
import Cell from './table-cell.svelte';
@@ -6,15 +5,16 @@ import Footer from './table-footer.svelte';
import Head from './table-head.svelte';
import Header from './table-header.svelte';
import Row from './table-row.svelte';
import Root from './table.svelte';
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Root,
Row,
//
Root as Table,

View File

@@ -1,16 +1,16 @@
import Root from './tabs.svelte';
import Content from './tabs-content.svelte';
import List from './tabs-list.svelte';
import Trigger from './tabs-trigger.svelte';
import Root from './tabs.svelte';
export {
Root,
Content,
List,
Trigger,
Root,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger
Trigger as TabsTrigger,
Trigger
};

View File

@@ -1,21 +1,21 @@
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Trigger from './tooltip-trigger.svelte';
import Content from './tooltip-content.svelte';
import Trigger from './tooltip-trigger.svelte';
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
const Portal = TooltipPrimitive.Portal;
export {
Root,
Trigger,
Content,
Provider,
Portal,
Provider,
Root,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Portal as TooltipPortal,
Provider as TooltipProvider,
Portal as TooltipPortal
Trigger as TooltipTrigger,
Trigger
};

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

@@ -7,17 +7,23 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import type { VideoInfo } from '$lib/types';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import InfoIcon from '@lucide/svelte/icons/info';
import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning';
import UserIcon from '@lucide/svelte/icons/user';
import SquareArrowOutUpRightIcon from '@lucide/svelte/icons/square-arrow-out-up-right';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import {
RotateCcwIcon,
InfoIcon,
BrushCleaningIcon,
UserIcon,
SquareArrowOutUpRightIcon,
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 = ''; // 自定义标题
@@ -55,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' };
@@ -88,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;
@@ -125,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'
@@ -150,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'}
@@ -204,7 +233,7 @@
variant="outline"
class="hover:bg-accent hover:text-accent-foreground h-8 shrink-0 cursor-pointer px-2"
>
<MoreHorizontalIcon class="h-3 w-3" />
<EllipsisIcon class="h-3 w-3" />
</Button>
{/snippet}
</DropdownMenu.Trigger>

View File

@@ -1,7 +1,4 @@
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import { ClockIcon, FolderIcon, HeartIcon, UserIcon } from '@lucide/svelte/icons';
export const VIDEO_SOURCES = {
FAVORITE: { type: 'favorite', title: '收藏夹', icon: HeartIcon },

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;
}
@@ -198,7 +213,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 {
@@ -296,6 +311,7 @@ export interface WebhookNotifier {
type: 'webhook';
url: string;
template?: string | null;
headers?: Record<string, string> | null;
}
export type Notifier = TelegramNotifier | WebhookNotifier;
@@ -321,6 +337,7 @@ export interface Config {
concurrent_limit: ConcurrentLimit;
time_format: string;
cdn_sorting: boolean;
try_upower_anyway: boolean;
version: number;
}

View File

@@ -1,4 +1,5 @@
import { toast } from 'svelte-sonner';
import api from './api';
import type { SysInfo, TaskStatus } from './types';
// 支持的事件类型
@@ -25,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;
@@ -40,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;
@@ -60,8 +58,7 @@ export class WebSocketManager {
if (this.connectionPromise) return this.connectionPromise;
this.connectionPromise = new Promise((resolve, reject) => {
this.connecting = true;
const token = localStorage.getItem('authToken') || '';
const token = api.getAuthToken() || '';
try {
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
@@ -72,7 +69,6 @@ export class WebSocketManager {
);
this.socket.onopen = () => {
this.connected = true;
this.connecting = false;
this.reconnectAttempts = 0;
this.connectionPromise = null;
this.resubscribeEvents();
@@ -83,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);
@@ -272,7 +265,6 @@ export class WebSocketManager {
}
this.connected = false;
this.connecting = false;
this.connectionPromise = null;
this.subscribedEvents.clear();
}

View File

@@ -13,25 +13,29 @@
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
import api from '$lib/api';
import type { DashBoardResponse, SysInfo, ApiError, TaskStatus } from '$lib/types';
import DatabaseIcon from '@lucide/svelte/icons/database';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import CpuIcon from '@lucide/svelte/icons/cpu';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import PlayIcon from '@lucide/svelte/icons/play';
import CheckCircleIcon from '@lucide/svelte/icons/check-circle';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import DownloadIcon from '@lucide/svelte/icons/download';
import {
DatabaseIcon,
HeartIcon,
FolderIcon,
UserIcon,
ClockIcon,
VideoIcon,
HardDriveIcon,
CpuIcon,
MemoryStickIcon,
PlayIcon,
CircleCheckBigIcon,
CalendarIcon,
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;
@@ -88,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: '视频数量',
@@ -140,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>
@@ -337,7 +337,7 @@
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<CheckCircleIcon class="text-muted-foreground h-4 w-4" />
<CircleCheckBigIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">运行结束</span>
</div>
<span class="text-muted-foreground text-sm">

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

@@ -12,8 +12,7 @@
import PasswordInput from '$lib/components/custom/password-input.svelte';
import QrLogin from '$lib/components/custom/qr-login.svelte';
import NotifierDialog from './NotifierDialog.svelte';
import InfoIcon from '@lucide/svelte/icons/info';
import QrCodeIcon from '@lucide/svelte/icons/qr-code';
import { InfoIcon, QrCodeIcon } from '@lucide/svelte/icons';
import api from '$lib/api';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
@@ -110,6 +109,7 @@
toast.error('加载配置失败', {
description: (error as ApiError).message
});
throw error;
} finally {
loading = false;
}
@@ -123,12 +123,13 @@
try {
api.setAuthToken(frontendToken.trim());
localStorage.setItem('authToken', frontendToken.trim());
loadConfig();
await loadConfig();
toast.success('前端认证成功');
} catch (error) {
console.error('前端认证失败:', error);
toast.error('认证失败请检查Token是否正确');
toast.error('认证失败请检查Token是否正确', {
description: (error as ApiError).message
});
}
}
@@ -191,13 +192,7 @@
onMount(() => {
setBreadcrumb([{ label: '设置' }]);
const savedToken = localStorage.getItem('authToken');
if (savedToken) {
frontendToken = savedToken;
api.setAuthToken(savedToken);
}
frontendToken = api.getAuthToken() || '';
loadConfig();
});
</script>
@@ -233,7 +228,6 @@
onclick={() => {
formData = null;
config = null;
localStorage.removeItem('authToken');
api.clearAuthToken();
frontendToken = '';
}}
@@ -365,6 +359,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

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

View File

@@ -4,20 +4,24 @@
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';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import EditIcon from '@lucide/svelte/icons/edit';
import FolderIcon from '@lucide/svelte/icons/folder';
import HeartIcon from '@lucide/svelte/icons/heart';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import PlusIcon from '@lucide/svelte/icons/plus';
import InfoIcon from '@lucide/svelte/icons/info';
import TrashIcon2 from '@lucide/svelte/icons/trash-2';
import CheckCircleIcon from '@lucide/svelte/icons/check-circle';
import XCircleIcon from '@lucide/svelte/icons/x-circle';
import {
SquarePenIcon,
FolderIcon,
HeartIcon,
UserIcon,
ClockIcon,
PlusIcon,
InfoIcon,
Trash2Icon,
CircleCheckBigIcon,
CircleXIcon,
RefreshCwIcon
} from '@lucide/svelte/icons';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
@@ -56,6 +60,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: '',
@@ -118,6 +129,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;
@@ -364,14 +413,14 @@
<Badge
class="flex w-fit items-center gap-1.5 bg-emerald-700 text-emerald-100"
>
<CheckCircleIcon class="h-3 w-3" />
<CircleCheckBigIcon class="h-3 w-3" />
已启用{#if key === 'submissions' && source.useDynamicApi !== null}{source.useDynamicApi
? '(动态 API'
: ''}{/if}
</Badge>
{:else}
<Badge class="flex w-fit items-center gap-1.5 bg-rose-700 text-rose-100 ">
<XCircleIcon class="h-3 w-3" />
<CircleXIcon class="h-3 w-3" />
已禁用
</Badge>
{/if}
@@ -386,7 +435,7 @@
onclick={() => openEditDialog(key, source, index)}
class="h-8 w-8 p-0"
>
<EditIcon class="h-3 w-3" />
<SquarePenIcon class="h-3 w-3" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -408,6 +457,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>
@@ -417,7 +481,7 @@
onclick={() => openRemoveDialog(key, source, index)}
class="h-8 w-8 p-0"
>
<TrashIcon2 class="h-3 w-3" />
<Trash2Icon class="h-3 w-3" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -579,6 +643,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>

View File

@@ -6,9 +6,7 @@
import api from '$lib/api';
import SquareArrowOutUpRightIcon from '@lucide/svelte/icons/square-arrow-out-up-right';
import type { ApiError, VideoResponse, UpdateVideoStatusRequest } from '$lib/types';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import EditIcon from '@lucide/svelte/icons/edit';
import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning';
import { RotateCcwIcon, SquarePenIcon, BrushCleaningIcon } from '@lucide/svelte/icons';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import VideoCard from '$lib/components/video-card.svelte';
@@ -26,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');
@@ -175,7 +173,7 @@
onclick={() => (statusEditorOpen = true)}
disabled={statusEditorLoading}
>
<EditIcon class="mr-2 h-4 w-4" />
<SquarePenIcon class="mr-2 h-4 w-4" />
编辑状态
</Button>
<Button
@@ -214,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 主信息', '分页下载']}
@@ -256,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

@@ -3,8 +3,7 @@
import Pagination from '$lib/components/pagination.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import EditIcon from '@lucide/svelte/icons/edit';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { SquarePenIcon, RotateCcwIcon } from '@lucide/svelte/icons';
import api from '$lib/api';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Label } from '$lib/components/ui/label/index.js';
@@ -13,7 +12,8 @@
VideoSourcesResponse,
ApiError,
VideoSource,
UpdateFilteredVideoStatusRequest
UpdateFilteredVideoStatusRequest,
VideoInfo
} from '$lib/types';
import { onMount } from 'svelte';
import { page } from '$app/stores';
@@ -27,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;
@@ -54,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;
@@ -72,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')
};
}
@@ -84,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 {
@@ -101,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) {
@@ -119,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) {
@@ -132,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}」没有失败的状态,无需重置`
@@ -160,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('清空重置失败', {
@@ -184,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('没有需要重置的视频');
}
@@ -215,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('没有视频被更新');
}
@@ -231,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;
@@ -258,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);
}
@@ -281,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 () => {
@@ -292,6 +346,7 @@
}
]);
videoSources = (await api.getVideoSources()).data;
videoSourcesLoaded = true;
});
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
@@ -314,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>
@@ -338,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)}`);
}}
/>
@@ -368,7 +439,7 @@
onclick={() => (updateAllDialogOpen = true)}
disabled={updatingAll || loading}
>
<EditIcon class="mr-1.5 h-3 w-3" />
<SquarePenIcon class="mr-1.5 h-3 w-3" />
{hasFilters ? '编辑筛选' : '编辑全部'}
</Button>
<Button
@@ -396,6 +467,7 @@
{#each videosData.videos as video (video.id)}
<VideoCard
{video}
source={getVideoSource(video)}
onReset={async (forceReset: boolean) => {
await handleResetVideo(video.id, forceReset);
}}

View File

@@ -1,5 +1,5 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({