Compare commits

...

27 Commits

Author SHA1 Message Date
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
amtoaer
e51fed984b chore: 发布 bili-sync 2.10.3 2026-01-29 13:59:42 +08:00
ᴀᴍᴛᴏᴀᴇʀ
716c78b1e3 chore: 指定项目 rust 版本为 1.93.0,调整 ci 以读取配置 (#626) 2026-01-28 18:56:54 +08:00
ᴀᴍᴛᴏᴀᴇʀ
22bc6bb3e8 feat: 调整视频源页面 UI,提高可读性 (#623) 2026-01-26 20:11:38 +08:00
ᴀᴍᴛᴏᴀᴇʀ
fedbd4cdb1 feat: 调整视频编码优先级,默认使用 AVC (#622) 2026-01-26 18:23:31 +08:00
amtoaer
c1d9dc8b87 chore: 发布 bili-sync 2.10.2 2026-01-16 15:25:33 +08:00
ᴀᴍᴛᴏᴀᴇʀ
7f09a98d6c feat: 实现仅失败、仅成功、仅等待的筛选 (#610) 2026-01-16 15:10:39 +08:00
ᴀᴍᴛᴏᴀᴇʀ
269647ac22 chore: 使用 ring 代替 aws-lc-rs (#609) 2026-01-15 14:39:16 +08:00
amtoaer
e0189c5b36 chore: 移除 sea-orm 的 tls 依赖 2026-01-14 16:54:18 +08:00
开心
4c1abcf48c feat: videos页面中新增仅失败过滤选项 (#605)
* videos页面中新增 仅失败过滤选项

* 仅失败筛选时才计算失败标记,避免额外的分页查询

* 去除[仅失败]多余的逻辑判定

* refactor: 后端调整:1)为 status -> sql 加入一个中间层方便拓展;2)将 Option<bool> 改为带有 default 的 bool;3)failed 统一改成 failed_only

* refactor: 前端调整:1)前端也统一改成 failed_only;2)修复很多地方在 loadVideo 前没有读取 failedOnly;3)略微调整前端样式

* format

---------

