mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-10 16:12:39 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe13029e84 | ||
|
|
bdf4ab58f2 | ||
|
|
681617cf02 | ||
|
|
b6c5b547a3 | ||
|
|
8aba906904 | ||
|
|
3e465d9b71 | ||
|
|
1930a57edd | ||
|
|
bb1576a0df | ||
|
|
5350d3491b | ||
|
|
e130f14c13 | ||
|
|
980f74a242 | ||
|
|
8c04dc6564 | ||
|
|
c49ec81d51 | ||
|
|
580a66eb17 | ||
|
|
295d4105aa | ||
|
|
151251719b | ||
|
|
e51fed984b | ||
|
|
716c78b1e3 | ||
|
|
22bc6bb3e8 | ||
|
|
fedbd4cdb1 | ||
|
|
c1d9dc8b87 | ||
|
|
7f09a98d6c | ||
|
|
269647ac22 | ||
|
|
e0189c5b36 | ||
|
|
4c1abcf48c | ||
|
|
c05463285b | ||
|
|
264de2487e |
15
.github/workflows/build-binary.yaml
vendored
15
.github/workflows/build-binary.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/pr-check.yaml
vendored
2
.github/workflows/pr-check.yaml
vendored
@@ -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
155
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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 驱动。
|
||||
|
||||
## 效果演示
|
||||
|
||||
### 管理页
|
||||

|
||||

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

|
||||
### 媒体库详情
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 138 KiB |
@@ -24,6 +24,7 @@ cookie = { workspace = true }
|
||||
croner = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
enum_dispatch = { workspace = true }
|
||||
float-ord = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(_, _)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.10.0",
|
||||
text: "v2.10.4",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.10.0,文档将始终与最新程序版本保持一致。
|
||||
> 当前最新程序版本为 v2.10.4,文档将始终与最新程序版本保持一致。
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
bili-sync.allwens.work
|
||||
bili-sync.amto.cc
|
||||
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.93.1"
|
||||
components = ["clippy"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 ? '提交中...' : '提交更改'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = '搜索视频..';
|
||||
|
||||
@@ -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 ? '提交中...' : '提交更改'}
|
||||
|
||||
95
web/src/lib/components/status-filter.svelte
Normal file
95
web/src/lib/components/status-filter.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Root from './checkbox.svelte';
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox
|
||||
Root as Checkbox,
|
||||
Root
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from './input.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input
|
||||
Root as Input,
|
||||
Root
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from './label.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label
|
||||
Root as Label,
|
||||
Root
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from './progress.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress
|
||||
Root as Progress,
|
||||
Root
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = '';
|
||||
}}
|
||||
|
||||
@@ -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 || ''}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user