Co-authored-by: kaixin1995 <admin@haokaikai.cn>
Co-authored-by: amtoaer <amtoaer@gmail.com>
2026-01-13 22:28:10 +08:00
amtoaer
c05463285b chore: 发布 bili-sync 2.10.1 2026-01-12 11:25:01 +08:00
ᴀᴍᴛᴏᴀᴇʀ
264de2487e fix: 修复 svelte 升级后 status-editor 按钮无法点击的问题 (#603) 2026-01-12 11:22:48 +08:00
82 changed files with 992 additions and 763 deletions

View File

@@ -75,17 +75,18 @@ jobs:
with:
name: web-build
path: web/build
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Install musl-tools
run: sudo apt-get update --yes && sudo apt-get install --yes musl-tools
if: contains(matrix.platform.target, 'musl')
- name: Read Toolchain Version
uses: SebRollen/toml-action@v1.2.0
id: read_rust_toolchain
with:
file: rust-toolchain.toml
field: toolchain.channel
- name: Build binary
uses: houseabsolute/actions-rust-cross@v0
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: ${{ matrix.platform.target }}
toolchain: stable
toolchain: ${{ steps.read_rust_toolchain.outputs.value }}
args: "--locked --release"
strip: true
- name: Package as archive

View File

@@ -26,7 +26,7 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v4
- run: rustup default stable && rustup component add clippy && rustup component add rustfmt --toolchain nightly
- run: rustup install && rustup component add rustfmt --toolchain nightly
- name: Cache dependencies
uses: swatinem/rust-cache@v2

155
Cargo.lock generated
View File

@@ -231,28 +231,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.8"
@@ -375,7 +353,7 @@ dependencies = [
[[package]]
name = "bili_sync"
version = "2.10.0"
version = "2.10.4"
dependencies = [
"anyhow",
"arc-swap",
@@ -392,6 +370,7 @@ dependencies = [
"croner",
"dashmap",
"dirs",
"dunce",
"enum_dispatch",
"float-ord",
"futures",
@@ -411,6 +390,7 @@ dependencies = [
"reqwest",
"rsa 0.10.0-rc.9",
"rust-embed-for-web",
"rustls",
"sea-orm",
"serde",
"serde_json",
@@ -433,7 +413,7 @@ dependencies = [
[[package]]
name = "bili_sync_entity"
version = "2.10.0"
version = "2.10.4"
dependencies = [
"derivative",
"regex",
@@ -444,7 +424,7 @@ dependencies = [
[[package]]
name = "bili_sync_migration"
version = "2.10.0"
version = "2.10.4"
dependencies = [
"sea-orm-migration",
]
@@ -686,15 +666,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -1251,12 +1222,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "funty"
version = "2.0.0"
@@ -1380,10 +1345,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1393,11 +1356,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -2013,12 +1974,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2621,62 +2576,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.42"
@@ -2840,7 +2739,6 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@@ -3006,12 +2904,6 @@ version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3023,11 +2915,10 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.35"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3054,7 +2945,6 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
"web-time",
"zeroize",
]
@@ -3091,7 +2981,6 @@ version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -3610,7 +3499,6 @@ dependencies = [
"once_cell",
"percent-encoding",
"rust_decimal",
"rustls",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -3622,7 +3510,6 @@ dependencies = [
"tracing",
"url",
"uuid",
"webpki-roots 0.26.11",
]
[[package]]
@@ -4592,16 +4479,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.5"
@@ -4611,24 +4488,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.5",
]
[[package]]
name = "webpki-roots"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.6.1"

View File

@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
resolver = "2"
[workspace.package]
version = "2.10.0"
version = "2.10.4"
authors = ["amtoaer <amtoaer@gmail.com>"]
license = "MIT"
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
@@ -30,6 +30,7 @@ croner = "3.0.1"
dashmap = "6.1.0"
derivative = "2.2.0"
dirs = "6.0.0"
dunce = "1.0.5"
enum_dispatch = "0.3.13"
float-ord = "0.3.2"
futures = "0.3.31"
@@ -54,14 +55,15 @@ reqwest = { version = "0.13.1", features = [
"gzip",
"http2",
"json",
"rustls",
"rustls-no-provider",
"stream",
], default-features = false }
rsa = { version = "0.10.0-rc.9", features = ["sha2"] }
rust-embed-for-web = { git = "https://github.com/amtoaer/rust-embed-for-web", tag = "v1.0.0" }
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
sea-orm = { version = "1.1.19", features = [
"macros",
"runtime-tokio-rustls",
"runtime-tokio",
"sqlx-sqlite",
"sqlite-use-returning-for-3_35",
] }

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 }
@@ -42,6 +43,7 @@ regex = { workspace = true }
reqwest = { workspace = true }
rsa = { workspace = true }
rust-embed-for-web = { workspace = true }
rustls = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -1,9 +1,22 @@
use std::borrow::Borrow;
use itertools::Itertools;
use sea_orm::{ConnectionTrait, DatabaseTransaction};
use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction};
use crate::api::request::StatusFilter;
use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo};
use crate::utils::status::VideoStatus;
impl StatusFilter {
pub fn to_video_query(&self) -> Condition {
let query_builder = VideoStatus::query_builder();
match self {
Self::Failed => query_builder.failed(),
Self::Succeeded => query_builder.succeeded(),
Self::Waiting => query_builder.waiting(),
}
}
}
pub trait VideoRecord {
fn as_id_status_tuple(&self) -> (i32, u32);
@@ -103,10 +116,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

@@ -4,6 +4,14 @@ use validator::Validate;
use crate::bilibili::CollectionType;
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StatusFilter {
Failed,
Succeeded,
Waiting,
}
#[derive(Deserialize)]
pub struct VideosRequest {
pub collection: Option<i32>,
@@ -11,6 +19,7 @@ pub struct VideosRequest {
pub submission: Option<i32>,
pub watch_later: Option<i32>,
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
@@ -28,6 +37,7 @@ pub struct ResetFilteredVideoStatusRequest {
pub submission: Option<i32>,
pub watch_later: Option<i32>,
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
#[serde(default)]
pub force: bool,
}
@@ -64,6 +74,7 @@ pub struct UpdateFilteredVideoStatusRequest {
pub submission: Option<i32>,
pub watch_later: Option<i32>,
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
#[serde(default)]
#[validate(nested)]
pub video_updates: Vec<StatusUpdate>,

View File

@@ -73,6 +73,7 @@ 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,

View File

@@ -7,6 +7,7 @@ use axum::routing::{get, post, put};
use bili_sync_entity::rule::Rule;
use bili_sync_entity::*;
use bili_sync_migration::Expr;
use itertools::Itertools;
use sea_orm::ActiveValue::Set;
use sea_orm::entity::prelude::*;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTrait, TransactionTrait};
@@ -347,11 +348,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?;
}

View File

@@ -62,6 +62,9 @@ pub async fn get_videos(
.or(video::Column::Bvid.contains(query_word)),
);
}
if let Some(status_filter) = params.status_filter {
query = query.filter(status_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)
@@ -171,6 +174,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))
@@ -190,6 +194,7 @@ 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,
},
@@ -218,6 +223,9 @@ pub async fn reset_filtered_video_status(
.or(video::Column::Bvid.contains(query_word)),
);
}
if let Some(status_filter) = request.status_filter {
query = query.filter(status_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)))
@@ -351,6 +359,9 @@ pub async fn update_filtered_video_status(
.or(video::Column::Bvid.contains(query_word)),
);
}
if let Some(status_filter) = request.status_filter {
query = query.filter(status_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

@@ -100,7 +100,7 @@ impl Default for FilterOption {
video_min_quality: VideoQuality::Quality360p,
audio_max_quality: AudioQuality::QualityHiRES,
audio_min_quality: AudioQuality::Quality64k,
codecs: vec![VideoCodecs::AV1, VideoCodecs::HEV, VideoCodecs::AVC],
codecs: vec![VideoCodecs::AVC, VideoCodecs::HEV, VideoCodecs::AV1],
no_dolby_video: false,
no_dolby_audio: false,
no_hdr: false,
@@ -263,10 +263,13 @@ impl PageAnalyzer {
}
}
if !filter_option.no_hires
&& let Some(flac) = self.info.pointer_mut("/dash/flac/audio")
&& let Some(flac) = self
.info
.pointer_mut("/dash/flac/audio")
.and_then(|f| f.as_object_mut())
{
let (Some(url), Some(quality)) = (flac["baseUrl"].as_str(), flac["id"].as_u64()) else {
bail!("invalid flac stream, flac content: {}", flac);
bail!("invalid flac stream, flac content: {:?}", flac);
};
let quality = AudioQuality::from_repr(quality as usize).context("invalid flac stream quality")?;
if quality >= filter_option.audio_min_quality && quality <= filter_option.audio_max_quality {

View File

@@ -3,6 +3,7 @@ use std::time::Duration;
use anyhow::{Result, bail};
use leaky_bucket::RateLimiter;
use parking_lot::Once;
use reqwest::{Method, header};
use ua_generator::ua;
@@ -16,6 +17,12 @@ pub struct Client(reqwest::Client);
impl Client {
pub fn new() -> Self {
static INIT: Once = Once::new();
INIT.call_once(|| {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
});
// 正常访问 api 所必须的 header作为默认 header 添加到每个请求中
let mut headers = header::HeaderMap::new();
headers.insert(
@@ -24,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 {

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

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,
@@ -43,7 +43,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 +68,7 @@ impl<'a> FavoriteList<'a> {
])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()

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

@@ -16,7 +16,7 @@ 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;

View File

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

View File

@@ -8,7 +8,7 @@ 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,
@@ -57,7 +57,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 +77,7 @@ impl<'a> Video<'a> {
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -96,7 +96,7 @@ impl<'a> Video<'a> {
.query(&[("bvid", &self.bvid)])
.send()
.await?
.error_for_status()?
.error_for_status_ext()?
.json::<serde_json::Value>()
.await?
.validate()?;
@@ -132,7 +132,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!(
@@ -164,7 +164,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()?;
@@ -181,7 +181,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()?;
@@ -207,7 +207,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

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

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 {
@@ -98,13 +104,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(())
}

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,

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

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

View File

@@ -1,3 +1,10 @@
use std::marker::PhantomData;
use bili_sync_entity::{page, video};
use bili_sync_migration::{ExprTrait, IntoCondition};
use sea_orm::sea_query::Expr;
use sea_orm::{ColumnTrait, Condition};
use crate::error::ExecutionStatus;
pub static STATUS_NOT_STARTED: u32 = 0b000;
@@ -11,10 +18,17 @@ pub static STATUS_COMPLETED: u32 = 1 << 31;
/// 如果子任务执行成功,将状态设置为 0b111该值定义为 STATUS_OK。
/// 子任务达到最大失败次数或者执行成功时,认为该子任务已经完成。
/// 当所有子任务都已经完成时,为最高位打上标记 1表示整个下载任务已经完成。
#[derive(Clone, Copy, Default)]
pub struct Status<const N: usize>(u32);
#[derive(Clone, Copy)]
pub struct Status<const N: usize, C>(u32, PhantomData<C>);
impl<const N: usize> Status<N> {
impl<const N: usize, C> Default for Status<N, C> {
fn default() -> Self {
Self(0, PhantomData)
}
}
impl<const N: usize, C> Status<N, C> {
pub(crate) const LEN: usize = N;
// 获取最高位的完成标记
pub fn get_completed(&self) -> bool {
self.0 >> 31 == 1
@@ -136,20 +150,20 @@ impl<const N: usize> Status<N> {
}
}
impl<const N: usize> From<u32> for Status<N> {
impl<const N: usize, C> From<u32> for Status<N, C> {
fn from(status: u32) -> Self {
Status(status)
Status(status, PhantomData)
}
}
impl<const N: usize> From<Status<N>> for u32 {
fn from(status: Status<N>) -> Self {
impl<const N: usize, C> From<Status<N, C>> for u32 {
fn from(status: Status<N, C>) -> Self {
status.0
}
}
impl<const N: usize> From<Status<N>> for [u32; N] {
fn from(status: Status<N>) -> Self {
impl<const N: usize, C> From<Status<N, C>> for [u32; N] {
fn from(status: Status<N, C>) -> Self {
let mut result = [0; N];
for (i, item) in result.iter_mut().enumerate() {
*item = status.get_status(i);
@@ -158,9 +172,9 @@ impl<const N: usize> From<Status<N>> for [u32; N] {
}
}
impl<const N: usize> From<[u32; N]> for Status<N> {
impl<const N: usize, C> From<[u32; N]> for Status<N, C> {
fn from(status: [u32; N]) -> Self {
let mut result = Status::<N>::default();
let mut result = Self::default();
for (i, item) in status.iter().enumerate() {
assert!(*item < 0b1000, "status should be less than 0b1000");
result.set_status(i, *item);
@@ -173,10 +187,64 @@ impl<const N: usize> From<[u32; N]> for Status<N> {
}
/// 包含五个子任务从前到后依次是视频封面、视频信息、Up 主头像、Up 主信息、分页下载
pub type VideoStatus = Status<5>;
pub type VideoStatus = Status<5, video::Column>;
impl VideoStatus {
pub fn query_builder() -> StatusQueryBuilder<{ Self::LEN }, video::Column> {
StatusQueryBuilder::new(video::Column::DownloadStatus)
}
}
/// 包含五个子任务,从前到后分别是:视频封面、视频内容、视频信息、视频弹幕、视频字幕
pub type PageStatus = Status<5>;
pub type PageStatus = Status<5, page::Column>;
impl PageStatus {
pub fn query_builder() -> StatusQueryBuilder<{ Self::LEN }, page::Column> {
StatusQueryBuilder::new(page::Column::DownloadStatus)
}
}
pub struct StatusQueryBuilder<const N: usize, C: ColumnTrait> {
column: C,
}
impl<const N: usize, C: ColumnTrait> StatusQueryBuilder<N, C> {
fn new(column: C) -> Self {
Self { column }
}
/// 完成状态:所有子任务的状态都是成功
pub fn succeeded(&self) -> Condition {
let mut condition = Condition::all();
for offset in 0..N as i32 {
condition = condition.add(Expr::col(self.column).right_shift(offset * 3).bit_and(7).eq(7))
}
condition
}
/// 失败状态:存在任何失败的子任务
pub fn failed(&self) -> Condition {
let mut condition = Condition::any();
for offset in 0..N as i32 {
condition = condition.add(
Expr::col(self.column)
.right_shift(offset * 3)
.bit_and(7)
.is_not_in([0, 7]),
)
}
condition
}
/// 等待状态:所有子任务的状态都不是失败,且其中存在未开始
pub fn waiting(&self) -> Condition {
let mut condition = Condition::any();
for offset in 0..N as i32 {
condition = condition.add(Expr::col(self.column).right_shift(offset * 3).bit_and(7).eq(0))
}
condition.and(self.failed().not()).into_condition()
}
}
#[cfg(test)]
mod tests {
@@ -186,7 +254,7 @@ mod tests {
#[test]
fn test_status_update() {
let mut status = Status::<3>::default();
let mut status = Status::<3, video::Column>::default();
assert_eq!(status.should_run(), [true, true, true]);
for _ in 0..3 {
status.update_status(&[
@@ -217,7 +285,7 @@ mod tests {
fn test_status_convert() {
let testcases = [[0, 0, 1], [1, 2, 3], [3, 1, 2], [3, 0, 7]];
for testcase in testcases.iter() {
let status = Status::<3>::from(testcase.clone());
let status = Status::<3, video::Column>::from(testcase.clone());
assert_eq!(<[u32; 3]>::from(status), *testcase);
}
}
@@ -226,7 +294,7 @@ mod tests {
fn test_status_convert_and_update() {
let testcases = [([0, 0, 1], [1, 7, 7]), ([3, 4, 3], [4, 4, 7]), ([3, 1, 7], [4, 7, 7])];
for (before, after) in testcases.iter() {
let mut status = Status::<3>::from(before.clone());
let mut status = Status::<3, video::Column>::from(before.clone());
status.update_status(&[
ExecutionStatus::Failed(anyhow!("")),
ExecutionStatus::Succeeded,
@@ -239,7 +307,7 @@ mod tests {
#[test]
fn test_status_reset_failed() {
// 重置一个出现部分失败但还有重试次数的任务,将所有的失败状态重置为 0
let mut status = Status::<3>::from([3, 4, 7]);
let mut status = Status::<3, video::Column>::from([3, 4, 7]);
assert!(!status.get_completed());
assert!(status.reset_failed());
assert!(!status.get_completed());
@@ -253,12 +321,12 @@ mod tests {
assert!(status.force_reset_failed());
assert!(!status.get_completed());
// 重置一个已经成功的任务,没有改变状态,也不会修改标记位
let mut status = Status::<3>::from([7, 7, 7]);
let mut status = Status::<3, video::Column>::from([7, 7, 7]);
assert!(status.get_completed());
assert!(!status.reset_failed());
assert!(status.get_completed());
// 重置一个全部失败的任务,修改状态并且修改标记位
let mut status = Status::<3>::from([4, 4, 4]);
let mut status = Status::<3, video::Column>::from([4, 4, 4]);
assert!(status.get_completed());
assert!(status.reset_failed());
assert!(!status.get_completed());
@@ -268,13 +336,13 @@ mod tests {
#[test]
fn test_status_set() {
// 设置子状态,从 completed 到 uncompleted
let mut status = Status::<5>::from([7, 7, 7, 7, 7]);
let mut status = Status::<5, video::Column>::from([7, 7, 7, 7, 7]);
assert!(status.get_completed());
status.set(4, 0);
assert!(!status.get_completed());
assert_eq!(<[u32; 5]>::from(status), [7, 7, 7, 7, 0]);
// 设置子状态,从 uncompleted 到 completed
let mut status = Status::<5>::from([4, 7, 7, 7, 0]);
let mut status = Status::<5, video::Column>::from([4, 7, 7, 7, 0]);
assert!(!status.get_completed());
status.set(4, 7);
assert!(status.get_completed());

View File

@@ -236,6 +236,8 @@ pub async fn download_video_pages(
.path_safe_render("video", &video_format_args(&video_model, &cx.config.time_format))?,
)
};
fs::create_dir_all(&base_path).await?;
let base_path = dunce::canonicalize(base_path).context("canonicalize video path failed")?;
let upper_id = video_model.upper_id.to_string();
let base_upper_path = cx
.config
@@ -416,6 +418,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)),

View File

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

View File

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

View File

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

View File

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

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.93.1"
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.0",
"version": "2.10.4",
"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,35 @@
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,
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 +63,10 @@ class ApiClient {
}
}
getAuthToken(): string | null {
return this.defaultHeaders['Authorization'] || localStorage.getItem('authToken');
}
// 清除认证 token
clearAuthToken() {
delete this.defaultHeaders['Authorization'];
@@ -340,6 +344,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';
@@ -62,7 +61,7 @@
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-[200px]" align="end">
<DropdownMenu.Content class="w-50" align="end">
<DropdownMenu.Group>
{#if filters}
{#each Object.entries(filters) as [key, filter] (key)}

View File

@@ -11,11 +11,19 @@
import type { StatusUpdate, UpdateFilteredVideoStatusRequest } from '$lib/types';
import { toast } from 'svelte-sonner';
export let open = false;
export let hasFilters = false;
export let loading = false;
export let filterDescriptionParts: string[] = [];
export let onsubmit: (request: UpdateFilteredVideoStatusRequest) => void;
let {
open = $bindable(false),
hasFilters = false,
loading = false,
filterDescriptionParts = [],
onsubmit
}: {
open?: boolean;
hasFilters?: boolean;
loading?: boolean;
filterDescriptionParts?: string[];
onsubmit: (request: UpdateFilteredVideoStatusRequest) => void;
} = $props();
// 视频任务名称(与后端 VideoStatus 对应)
const videoTaskNames = ['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载'];
@@ -27,29 +35,25 @@
type StatusValue = null | 0 | 7;
// 视频任务状态,默认都是 null未选择
let videoStatuses: StatusValue[] = Array(5).fill(null);
let videoStatuses = $state<StatusValue[]>(Array(5).fill(null));
// 分页任务状态,默认都是 null未选择
let pageStatuses: StatusValue[] = Array(5).fill(null);
let pageStatuses = $state<StatusValue[]>(Array(5).fill(null));
function setVideoStatus(taskIndex: number, value: StatusValue) {
videoStatuses[taskIndex] = value;
videoStatuses = [...videoStatuses];
}
function setPageStatus(taskIndex: number, value: StatusValue) {
pageStatuses[taskIndex] = value;
pageStatuses = [...pageStatuses];
}
function resetVideoStatus(taskIndex: number) {
videoStatuses[taskIndex] = null;
videoStatuses = [...videoStatuses];
}
function resetPageStatus(taskIndex: number) {
pageStatuses[taskIndex] = null;
pageStatuses = [...pageStatuses];
}
function resetAllStatuses() {
@@ -57,13 +61,16 @@
pageStatuses = Array(5).fill(null);
}
function hasAnyChanges(): boolean {
return (
videoStatuses.some((status) => status !== null) ||
pageStatuses.some((status) => status !== null)
);
function hasVideoChanges(): boolean {
return videoStatuses.some((status) => status !== null);
}
function hasPageChanges(): boolean {
return pageStatuses.some((status) => status !== null);
}
let hasAnyChanges = $derived(hasVideoChanges() || hasPageChanges());
function buildRequest(): UpdateFilteredVideoStatusRequest {
const request: UpdateFilteredVideoStatusRequest = {};
@@ -99,7 +106,7 @@
}
function handleSubmit() {
if (!hasAnyChanges()) {
if (!hasAnyChanges) {
toast.info('请至少选择一个状态进行修改');
return;
}
@@ -108,9 +115,11 @@
}
// 当 Sheet 关闭时重置状态
$: if (!open) {
resetAllStatuses();
}
$effect(() => {
if (!open) {
resetAllStatuses();
}
});
function getStatusInfo(status: StatusValue) {
if (status === 0) {
@@ -305,14 +314,14 @@
<Button
variant="outline"
onclick={resetAllStatuses}
disabled={!hasAnyChanges() || loading}
disabled={!hasAnyChanges || loading}
class="flex-1 cursor-pointer"
>
重置所有状态
</Button>
<Button
onclick={handleSubmit}
disabled={loading || !hasAnyChanges()}
disabled={loading || !hasAnyChanges}
class="flex-1 cursor-pointer"
>
{loading ? '提交中...' : '提交更改'}

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,7 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import PlusIcon from '@lucide/svelte/icons/plus';
import MinusIcon from '@lucide/svelte/icons/minus';
import XIcon from '@lucide/svelte/icons/x';
import { PlusIcon, MinusIcon, XIcon } from '@lucide/svelte/icons';
import type { Rule, RuleTarget, Condition } from '$lib/types';
import { onMount } from 'svelte';

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

@@ -12,11 +12,19 @@
import type { VideoInfo, PageInfo, StatusUpdate, UpdateVideoStatusRequest } from '$lib/types';
import { toast } from 'svelte-sonner';
export let open = false;
export let video: VideoInfo;
export let pages: PageInfo[] = [];
export let loading = false;
export let onsubmit: (request: UpdateVideoStatusRequest) => void;
let {
open = $bindable(false),
video,
pages = [],
loading = false,
onsubmit
}: {
open?: boolean;
video: VideoInfo;
pages?: PageInfo[];
loading?: boolean;
onsubmit: (request: UpdateVideoStatusRequest) => void;
} = $props();
// 视频任务名称(与后端 VideoStatus 对应)
const videoTaskNames = ['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载'];
@@ -24,28 +32,13 @@
// 分页任务名称(与后端 PageStatus 对应)
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];
// 重置单个视频任务到原始状态
function resetVideoTask(taskIndex: number) {
videoStatuses[taskIndex] = originalVideoStatuses[taskIndex];
videoStatuses = [...videoStatuses];
}
let videoStatuses = $state<number[]>([]);
let pageStatuses = $state<Record<number, number[]>>({});
// 重置单个分页任务到原始状态
function resetPageTask(pageId: number, taskIndex: number) {
if (!pageStatuses[pageId]) {
pageStatuses[pageId] = [];
}
pageStatuses[pageId][taskIndex] = originalPageStatuses[pageId]?.[taskIndex] ?? 0;
pageStatuses = { ...pageStatuses };
}
let originalVideoStatuses = $state<number[]>([]);
let originalPageStatuses = $state<Record<number, number[]>>({});
let videoStatuses: number[] = [];
let pageStatuses: Record<number, number[]> = {};
let originalVideoStatuses: number[] = [];
let originalPageStatuses: Record<number, number[]> = {};
$: {
$effect(() => {
videoStatuses = [...video.download_status];
originalVideoStatuses = [...video.download_status];
@@ -68,6 +61,19 @@
pageStatuses = {};
originalPageStatuses = {};
}
});
// 重置单个视频任务到原始状态
function resetVideoTask(taskIndex: number) {
videoStatuses[taskIndex] = originalVideoStatuses[taskIndex];
}
// 重置单个分页任务到原始状态
function resetPageTask(pageId: number, taskIndex: number) {
if (!pageStatuses[pageId]) {
pageStatuses[pageId] = [];
}
pageStatuses[pageId][taskIndex] = originalPageStatuses[pageId]?.[taskIndex] ?? 0;
}
function handleVideoStatusChange(taskIndex: number, newValue: number) {
@@ -108,9 +114,8 @@
});
}
function hasAnyChanges(): boolean {
return hasVideoChanges() || hasPageChanges();
}
// 使用 $derived 创建派生状态
let hasAnyChanges = $derived(hasVideoChanges() || hasPageChanges());
function buildRequest(): UpdateVideoStatusRequest {
const request: UpdateVideoStatusRequest = {};
@@ -151,7 +156,7 @@
}
function handleSubmit() {
if (!hasAnyChanges()) {
if (!hasAnyChanges) {
toast.info('没有状态变更需要提交');
return;
}
@@ -231,14 +236,14 @@
<Button
variant="outline"
onclick={resetAllStatuses}
disabled={!hasAnyChanges()}
disabled={!hasAnyChanges}
class="flex-1 cursor-pointer"
>
重置所有状态
</Button>
<Button
onclick={handleSubmit}
disabled={loading || !hasAnyChanges()}
disabled={loading || !hasAnyChanges}
class="flex-1 cursor-pointer"
>
{loading ? '提交中...' : '提交更改'}

View File

@@ -0,0 +1,95 @@
<script lang="ts">
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';
interface Props {
value: StatusFilterValue | null;
onSelect?: (value: StatusFilterValue) => void;
onRemove?: () => void;
}
let { value = $bindable(null), onSelect, onRemove }: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusTrigger() {
open = false;
}
const statusOptions = [
{
value: 'failed' as const,
label: '仅失败',
icon: CircleXIcon
},
{
value: 'succeeded' as const,
label: '仅成功',
icon: CircleCheckBigIcon
},
{
value: 'waiting' as const,
label: '仅等待',
icon: ClockIcon
}
];
function handleSelect(selectedValue: StatusFilterValue) {
value = selectedValue;
onSelect?.(selectedValue);
closeAndFocusTrigger();
}
const currentOption = $derived(statusOptions.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 statusOptions 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

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

@@ -7,12 +7,14 @@
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
} from '@lucide/svelte/icons';
import { goto } from '$app/navigation';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -55,11 +57,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 +95,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;
@@ -204,7 +211,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,5 +1,7 @@
import { writable } from 'svelte/store';
export type StatusFilterValue = 'failed' | 'succeeded' | 'waiting' | null;
export interface AppState {
query: string;
currentPage: number;
@@ -7,19 +9,21 @@ export interface AppState {
type: string;
id: string;
} | null;
statusFilter: StatusFilterValue | null;
}
export const appStateStore = writable<AppState>({
query: '',
currentPage: 0,
videoSource: null
videoSource: null,
statusFilter: null
});
export const ToQuery = (state: AppState): string => {
const { query, videoSource } = state;
const { query, videoSource, currentPage, statusFilter } = state;
const params = new URLSearchParams();
if (state.currentPage > 0) {
params.set('page', String(state.currentPage));
if (currentPage > 0) {
params.set('page', String(currentPage));
}
if (query.trim()) {
params.set('query', query);
@@ -27,6 +31,9 @@ export const ToQuery = (state: AppState): string => {
if (videoSource && videoSource.type && videoSource.id) {
params.set(videoSource.type, videoSource.id);
}
if (statusFilter) {
params.set('status_filter', statusFilter);
}
const queryString = params.toString();
return queryString ? `videos?${queryString}` : 'videos';
};
@@ -40,6 +47,7 @@ export const ToFilterParams = (
favorite?: number;
submission?: number;
watch_later?: number;
status_filter?: Exclude<StatusFilterValue, null>;
} => {
const params: {
query?: string;
@@ -47,6 +55,7 @@ export const ToFilterParams = (
favorite?: number;
submission?: number;
watch_later?: number;
status_filter?: Exclude<StatusFilterValue, null>;
} = {};
if (state.query.trim()) {
@@ -57,13 +66,15 @@ export const ToFilterParams = (
const { type, id } = state.videoSource;
params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id);
}
if (state.statusFilter) {
params.status_filter = state.statusFilter;
}
return params;
};
// 检查是否有活动的筛选条件
export const hasActiveFilters = (state: AppState): boolean => {
return !!(state.query.trim() || state.videoSource);
return !!(state.query.trim() || state.videoSource || state.statusFilter);
};
export const setQuery = (query: string) => {
@@ -73,20 +84,6 @@ export const setQuery = (query: string) => {
}));
};
export const setVideoSourceFilter = (filter: { type: string; id: string }) => {
appStateStore.update((state) => ({
...state,
videoSource: filter
}));
};
export const clearVideoSourceFilter = () => {
appStateStore.update((state) => ({
...state,
videoSource: null
}));
};
export const setCurrentPage = (page: number) => {
appStateStore.update((state) => ({
...state,
@@ -94,6 +91,13 @@ export const setCurrentPage = (page: number) => {
}));
};
export const setStatusFilter = (statusFilter: StatusFilterValue | null) => {
appStateStore.update((state) => ({
...state,
statusFilter
}));
};
export const resetCurrentPage = () => {
appStateStore.update((state) => ({
...state,
@@ -104,19 +108,13 @@ export const resetCurrentPage = () => {
export const setAll = (
query: string,
currentPage: number,
videoSource: { type: string; id: string } | null
videoSource: { type: string; id: string } | null,
statusFilter: StatusFilterValue | null
) => {
appStateStore.set({
query,
currentPage,
videoSource
});
};
export const clearAll = () => {
appStateStore.set({
query: '',
currentPage: 0,
videoSource: null
videoSource,
statusFilter
});
};

View File

@@ -9,6 +9,7 @@ export interface VideosRequest {
submission?: number;
watch_later?: number;
query?: string;
failed_only?: boolean;
page?: number;
page_size?: number;
}
@@ -30,6 +31,7 @@ export interface VideoInfo {
bvid: string;
name: string;
upper_name: string;
valid: boolean;
should_download: boolean;
download_status: [number, number, number, number, number];
}
@@ -106,6 +108,8 @@ export interface UpdateFilteredVideoStatusRequest {
submission?: number;
watch_later?: number;
query?: string;
// 仅更新下载失败
failed_only?: boolean;
video_updates?: StatusUpdate[];
page_updates?: StatusUpdate[];
}
@@ -120,6 +124,8 @@ export interface ResetFilteredVideoStatusRequest {
submission?: number;
watch_later?: number;
query?: string;
// 仅重置下载失败
failed_only?: boolean;
force: boolean;
}

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 = '';
}}

View File

@@ -4,17 +4,22 @@
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 { 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 {
SquarePenIcon,
FolderIcon,
HeartIcon,
UserIcon,
ClockIcon,
PlusIcon,
InfoIcon,
Trash2Icon,
CircleCheckBigIcon,
CircleXIcon
} 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';
@@ -315,10 +320,7 @@
<Table.Head class="w-[20%]">名称</Table.Head>
<Table.Head class="w-[30%]">下载路径</Table.Head>
<Table.Head class="w-[15%]">过滤规则</Table.Head>
<Table.Head class="w-[10%]">启用状态</Table.Head>
{#if key === 'submissions'}
<Table.Head class="w-[10%]">使用动态 API</Table.Head>
{/if}
<Table.Head class="w-[15%]">启用状态</Table.Head>
<Table.Head class="w-[10%] text-right">操作</Table.Head>
</Table.Row>
</Table.Header>
@@ -327,73 +329,103 @@
<Table.Row>
<Table.Cell class="font-medium">{source.name}</Table.Cell>
<Table.Cell>
<code
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
<div
class="bg-secondary hover:bg-secondary/80 flex w-fit cursor-text items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors"
>
{source.path || '未设置'}
</code>
<FolderIcon class="text-foreground/70 h-3.5 w-3.5 shrink-0" />
<span
class="text-foreground/70 font-mono text-xs font-medium select-text"
>
{source.path || '未设置'}
</span>
</div>
</Table.Cell>
<Table.Cell>
{#if source.rule && source.rule.length > 0}
<div class="flex items-center gap-1">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-muted-foreground text-sm"
>{source.rule.length} 条规则</span
>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">{source.ruleDisplay}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Tooltip.Root disableHoverableContent={true}>
<Tooltip.Trigger>
<Badge
variant="secondary"
class="flex w-fit cursor-help items-center gap-1.5"
>
{source.rule.length} 条规则
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">{source.ruleDisplay}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-muted-foreground text-sm">-</span>
<Badge variant="secondary" class="flex w-fit items-center gap-1.5">
-
</Badge>
{/if}
</Table.Cell>
<Table.Cell>
<div class="flex h-8 items-center gap-2">
<Switch checked={source.enabled} disabled />
</div>
</Table.Cell>
{#if key === 'submissions'}
<Table.Cell>
<div class="flex h-8 items-center gap-2">
{#if source.useDynamicApi !== null}
<Switch checked={source.useDynamicApi} disabled />
{/if}
</div>
</Table.Cell>
{/if}
<Table.Cell class="text-right">
<Button
size="sm"
variant="outline"
onclick={() => openEditDialog(key, source, index)}
class="h-8 w-8 p-0"
title="编辑"
>
<EditIcon class="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onclick={() => openEvaluateRules(key, source)}
class="h-8 w-8 p-0"
title="重新评估规则"
>
<ListRestartIcon class="h-3 w-3" />
</Button>
{#if activeTab !== 'watch_later'}
<Button
size="sm"
variant="outline"
onclick={() => openRemoveDialog(key, source, index)}
class="h-8 w-8 p-0"
title="删除"
{#if source.enabled}
<Badge
class="flex w-fit items-center gap-1.5 bg-emerald-700 text-emerald-100"
>
<TrashIcon2 class="h-3 w-3" />
</Button>
<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 ">
<CircleXIcon class="h-3 w-3" />
已禁用
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<Tooltip.Root disableHoverableContent={true}>
<Tooltip.Trigger>
<Button
size="sm"
variant="outline"
onclick={() => openEditDialog(key, source, index)}
class="h-8 w-8 p-0"
>
<SquarePenIcon class="h-3 w-3" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">编辑</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root disableHoverableContent={true}>
<Tooltip.Trigger>
<Button
size="sm"
variant="outline"
onclick={() => openEvaluateRules(key, source)}
class="h-8 w-8 p-0"
>
<ListRestartIcon 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>
<Button
size="sm"
variant="outline"
onclick={() => openRemoveDialog(key, source, index)}
class="h-8 w-8 p-0"
>
<Trash2Icon class="h-3 w-3" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">删除</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Table.Cell>
</Table.Row>
@@ -438,7 +470,7 @@
<!-- 编辑对话框 -->
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content
class="no-scrollbar max-h-[85vh] !max-w-[90vw] overflow-y-auto lg:!max-w-[70vw]"
class="no-scrollbar max-h-[85vh] max-w-[90vw]! overflow-y-auto lg:max-w-[70vw]!"
>
<Dialog.Title class="text-lg font-semibold">
编辑视频源: {editingSource?.name || ''}

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';
@@ -26,14 +25,17 @@
setAll,
setCurrentPage,
setQuery,
setStatusFilter,
ToQuery,
ToFilterParams,
hasActiveFilters
hasActiveFilters,
type StatusFilterValue
} 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';
const pageSize = 20;
@@ -61,9 +63,18 @@
videoSource = { type: source.type, id: value };
}
}
// 支持从 URL 里还原状态筛选
const statusFilterParam = searchParams.get('status_filter');
const statusFilter: StatusFilterValue | null =
statusFilterParam === 'failed' ||
statusFilterParam === 'succeeded' ||
statusFilterParam === 'waiting'
? statusFilterParam
: null;
return {
query: searchParams.get('query') || '',
videoSource,
statusFilter,
pageNum: parseInt(searchParams.get('page') || '0')
};
}
@@ -71,11 +82,12 @@
async function loadVideos(
query: string,
pageNum: number = 0,
filter?: { type: string; id: string } | null
filter?: { type: string; id: string } | null,
statusFilter: StatusFilterValue | null = null
) {
loading = true;
try {
const params: Record<string, string | number> = {
const params: Record<string, string | number | boolean> = {
page: pageNum,
page_size: pageSize
};
@@ -85,6 +97,9 @@
if (filter) {
params[filter.type] = parseInt(filter.id);
}
if (statusFilter) {
params.status_filter = statusFilter;
}
const result = await api.getVideos(params);
videosData = result.data;
} catch (error) {
@@ -103,9 +118,9 @@
}
async function handleSearchParamsChange(searchParams: URLSearchParams) {
const { query, videoSource, pageNum } = getApiParams(searchParams);
setAll(query, pageNum, videoSource);
loadVideos(query, pageNum, videoSource);
const { query, videoSource, pageNum, statusFilter } = getApiParams(searchParams);
setAll(query, pageNum, videoSource, statusFilter);
loadVideos(query, pageNum, videoSource, statusFilter);
}
async function handleResetVideo(id: number, forceReset: boolean) {
@@ -116,8 +131,8 @@
toast.success('重置成功', {
description: `视频「${data.video.name}」已重置`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, statusFilter);
} else {
toast.info('重置无效', {
description: `视频「${data.video.name}」没有失败的状态,无需重置`
@@ -144,8 +159,8 @@
description: `视频「${data.video.name}」已清空重置`
});
}
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, statusFilter);
} catch (error) {
console.error('清空重置失败:', error);
toast.error('清空重置失败', {
@@ -168,8 +183,8 @@
toast.success('重置成功', {
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, statusFilter);
} else {
toast.info('没有需要重置的视频');
}
@@ -199,8 +214,8 @@
toast.success('更新成功', {
description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, statusFilter);
} else {
toast.info('没有视频被更新');
}
@@ -234,6 +249,14 @@
}
}
}
if (state.statusFilter) {
const statusLabels = {
failed: '仅失败',
succeeded: '仅成功',
waiting: '仅等待'
};
parts.push(`状态:${statusLabels[state.statusFilter]}`);
}
return parts;
}
@@ -289,20 +312,40 @@
goto(`/${ToQuery($appStateStore)}`);
}}
></SearchBar>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">筛选:</span>
<DropdownFilter
{filters}
selectedLabel={$appStateStore.videoSource}
onSelect={(type, id) => {
setAll('', 0, { type, id });
goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setAll('', 0, null);
goto(`/${ToQuery($appStateStore)}`);
}}
/>
<div class="flex items-center gap-3">
<!-- 状态筛选 -->
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">状态:</span>
<StatusFilter
value={$appStateStore.statusFilter}
onSelect={(value) => {
setStatusFilter(value);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setStatusFilter(null);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
<!-- 视频源筛选 -->
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">来源:</span>
<DropdownFilter
{filters}
selectedLabel={$appStateStore.videoSource}
onSelect={(type, id) => {
setAll('', 0, { type, id }, $appStateStore.statusFilter);
goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setAll('', 0, null, $appStateStore.statusFilter);
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
</div>
</div>
@@ -324,7 +367,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

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