mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-08 01:02:49 +08:00
Compare commits
85 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 | ||
|
|
ea575b04e6 | ||
|
|
f122b9756b | ||
|
|
26514f7174 | ||
|
|
5944298f10 | ||
|
|
64eecaa822 | ||
|
|
18d06c51ba | ||
|
|
ffa5c1e860 | ||
|
|
97e1b6285e | ||
|
|
e2a24eff29 | ||
|
|
56f5ed8e01 | ||
|
|
0b5ae3d664 | ||
|
|
f24ee97b28 | ||
|
|
96c11bb077 | ||
|
|
2455f7c83d | ||
|
|
4faf5a7cf9 | ||
|
|
c2c732093d | ||
|
|
4103122f6b | ||
|
|
14b8f877cf | ||
|
|
8dfc7ddf5c | ||
|
|
9a63e1eb6f | ||
|
|
d1b279ed7f | ||
|
|
128ca49225 | ||
|
|
8c2e8da2b0 | ||
|
|
5dd7486b12 | ||
|
|
b7d9e5dc0c | ||
|
|
d1eac3e298 | ||
|
|
3f047771cb | ||
|
|
f1703096fd | ||
|
|
930660045f | ||
|
|
6391aa67c0 | ||
|
|
b5ef76b0ed | ||
|
|
f37d9af678 | ||
|
|
7ef38a38ed | ||
|
|
e76673d076 | ||
|
|
f3822dd536 | ||
|
|
688c8cec6a | ||
|
|
c854e4e889 | ||
|
|
645e686822 | ||
|
|
670f21a725 | ||
|
|
8931cb5d2a | ||
|
|
66996a77c6 | ||
|
|
170bd14fe3 | ||
|
|
c69a88f1da | ||
|
|
8ac6829e61 | ||
|
|
a871db655f | ||
|
|
854d39cf88 | ||
|
|
b6cba69e11 | ||
|
|
ff6db0ad97 | ||
|
|
84d353365a | ||
|
|
c7e0d31811 | ||
|
|
2fff5134cf | ||
|
|
8a1569d085 | ||
|
|
de702435af | ||
|
|
eb2606f120 | ||
|
|
02c42861ab | ||
|
|
ed54ca13b8 | ||
|
|
4d6669a48a | ||
|
|
eadb464363 |
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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
**/target
|
||||
auth_data
|
||||
*.sqlite
|
||||
*.sqlite*
|
||||
debug*
|
||||
node_modules
|
||||
docs/.vitepress/cache
|
||||
|
||||
2347
Cargo.lock
generated
2347
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
78
Cargo.toml
78
Cargo.toml
@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.7.0"
|
||||
version = "2.10.4"
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
|
||||
@@ -15,69 +15,75 @@ publish = false
|
||||
bili_sync_entity = { path = "crates/bili_sync_entity" }
|
||||
bili_sync_migration = { path = "crates/bili_sync_migration" }
|
||||
|
||||
anyhow = { version = "1.0.98", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.7.1", features = ["serde"] }
|
||||
assert_matches = "1.5.0"
|
||||
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
||||
anyhow = { version = "1.0.100", features = ["backtrace"] }
|
||||
arc-swap = { version = "1.8.0", features = ["serde"] }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.88"
|
||||
axum = { version = "0.8.4", features = ["macros", "ws"] }
|
||||
async-tempfile = { version = "0.7.0", features = ["uuid"] }
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.8", features = ["macros", "ws"] }
|
||||
base64 = "0.22.1"
|
||||
built = { version = "0.7.7", features = ["git2", "chrono"] }
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
clap = { version = "4.5.41", features = ["env", "string"] }
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
clap = { version = "4.5.54", features = ["env", "string"] }
|
||||
cookie = "0.18.1"
|
||||
cow-utils = "0.1.3"
|
||||
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"
|
||||
git2 = { version = "0.20.2", features = [], default-features = false }
|
||||
handlebars = "6.3.2"
|
||||
git2 = { version = "0.20.3", features = [], default-features = false }
|
||||
handlebars = "6.4.0"
|
||||
hex = "0.4.3"
|
||||
itertools = "0.14.0"
|
||||
leaky-bucket = "1.1.2"
|
||||
md5 = "0.8.0"
|
||||
memchr = "2.7.5"
|
||||
memchr = "2.7.6"
|
||||
once_cell = "1.21.3"
|
||||
parking_lot = "0.12.4"
|
||||
parking_lot = "0.12.5"
|
||||
prost = "0.14.1"
|
||||
quick-xml = { version = "0.38.0", features = ["async-tokio"] }
|
||||
rand = "0.9.1"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.22", features = [
|
||||
quick-xml = { version = "0.38.4", features = ["async-tokio"] }
|
||||
rand = "0.9.2"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.13.1", features = [
|
||||
"query",
|
||||
"form",
|
||||
"charset",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"http2",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
"rustls-no-provider",
|
||||
"stream",
|
||||
], default-features = false }
|
||||
rsa = { version = "0.10.0-rc.3", features = ["sha2"] }
|
||||
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" }
|
||||
sea-orm = { version = "1.1.13", features = [
|
||||
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",
|
||||
] }
|
||||
sea-orm-migration = { version = "1.1.13", features = [] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
sea-orm-migration = { version = "1.1.19", features = [] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.148"
|
||||
serde_urlencoded = "0.7.1"
|
||||
strum = { version = "0.27.1", features = ["derive"] }
|
||||
sysinfo = "0.36.0"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.46.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.17", features = ["sync"] }
|
||||
tokio-util = { version = "0.7.15", features = ["io", "rt"] }
|
||||
toml = "0.9.1"
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sysinfo = "0.37.2"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-cron-scheduler = "0.15.1"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
tokio-util = { version = "0.7.18", features = ["io", "rt"] }
|
||||
toml = "0.9.10"
|
||||
tower = "0.5.2"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "json"] }
|
||||
ua_generator = "0.5.22"
|
||||
uuid = { version = "1.17.0", features = ["v4"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["chrono", "json"] }
|
||||
ua_generator = { version = "0.5.42", default-features = false }
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
|
||||
[workspace.metadata.release]
|
||||
|
||||
@@ -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 |
@@ -13,6 +13,7 @@ build = "build.rs"
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
async-tempfile = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bili_sync_entity = { workspace = true }
|
||||
@@ -20,14 +21,16 @@ bili_sync_migration = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
cookie = { workspace = true }
|
||||
cow-utils = { 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 }
|
||||
handlebars = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
itertools = { workspace = true }
|
||||
leaky-bucket = { workspace = true }
|
||||
md5 = { workspace = true }
|
||||
memchr = { workspace = true }
|
||||
@@ -40,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 }
|
||||
@@ -48,6 +52,7 @@ strum = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-cron-scheduler = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -58,9 +63,6 @@ ua_generator = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_matches = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
built = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use anyhow::{Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use chrono::Utc;
|
||||
@@ -13,7 +13,7 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, CollectionType, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, CollectionType, Credential, VideoInfo};
|
||||
|
||||
impl VideoSource for collection::Model {
|
||||
fn display_name(&self) -> Cow<'static, str> {
|
||||
@@ -44,7 +44,12 @@ impl VideoSource for collection::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn should_take(&self, _release_datetime: &chrono::DateTime<Utc>, _latest_row_at: &chrono::DateTime<Utc>) -> bool {
|
||||
fn should_take(
|
||||
&self,
|
||||
_idx: usize,
|
||||
_release_datetime: &chrono::DateTime<Utc>,
|
||||
_latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> bool {
|
||||
// collection(视频合集/视频列表)返回的内容似乎并非严格按照时间排序,并且不同 collection 的排序方式也不同
|
||||
// 为了保证程序正确性,collection 不根据时间提前 break,而是每次都全量拉取
|
||||
true
|
||||
@@ -52,6 +57,7 @@ impl VideoSource for collection::Model {
|
||||
|
||||
fn should_filter(
|
||||
&self,
|
||||
_idx: usize,
|
||||
video_info: Result<VideoInfo, anyhow::Error>,
|
||||
latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
@@ -61,7 +67,6 @@ impl VideoSource for collection::Model {
|
||||
{
|
||||
return Some(video_info);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -72,6 +77,7 @@ impl VideoSource for collection::Model {
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
@@ -84,6 +90,7 @@ impl VideoSource for collection::Model {
|
||||
mid: self.m_id.to_string(),
|
||||
collection_type: CollectionType::from_expected(self.r#type),
|
||||
},
|
||||
credential,
|
||||
);
|
||||
let collection_info = collection.get_info().await?;
|
||||
ensure!(
|
||||
@@ -94,21 +101,18 @@ impl VideoSource for collection::Model {
|
||||
collection_info,
|
||||
collection.collection
|
||||
);
|
||||
collection::ActiveModel {
|
||||
let updated_model = collection::ActiveModel {
|
||||
id: Unchanged(self.id),
|
||||
name: Set(collection_info.name.clone()),
|
||||
name: Set(collection_info.name),
|
||||
..Default::default()
|
||||
}
|
||||
.save(connection)
|
||||
.update(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
collection::Entity::find()
|
||||
.filter(collection::Column::Id.eq(self.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.context("collection not found")?
|
||||
.into(),
|
||||
Box::pin(collection.into_video_stream()),
|
||||
))
|
||||
Ok((updated_model.into(), Box::pin(collection.into_video_stream())))
|
||||
}
|
||||
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use anyhow::{Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
@@ -12,7 +12,7 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, FavoriteList, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, FavoriteList, VideoInfo};
|
||||
|
||||
impl VideoSource for favorite::Model {
|
||||
fn display_name(&self) -> Cow<'static, str> {
|
||||
@@ -50,12 +50,13 @@ impl VideoSource for favorite::Model {
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let favorite = FavoriteList::new(bili_client, self.f_id.to_string());
|
||||
let favorite = FavoriteList::new(bili_client, self.f_id.to_string(), credential);
|
||||
let favorite_info = favorite.get_info().await?;
|
||||
ensure!(
|
||||
favorite_info.id == self.f_id,
|
||||
@@ -63,21 +64,18 @@ impl VideoSource for favorite::Model {
|
||||
favorite_info.id,
|
||||
self.f_id
|
||||
);
|
||||
favorite::ActiveModel {
|
||||
let updated_model = favorite::ActiveModel {
|
||||
id: Unchanged(self.id),
|
||||
name: Set(favorite_info.title.clone()),
|
||||
name: Set(favorite_info.title),
|
||||
..Default::default()
|
||||
}
|
||||
.save(connection)
|
||||
.update(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
favorite::Entity::find()
|
||||
.filter(favorite::Column::Id.eq(self.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.context("favorite not found")?
|
||||
.into(),
|
||||
Box::pin(favorite.into_video_stream()),
|
||||
))
|
||||
Ok((updated_model.into(), Box::pin(favorite.into_video_stream())))
|
||||
}
|
||||
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use enum_dispatch::enum_dispatch;
|
||||
use futures::Stream;
|
||||
@@ -23,7 +23,7 @@ use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::submission::Model as Submission;
|
||||
use bili_sync_entity::watch_later::Model as WatchLater;
|
||||
|
||||
use crate::bilibili::{BiliClient, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, VideoInfo};
|
||||
|
||||
#[enum_dispatch]
|
||||
pub enum VideoSourceEnum {
|
||||
@@ -56,12 +56,18 @@ pub trait VideoSource {
|
||||
fn update_latest_row_at(&self, datetime: DateTime) -> _ActiveModel;
|
||||
|
||||
// 判断是否应该继续拉取视频
|
||||
fn should_take(&self, release_datetime: &chrono::DateTime<Utc>, latest_row_at: &chrono::DateTime<Utc>) -> bool {
|
||||
fn should_take(
|
||||
&self,
|
||||
_idx: usize,
|
||||
release_datetime: &chrono::DateTime<Utc>,
|
||||
latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> bool {
|
||||
release_datetime > latest_row_at
|
||||
}
|
||||
|
||||
fn should_filter(
|
||||
&self,
|
||||
_idx: usize,
|
||||
video_info: Result<VideoInfo, anyhow::Error>,
|
||||
_latest_row_at: &chrono::DateTime<Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
@@ -98,11 +104,25 @@ pub trait VideoSource {
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)>;
|
||||
|
||||
async fn create_dir_all(&self) -> Result<()> {
|
||||
let video_source_path = self.path();
|
||||
tokio::fs::create_dir_all(video_source_path).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create video source directory {}",
|
||||
video_source_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()>;
|
||||
}
|
||||
|
||||
pub enum _ActiveModel {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use anyhow::{Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
@@ -11,7 +11,7 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, Submission, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Dynamic, Submission, VideoInfo};
|
||||
|
||||
impl VideoSource for submission::Model {
|
||||
fn display_name(&self) -> std::borrow::Cow<'static, str> {
|
||||
@@ -42,6 +42,42 @@ impl VideoSource for submission::Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn should_take(
|
||||
&self,
|
||||
idx: usize,
|
||||
release_datetime: &chrono::DateTime<chrono::Utc>,
|
||||
latest_row_at: &chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
// 如果使用动态 API,那么可能出现用户置顶了一个很久以前的视频在动态顶部的情况
|
||||
// 这种情况应该继续拉取下去,不能因为第一条不满足条件就停止
|
||||
// 后续的非置顶内容是正常由新到旧排序的,可以继续使用常规方式处理
|
||||
if idx == 0 && self.use_dynamic_api {
|
||||
return true;
|
||||
}
|
||||
release_datetime > latest_row_at
|
||||
}
|
||||
|
||||
fn should_filter(
|
||||
&self,
|
||||
idx: usize,
|
||||
video_info: Result<VideoInfo, anyhow::Error>,
|
||||
latest_row_at: &chrono::DateTime<chrono::Utc>,
|
||||
) -> Option<VideoInfo> {
|
||||
if idx == 0 && self.use_dynamic_api {
|
||||
// 同理,动态 API 的第一条内容可能是置顶的老视频,单独做个过滤
|
||||
// 其实不过滤也不影响逻辑正确性,因为后续 insert 发生冲突仍然会忽略掉
|
||||
// 此处主要是出于性能考虑,减少不必要的数据库操作
|
||||
if let Ok(video_info) = video_info
|
||||
&& video_info.release_datetime() > latest_row_at
|
||||
{
|
||||
return Some(video_info);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
video_info.ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn rule(&self) -> &Option<Rule> {
|
||||
&self.rule
|
||||
}
|
||||
@@ -49,12 +85,13 @@ impl VideoSource for submission::Model {
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let submission = Submission::new(bili_client, self.upper_id.to_string());
|
||||
let submission = Submission::new(bili_client, self.upper_id.to_string(), credential);
|
||||
let upper = submission.get_info().await?;
|
||||
ensure!(
|
||||
upper.mid == submission.upper_id,
|
||||
@@ -62,21 +99,24 @@ impl VideoSource for submission::Model {
|
||||
upper.mid,
|
||||
submission.upper_id
|
||||
);
|
||||
submission::ActiveModel {
|
||||
let updated_model = submission::ActiveModel {
|
||||
id: Unchanged(self.id),
|
||||
upper_name: Set(upper.name),
|
||||
..Default::default()
|
||||
}
|
||||
.save(connection)
|
||||
.update(connection)
|
||||
.await?;
|
||||
Ok((
|
||||
submission::Entity::find()
|
||||
.filter(submission::Column::Id.eq(self.id))
|
||||
.one(connection)
|
||||
.await?
|
||||
.context("submission not found")?
|
||||
.into(),
|
||||
Box::pin(submission.into_video_stream()),
|
||||
))
|
||||
let video_stream = if self.use_dynamic_api {
|
||||
// 必须显式写出 dyn,否则 rust 会自动推导到 impl 从而认为 if else 返回类型不一致
|
||||
Box::pin(Dynamic::from(submission).into_video_stream()) as Pin<Box<dyn Stream<Item = _> + Send + 'a>>
|
||||
} else {
|
||||
Box::pin(submission.into_video_stream())
|
||||
};
|
||||
Ok((updated_model.into(), video_stream))
|
||||
}
|
||||
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use crate::adapter::{_ActiveModel, VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BiliClient, VideoInfo, WatchLater};
|
||||
use crate::bilibili::{BiliClient, Credential, VideoInfo, WatchLater};
|
||||
|
||||
impl VideoSource for watch_later::Model {
|
||||
fn display_name(&self) -> std::borrow::Cow<'static, str> {
|
||||
@@ -49,12 +49,18 @@ impl VideoSource for watch_later::Model {
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
_connection: &'a DatabaseConnection,
|
||||
) -> Result<(
|
||||
VideoSourceEnum,
|
||||
Pin<Box<dyn Stream<Item = Result<VideoInfo>> + Send + 'a>>,
|
||||
)> {
|
||||
let watch_later = WatchLater::new(bili_client);
|
||||
let watch_later = WatchLater::new(bili_client, credential);
|
||||
Ok((self.into(), Box::pin(watch_later.into_video_stream())))
|
||||
}
|
||||
|
||||
async fn delete_from_db(self, conn: &impl ConnectionTrait) -> Result<()> {
|
||||
self.delete(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,103 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use sea_orm::{ConnectionTrait, DatabaseTransaction};
|
||||
use itertools::Itertools;
|
||||
use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction};
|
||||
|
||||
use crate::api::response::{PageInfo, VideoInfo};
|
||||
use crate::api::request::StatusFilter;
|
||||
use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo};
|
||||
use crate::utils::status::VideoStatus;
|
||||
|
||||
pub async fn update_video_download_status(
|
||||
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);
|
||||
}
|
||||
|
||||
pub trait PageRecord {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32);
|
||||
}
|
||||
|
||||
impl VideoRecord for VideoInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoRecord for SimpleVideoInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRecord for PageInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRecord for SimplePageInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_video_download_status<T>(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: &[impl Borrow<VideoInfo>],
|
||||
videos: &[impl Borrow<T>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
) -> Result<(), sea_orm::DbErr>
|
||||
where
|
||||
T: VideoRecord,
|
||||
{
|
||||
if videos.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let videos = videos.iter().map(|v| v.borrow()).collect::<Vec<_>>();
|
||||
if let Some(size) = batch_size {
|
||||
for chunk in videos.chunks(size) {
|
||||
execute_video_update_batch(txn, chunk).await?;
|
||||
execute_video_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
} else {
|
||||
execute_video_update_batch(txn, &videos).await?;
|
||||
execute_video_update_batch(txn, videos.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_page_download_status(
|
||||
pub async fn update_page_download_status<T>(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: &[impl Borrow<PageInfo>],
|
||||
pages: &[impl Borrow<T>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
) -> Result<(), sea_orm::DbErr>
|
||||
where
|
||||
T: PageRecord,
|
||||
{
|
||||
if pages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let pages = pages.iter().map(|v| v.borrow()).collect::<Vec<_>>();
|
||||
if let Some(size) = batch_size {
|
||||
for chunk in pages.chunks(size) {
|
||||
execute_page_update_batch(txn, chunk).await?;
|
||||
execute_page_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
} else {
|
||||
execute_page_update_batch(txn, &pages).await?;
|
||||
execute_page_update_batch(txn, pages.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
if videos.is_empty() {
|
||||
async fn execute_video_update_batch(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: impl Iterator<Item = (i32, u32)>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
let values = videos.map(|v| format!("({}, {})", v.0, v.1)).join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = format!(
|
||||
@@ -52,18 +106,18 @@ async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoI
|
||||
SET download_status = tempdata.download_status \
|
||||
FROM tempdata \
|
||||
WHERE video.id = tempdata.id",
|
||||
videos
|
||||
.iter()
|
||||
.map(|v| format!("({}, {})", v.id, v.download_status))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
values
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
if pages.is_empty() {
|
||||
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)).join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = format!(
|
||||
@@ -72,11 +126,7 @@ async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo
|
||||
SET download_status = tempdata.download_status \
|
||||
FROM tempdata \
|
||||
WHERE page.id = tempdata.id",
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| format!("({}, {})", p.id, p.download_status))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
values
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,12 +19,25 @@ 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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetRequest {
|
||||
pub struct ResetVideoStatusRequest {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetFilteredVideoStatusRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
pub submission: Option<i32>,
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub status_filter: Option<StatusFilter>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
@@ -46,6 +67,22 @@ pub struct UpdateVideoStatusRequest {
|
||||
pub page_updates: Vec<PageStatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateFilteredVideoStatusRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
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>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub page_updates: Vec<StatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowedCollectionsRequest {
|
||||
pub page_num: Option<i32>,
|
||||
@@ -56,6 +93,7 @@ pub struct FollowedCollectionsRequest {
|
||||
pub struct FollowedUppersRequest {
|
||||
pub page_num: Option<i32>,
|
||||
pub page_size: Option<i32>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
@@ -83,9 +121,21 @@ pub struct InsertSubmissionRequest {
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateVideoSourceRequest {
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub rule: Option<Rule>,
|
||||
pub use_dynamic_api: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct DefaultPathRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PollQrcodeRequest {
|
||||
pub qrcode_key: String,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use bili_sync_entity::*;
|
||||
use sea_orm::{DerivePartialModel, FromQueryResult};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::bilibili::{PollStatus, Qrcode};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -33,7 +34,13 @@ pub struct ResetVideoResponse {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResetAllVideosResponse {
|
||||
pub struct ClearAndResetVideoStatusResponse {
|
||||
pub warning: Option<String>,
|
||||
pub video: VideoInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResetFilteredVideosResponse {
|
||||
pub resetted: bool,
|
||||
pub resetted_videos_count: usize,
|
||||
pub resetted_pages_count: usize,
|
||||
@@ -46,6 +53,13 @@ pub struct UpdateVideoStatusResponse {
|
||||
pub pages: Vec<PageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateFilteredVideoStatusResponse {
|
||||
pub success: bool,
|
||||
pub updated_videos_count: usize,
|
||||
pub updated_pages_count: usize,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Serialize)]
|
||||
pub struct VideoSource {
|
||||
pub id: i32,
|
||||
@@ -59,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,
|
||||
@@ -75,6 +90,21 @@ pub struct PageInfo {
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)]
|
||||
#[sea_orm(entity = "video::Entity")]
|
||||
pub struct SimpleVideoInfo {
|
||||
pub id: i32,
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)]
|
||||
#[sea_orm(entity = "page::Entity")]
|
||||
pub struct SimplePageInfo {
|
||||
pub id: i32,
|
||||
pub video_id: i32,
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
fn serde_video_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
@@ -92,47 +122,48 @@ where
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FavoriteWithSubscriptionStatus {
|
||||
pub title: String,
|
||||
pub media_count: i64,
|
||||
pub fid: i64,
|
||||
pub mid: i64,
|
||||
pub subscribed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CollectionWithSubscriptionStatus {
|
||||
pub title: String,
|
||||
pub sid: i64,
|
||||
pub mid: i64,
|
||||
pub invalid: bool,
|
||||
pub subscribed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpperWithSubscriptionStatus {
|
||||
pub mid: i64,
|
||||
pub uname: String,
|
||||
pub face: String,
|
||||
pub sign: String,
|
||||
pub invalid: bool,
|
||||
pub subscribed: bool,
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Followed {
|
||||
Favorite {
|
||||
title: String,
|
||||
media_count: i64,
|
||||
fid: i64,
|
||||
mid: i64,
|
||||
invalid: bool,
|
||||
subscribed: bool,
|
||||
},
|
||||
Collection {
|
||||
title: String,
|
||||
sid: i64,
|
||||
mid: i64,
|
||||
media_count: i64,
|
||||
invalid: bool,
|
||||
subscribed: bool,
|
||||
},
|
||||
Upper {
|
||||
mid: i64,
|
||||
uname: String,
|
||||
face: String,
|
||||
sign: String,
|
||||
invalid: bool,
|
||||
subscribed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FavoritesResponse {
|
||||
pub favorites: Vec<FavoriteWithSubscriptionStatus>,
|
||||
pub favorites: Vec<Followed>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CollectionsResponse {
|
||||
pub collections: Vec<CollectionWithSubscriptionStatus>,
|
||||
pub collections: Vec<Followed>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UppersResponse {
|
||||
pub uppers: Vec<UpperWithSubscriptionStatus>,
|
||||
pub uppers: Vec<Followed>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
@@ -159,8 +190,9 @@ pub struct DashBoardResponse {
|
||||
pub videos_by_day: Vec<DayCountPair>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Clone, Copy)]
|
||||
pub struct SysInfo {
|
||||
pub timestamp: i64,
|
||||
pub total_memory: u64,
|
||||
pub used_memory: u64,
|
||||
pub process_memory: u64,
|
||||
@@ -179,6 +211,8 @@ pub struct VideoSourceDetail {
|
||||
pub rule: Option<Rule>,
|
||||
#[serde(default)]
|
||||
pub rule_display: Option<String>,
|
||||
#[serde(default)]
|
||||
pub use_dynamic_api: Option<bool>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
@@ -187,3 +221,7 @@ pub struct VideoSourceDetail {
|
||||
pub struct UpdateVideoSourceResponse {
|
||||
pub rule_display: Option<String>,
|
||||
}
|
||||
|
||||
pub type GenerateQrcodeResponse = Qrcode;
|
||||
|
||||
pub type PollQrcodeResponse = PollStatus;
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::Extension;
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::{Config, VersionedConfig};
|
||||
use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
|
||||
use crate::notifier::Notifier;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new().route("/config", get(get_config).put(update_config))
|
||||
Router::new()
|
||||
.route("/config", get(get_config).put(update_config))
|
||||
.route("/config/notifiers/ping", post(ping_notifiers))
|
||||
}
|
||||
|
||||
/// 获取全局配置
|
||||
pub async fn get_config() -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||
Ok(ApiResponse::ok(VersionedConfig::get().load_full()))
|
||||
Ok(ApiResponse::ok(VersionedConfig::get().snapshot()))
|
||||
}
|
||||
|
||||
/// 更新全局配置
|
||||
@@ -25,12 +27,21 @@ pub async fn update_config(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(config): ValidatedJson<Config>,
|
||||
) -> Result<ApiResponse<Arc<Config>>, ApiError> {
|
||||
let Some(_lock) = TASK_STATUS_NOTIFIER.detect_running() else {
|
||||
// 简单避免一下可能的不一致现象
|
||||
return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into());
|
||||
};
|
||||
config.check()?;
|
||||
let new_config = VersionedConfig::get().update(config, &db).await?;
|
||||
drop(_lock);
|
||||
Ok(ApiResponse::ok(new_config))
|
||||
}
|
||||
|
||||
pub async fn ping_notifiers(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Json(mut notifier): Json<Notifier>,
|
||||
) -> Result<ApiResponse<()>, ApiError> {
|
||||
// 对于 webhook 类型的通知器测试,设置上 ignore_cache tag 以强制实时渲染
|
||||
if let Notifier::Webhook { ignore_cache, .. } = &mut notifier {
|
||||
*ignore_cache = Some(());
|
||||
}
|
||||
notifier
|
||||
.notify(bili_client.inner_client(), "This is a test notification from BiliSync.")
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(()))
|
||||
}
|
||||
|
||||
34
crates/bili_sync/src/api/routes/login/mod.rs
Normal file
34
crates/bili_sync/src/api/routes/login/mod.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use crate::api::request::PollQrcodeRequest;
|
||||
use crate::api::response::{GenerateQrcodeResponse, PollQrcodeResponse};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::bilibili::{BiliClient, Credential};
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/login/qrcode/generate", post(generate_qrcode))
|
||||
.route("/login/qrcode/poll", get(poll_qrcode))
|
||||
}
|
||||
|
||||
/// 生成扫码登录二维码
|
||||
pub async fn generate_qrcode(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
) -> Result<ApiResponse<GenerateQrcodeResponse>, ApiError> {
|
||||
Ok(ApiResponse::ok(Credential::generate_qrcode(&bili_client.client).await?))
|
||||
}
|
||||
|
||||
/// 轮询扫码登录状态
|
||||
pub async fn poll_qrcode(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<PollQrcodeRequest>,
|
||||
) -> Result<ApiResponse<PollQrcodeResponse>, ApiError> {
|
||||
Ok(ApiResponse::ok(
|
||||
Credential::poll_qrcode(&bili_client.client, ¶ms.qrcode_key).await?,
|
||||
))
|
||||
}
|
||||
@@ -6,15 +6,14 @@ use axum::Router;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::routing::get;
|
||||
use bili_sync_entity::*;
|
||||
use itertools::{Either, Itertools};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect};
|
||||
|
||||
use crate::api::request::{FollowedCollectionsRequest, FollowedUppersRequest};
|
||||
use crate::api::response::{
|
||||
CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse,
|
||||
UpperWithSubscriptionStatus, UppersResponse,
|
||||
};
|
||||
use crate::api::response::{CollectionsResponse, FavoritesResponse, Followed, UppersResponse};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::bilibili::{BiliClient, Me};
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
@@ -28,31 +27,33 @@ pub async fn get_created_favorites(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
) -> Result<ApiResponse<FavoritesResponse>, ApiError> {
|
||||
let me = Me::new(bili_client.as_ref());
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let me = Me::new(bili_client.as_ref(), credential);
|
||||
let bili_favorites = me.get_created_favorites().await?;
|
||||
|
||||
let favorites = if let Some(bili_favorites) = bili_favorites {
|
||||
// b 站收藏夹相关接口使用的所谓“fid”其实是该处的 id,即 fid + mid 后两位
|
||||
let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect();
|
||||
|
||||
let subscribed_fids: Vec<i64> = favorite::Entity::find()
|
||||
let subscribed_fids: HashSet<i64> = favorite::Entity::find()
|
||||
.select_only()
|
||||
.column(favorite::Column::FId)
|
||||
.filter(favorite::Column::FId.is_in(bili_fids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?;
|
||||
let subscribed_set: HashSet<i64> = subscribed_fids.into_iter().collect();
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
bili_favorites
|
||||
.into_iter()
|
||||
.map(|fav| FavoriteWithSubscriptionStatus {
|
||||
.map(|fav| Followed::Favorite {
|
||||
title: fav.title,
|
||||
media_count: fav.media_count,
|
||||
// api 返回的 id 才是真实的 fid
|
||||
fid: fav.id,
|
||||
mid: fav.mid,
|
||||
subscribed: subscribed_set.contains(&fav.id),
|
||||
invalid: false,
|
||||
subscribed: subscribed_fids.contains(&fav.id),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
@@ -62,36 +63,75 @@ pub async fn get_created_favorites(
|
||||
Ok(ApiResponse::ok(FavoritesResponse { favorites }))
|
||||
}
|
||||
|
||||
/// 获取当前用户收藏的合集
|
||||
/// 获取当前用户收藏的合集/收藏夹
|
||||
pub async fn get_followed_collections(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<FollowedCollectionsRequest>,
|
||||
) -> Result<ApiResponse<CollectionsResponse>, ApiError> {
|
||||
let me = Me::new(bili_client.as_ref());
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let me = Me::new(bili_client.as_ref(), credential);
|
||||
let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(50));
|
||||
let bili_collections = me.get_followed_collections(page_num, page_size).await?;
|
||||
|
||||
let collections = if let Some(collection_list) = bili_collections.list {
|
||||
let bili_sids: Vec<_> = collection_list.iter().map(|col| col.id).collect();
|
||||
|
||||
let subscribed_ids: Vec<i64> = collection::Entity::find()
|
||||
.select_only()
|
||||
.column(collection::Column::SId)
|
||||
.filter(collection::Column::SId.is_in(bili_sids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?;
|
||||
let subscribed_set: HashSet<i64> = subscribed_ids.into_iter().collect();
|
||||
|
||||
// collection_list 中的条目可能是合集或者收藏夹,需要分类处理
|
||||
// 目前看下来,最显著的区别是合集的 fid 是 0
|
||||
let (bili_fids, bili_sids): (Vec<_>, Vec<_>) = collection_list.iter().partition_map(|col| {
|
||||
if col.fid != 0 {
|
||||
Either::Left(col.id)
|
||||
} else {
|
||||
Either::Right(col.id)
|
||||
}
|
||||
});
|
||||
let (subscribed_fids, subscribed_sids): (HashSet<i64>, HashSet<i64>) = tokio::try_join!(
|
||||
async {
|
||||
Result::<_, anyhow::Error>::Ok(
|
||||
favorite::Entity::find()
|
||||
.select_only()
|
||||
.column(favorite::Column::FId)
|
||||
.filter(favorite::Column::FId.is_in(bili_fids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
},
|
||||
async {
|
||||
Ok(collection::Entity::find()
|
||||
.select_only()
|
||||
.column(collection::Column::SId)
|
||||
.filter(collection::Column::SId.is_in(bili_sids))
|
||||
.into_tuple()
|
||||
.all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
)?;
|
||||
collection_list
|
||||
.into_iter()
|
||||
.map(|col| CollectionWithSubscriptionStatus {
|
||||
title: col.title,
|
||||
sid: col.id,
|
||||
mid: col.mid,
|
||||
invalid: col.state == 1,
|
||||
subscribed: subscribed_set.contains(&col.id),
|
||||
.map(|col| {
|
||||
if col.fid != 0 {
|
||||
Followed::Favorite {
|
||||
title: col.title,
|
||||
media_count: col.media_count,
|
||||
fid: col.id,
|
||||
mid: col.mid,
|
||||
invalid: col.state == 1,
|
||||
subscribed: subscribed_fids.contains(&col.id),
|
||||
}
|
||||
} else {
|
||||
Followed::Collection {
|
||||
title: col.title,
|
||||
sid: col.id,
|
||||
mid: col.mid,
|
||||
media_count: col.media_count,
|
||||
invalid: col.state == 1,
|
||||
subscribed: subscribed_sids.contains(&col.id),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
@@ -110,9 +150,12 @@ pub async fn get_followed_uppers(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
Query(params): Query<FollowedUppersRequest>,
|
||||
) -> Result<ApiResponse<UppersResponse>, ApiError> {
|
||||
let me = Me::new(bili_client.as_ref());
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let me = Me::new(bili_client.as_ref(), credential);
|
||||
let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(20));
|
||||
let bili_uppers = me.get_followed_uppers(page_num, page_size).await?;
|
||||
let bili_uppers = me
|
||||
.get_followed_uppers(page_num, page_size, params.name.as_deref())
|
||||
.await?;
|
||||
|
||||
let bili_uid: Vec<_> = bili_uppers.list.iter().map(|upper| upper.mid).collect();
|
||||
|
||||
@@ -128,7 +171,7 @@ pub async fn get_followed_uppers(
|
||||
let uppers = bili_uppers
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|upper| UpperWithSubscriptionStatus {
|
||||
.map(|upper| Followed::Upper {
|
||||
mid: upper.mid,
|
||||
// 官方没有提供字段,但是可以使用这种方式简单判断下
|
||||
invalid: upper.uname == "账号已注销" && upper.face == "https://i0.hdslb.com/bfs/face/member/noface.jpg",
|
||||
|
||||
@@ -12,7 +12,9 @@ use crate::config::VersionedConfig;
|
||||
|
||||
mod config;
|
||||
mod dashboard;
|
||||
mod login;
|
||||
mod me;
|
||||
mod task;
|
||||
mod video_sources;
|
||||
mod videos;
|
||||
mod ws;
|
||||
@@ -24,17 +26,19 @@ pub fn router() -> Router {
|
||||
"/api",
|
||||
config::router()
|
||||
.merge(me::router())
|
||||
.merge(login::router())
|
||||
.merge(video_sources::router())
|
||||
.merge(videos::router())
|
||||
.merge(dashboard::router())
|
||||
.merge(ws::router())
|
||||
.merge(task::router())
|
||||
.layer(middleware::from_fn(auth)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 中间件:使用 auth token 对请求进行身份验证
|
||||
pub async fn auth(mut headers: HeaderMap, request: Request, next: Next) -> Result<Response, StatusCode> {
|
||||
let config = VersionedConfig::get().load();
|
||||
let config = VersionedConfig::get().read();
|
||||
let token = config.auth_token.as_str();
|
||||
if headers
|
||||
.get("Authorization")
|
||||
|
||||
15
crates/bili_sync/src/api/routes/task/mod.rs
Normal file
15
crates/bili_sync/src/api/routes/task/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::routing::post;
|
||||
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::task::DownloadTaskManager;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new().route("/task/download", post(new_download_task))
|
||||
}
|
||||
|
||||
pub async fn new_download_task() -> Result<ApiResponse<bool>, ApiError> {
|
||||
DownloadTaskManager::get().download_once().await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
@@ -2,32 +2,42 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
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, TransactionTrait};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTrait, TransactionTrait};
|
||||
|
||||
use crate::adapter::_ActiveModel;
|
||||
use crate::adapter::{_ActiveModel, VideoSource as _, VideoSourceEnum};
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::request::{
|
||||
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
|
||||
DefaultPathRequest, InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest,
|
||||
UpdateVideoSourceRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||
use crate::config::{PathSafeTemplate, TEMPLATE, VersionedConfig};
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/video-sources", get(get_video_sources))
|
||||
.route("/video-sources/details", get(get_video_sources_details))
|
||||
.route("/video-sources/{type}/{id}", put(update_video_source))
|
||||
.route(
|
||||
"/video-sources/{type}/default-path",
|
||||
get(get_video_sources_default_path),
|
||||
) // 仅用于前端获取默认路径
|
||||
.route(
|
||||
"/video-sources/{type}/{id}",
|
||||
put(update_video_source).delete(remove_video_source),
|
||||
)
|
||||
.route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source))
|
||||
.route("/video-sources/favorites", post(insert_favorite))
|
||||
.route("/video-sources/collections", post(insert_collection))
|
||||
@@ -111,7 +121,8 @@ pub async fn get_video_sources_details(
|
||||
submission::Column::Id,
|
||||
submission::Column::Path,
|
||||
submission::Column::Enabled,
|
||||
submission::Column::Rule
|
||||
submission::Column::Rule,
|
||||
submission::Column::UseDynamicApi
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(&db),
|
||||
@@ -134,6 +145,7 @@ pub async fn get_video_sources_details(
|
||||
path: String::new(),
|
||||
rule: None,
|
||||
rule_display: None,
|
||||
use_dynamic_api: None,
|
||||
enabled: false,
|
||||
})
|
||||
}
|
||||
@@ -152,6 +164,22 @@ pub async fn get_video_sources_details(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_video_sources_default_path(
|
||||
Path(source_type): Path<String>,
|
||||
Query(params): Query<DefaultPathRequest>,
|
||||
) -> Result<ApiResponse<String>, ApiError> {
|
||||
let template_name = match source_type.as_str() {
|
||||
"favorites" => "favorite_default_path",
|
||||
"collections" => "collection_default_path",
|
||||
"submissions" => "submission_default_path",
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let template = TEMPLATE.read();
|
||||
Ok(ApiResponse::ok(
|
||||
template.path_safe_render(template_name, &serde_json::to_value(params)?)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 更新视频来源
|
||||
pub async fn update_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
@@ -179,6 +207,9 @@ pub async fn update_video_source(
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
if let Some(use_dynamic_api) = request.use_dynamic_api {
|
||||
active_model.use_dynamic_api = Set(use_dynamic_api);
|
||||
}
|
||||
_ActiveModel::Submission(active_model)
|
||||
}),
|
||||
"watch_later" => match watch_later::Entity::find_by_id(id).one(&db).await? {
|
||||
@@ -215,6 +246,43 @@ pub async fn update_video_source(
|
||||
Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display }))
|
||||
}
|
||||
|
||||
pub async fn remove_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
// 不允许删除稍后再看
|
||||
let video_source: Option<VideoSourceEnum> = match source_type.as_str() {
|
||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(Into::into),
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let Some(video_source) = video_source else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let txn = db.begin().await?;
|
||||
page::Entity::delete_many()
|
||||
.filter(
|
||||
page::Column::VideoId.in_subquery(
|
||||
video::Entity::find()
|
||||
.filter(video_source.filter_expr())
|
||||
.select_only()
|
||||
.column(video::Column::Id)
|
||||
.as_query()
|
||||
.to_owned(),
|
||||
),
|
||||
)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
video::Entity::delete_many()
|
||||
.filter(video_source.filter_expr())
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
video_source.delete_from_db(&txn).await?;
|
||||
txn.commit().await?;
|
||||
Ok(ApiResponse::ok(true))
|
||||
}
|
||||
|
||||
pub async fn evaluate_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
@@ -280,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?;
|
||||
}
|
||||
@@ -298,7 +362,8 @@ pub async fn insert_favorite(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertFavoriteRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string());
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string(), credential);
|
||||
let favorite_info = favorite.get_info().await?;
|
||||
favorite::Entity::insert(favorite::ActiveModel {
|
||||
f_id: Set(favorite_info.id),
|
||||
@@ -318,6 +383,7 @@ pub async fn insert_collection(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertCollectionRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let collection = Collection::new(
|
||||
bili_client.as_ref(),
|
||||
CollectionItem {
|
||||
@@ -325,6 +391,7 @@ pub async fn insert_collection(
|
||||
mid: request.mid.to_string(),
|
||||
collection_type: request.collection_type,
|
||||
},
|
||||
credential,
|
||||
);
|
||||
let collection_info = collection.get_info().await?;
|
||||
collection::Entity::insert(collection::ActiveModel {
|
||||
@@ -348,7 +415,8 @@ pub async fn insert_submission(
|
||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
|
||||
) -> Result<ApiResponse<bool>, ApiError> {
|
||||
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string());
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string(), credential);
|
||||
let upper = submission.get_info().await?;
|
||||
submission::Entity::insert(submission::ActiveModel {
|
||||
upper_id: Set(upper.mid.parse()?),
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, TransactionTrait,
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, TransactionTrait, TryIntoModel,
|
||||
};
|
||||
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
||||
use crate::api::request::{ResetRequest, UpdateVideoStatusRequest, VideosRequest};
|
||||
use crate::api::request::{
|
||||
ResetFilteredVideoStatusRequest, ResetVideoStatusRequest, UpdateFilteredVideoStatusRequest,
|
||||
UpdateVideoStatusRequest, VideosRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
|
||||
ClearAndResetVideoStatusResponse, PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo,
|
||||
SimpleVideoInfo, UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
|
||||
VideosResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
@@ -23,9 +29,14 @@ pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/videos", get(get_videos))
|
||||
.route("/videos/{id}", get(get_video))
|
||||
.route("/videos/{id}/reset", post(reset_video))
|
||||
.route("/videos/reset-all", post(reset_all_videos))
|
||||
.route(
|
||||
"/videos/{id}/clear-and-reset-status",
|
||||
post(clear_and_reset_video_status),
|
||||
)
|
||||
.route("/videos/{id}/reset-status", post(reset_video_status))
|
||||
.route("/videos/{id}/update-status", post(update_video_status))
|
||||
.route("/videos/reset-status", post(reset_filtered_video_status))
|
||||
.route("/videos/update-status", post(update_filtered_video_status))
|
||||
}
|
||||
|
||||
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
|
||||
@@ -45,7 +56,14 @@ pub async fn get_videos(
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = params.query {
|
||||
query = query.filter(video::Column::Name.contains(query_word));
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.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) {
|
||||
@@ -85,10 +103,10 @@ pub async fn get_video(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_video(
|
||||
pub async fn reset_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetRequest>,
|
||||
Json(request): Json<ResetVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
||||
let (video_info, pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
@@ -130,7 +148,7 @@ pub async fn reset_video(
|
||||
let txn = db.begin().await?;
|
||||
if !resetted_videos_info.is_empty() {
|
||||
// 只可能有 1 个元素,所以不用 batch
|
||||
update_video_download_status(&txn, &resetted_videos_info, None).await?;
|
||||
update_video_download_status::<VideoInfo>(&txn, &resetted_videos_info, None).await?;
|
||||
}
|
||||
if !resetted_pages_info.is_empty() {
|
||||
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
||||
@@ -144,15 +162,76 @@ pub async fn reset_video(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_all_videos(
|
||||
pub async fn clear_and_reset_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetRequest>,
|
||||
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
|
||||
// 先查询所有视频和页面数据
|
||||
let (all_videos, all_pages) = tokio::try_join!(
|
||||
video::Entity::find().into_partial_model::<VideoInfo>().all(&db),
|
||||
page::Entity::find().into_partial_model::<PageInfo>().all(&db)
|
||||
)?;
|
||||
) -> Result<ApiResponse<ClearAndResetVideoStatusResponse>, ApiError> {
|
||||
let video_info = video::Entity::find_by_id(id).one(&db).await?;
|
||||
let Some(video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let txn = db.begin().await?;
|
||||
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))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
let video_info = video_info.try_into_model()?;
|
||||
let warning = tokio::fs::remove_dir_all(&video_info.path)
|
||||
.await
|
||||
.context(format!("删除本地路径「{}」失败", video_info.path))
|
||||
.err()
|
||||
.map(|e| format!("{:#}", e));
|
||||
Ok(ApiResponse::ok(ClearAndResetVideoStatusResponse {
|
||||
warning,
|
||||
video: VideoInfo {
|
||||
id: video_info.id,
|
||||
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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_filtered_video_status(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetFilteredVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetFilteredVideosResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(request.collection, video::Column::CollectionId),
|
||||
(request.favorite, video::Column::FavoriteId),
|
||||
(request.submission, video::Column::SubmissionId),
|
||||
(request.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = request.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.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)))
|
||||
.into_partial_model::<SimplePageInfo>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
let resetted_pages_info = all_pages
|
||||
.into_iter()
|
||||
.filter_map(|mut page_info| {
|
||||
@@ -196,7 +275,7 @@ pub async fn reset_all_videos(
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(ResetAllVideosResponse {
|
||||
Ok(ApiResponse::ok(ResetFilteredVideosResponse {
|
||||
resetted: has_video_updates || has_page_updates,
|
||||
resetted_videos_count: resetted_videos_info.len(),
|
||||
resetted_pages_count: resetted_pages_info.len(),
|
||||
@@ -244,10 +323,10 @@ pub async fn update_video_status(
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status(&txn, &[&video_info], None).await?;
|
||||
update_video_download_status::<VideoInfo>(&txn, &[&video_info], None).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &updated_pages_info, None).await?;
|
||||
update_page_download_status::<PageInfo>(&txn, &updated_pages_info, None).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
@@ -257,3 +336,67 @@ pub async fn update_video_status(
|
||||
pages: pages_info,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_filtered_video_status(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateFilteredVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<UpdateFilteredVideoStatusResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(request.collection, video::Column::CollectionId),
|
||||
(request.favorite, video::Column::FavoriteId),
|
||||
(request.submission, video::Column::SubmissionId),
|
||||
(request.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = request.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.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)))
|
||||
.into_partial_model::<SimplePageInfo>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
for video_info in all_videos.iter_mut() {
|
||||
let mut video_status = VideoStatus::from(video_info.download_status);
|
||||
for update in &request.video_updates {
|
||||
video_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
video_info.download_status = video_status.into();
|
||||
}
|
||||
for page_info in all_pages.iter_mut() {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
for update in &request.page_updates {
|
||||
page_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
page_info.download_status = page_status.into();
|
||||
}
|
||||
let has_video_updates = !all_videos.is_empty();
|
||||
let has_page_updates = !all_pages.is_empty();
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status(&txn, &all_videos, Some(500)).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &all_pages, Some(500)).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(UpdateFilteredVideoStatusResponse {
|
||||
success: has_video_updates || has_page_updates,
|
||||
updated_videos_count: all_videos.len(),
|
||||
updated_pages_count: all_pages.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
pub const MAX_HISTORY_LOGS: usize = 30;
|
||||
pub const MAX_HISTORY_LOGS: usize = 200;
|
||||
|
||||
/// LogHelper 维护了日志发送器和一个日志历史记录的缓冲区
|
||||
pub struct LogHelper {
|
||||
pub sender: broadcast::Sender<String>,
|
||||
pub log_history: Arc<Mutex<VecDeque<String>>>,
|
||||
pub log_history: Arc<RwLock<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl LogHelper {
|
||||
pub fn new(sender: broadcast::Sender<String>, log_history: Arc<Mutex<VecDeque<String>>>) -> Self {
|
||||
pub fn new(sender: broadcast::Sender<String>, log_history: Arc<RwLock<VecDeque<String>>>) -> Self {
|
||||
LogHelper { sender, log_history }
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ impl std::io::Write for LogHelper {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let log_message = String::from_utf8_lossy(buf).to_string();
|
||||
let _ = self.sender.send(log_message.clone());
|
||||
let mut history = self.log_history.lock();
|
||||
let mut history = self.log_history.write();
|
||||
history.push_back(log_message);
|
||||
if history.len() > MAX_HISTORY_LOGS {
|
||||
history.pop_front();
|
||||
|
||||
@@ -11,19 +11,23 @@ use axum::{Extension, Router};
|
||||
use dashmap::DashMap;
|
||||
use futures::stream::{SplitSink, SplitStream};
|
||||
use futures::{SinkExt, StreamExt, future};
|
||||
use itertools::Itertools;
|
||||
pub use log_helper::{LogHelper, MAX_HISTORY_LOGS};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sysinfo::{
|
||||
CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, ProcessRefreshKind, RefreshKind, System, get_current_pid,
|
||||
CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System,
|
||||
get_current_pid,
|
||||
};
|
||||
use tokio::pin;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_stream::wrappers::{BroadcastStream, IntervalStream, WatchStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::{pin, select};
|
||||
use tokio_stream::wrappers::{BroadcastStream, WatchStream};
|
||||
use tokio_util::future::FutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::response::SysInfo;
|
||||
use crate::utils::task_notifier::{TASK_STATUS_NOTIFIER, TaskStatus};
|
||||
use crate::task::{DownloadTaskManager, TaskStatus};
|
||||
|
||||
static WEBSOCKET_HANDLER: LazyLock<WebSocketHandler> = LazyLock::new(WebSocketHandler::new);
|
||||
|
||||
@@ -55,191 +59,251 @@ enum ClientEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum ServerEvent {
|
||||
Logs(String),
|
||||
Tasks(Arc<TaskStatus>),
|
||||
SysInfo(Arc<SysInfo>),
|
||||
Tasks(TaskStatus),
|
||||
SysInfo(SysInfo),
|
||||
}
|
||||
|
||||
struct WebSocketHandler {
|
||||
sysinfo_subscribers: Arc<DashMap<Uuid, tokio::sync::mpsc::Sender<ServerEvent>>>,
|
||||
sysinfo_handles: RwLock<Option<JoinHandle<()>>>,
|
||||
sysinfo_subscribers: Arc<DashMap<Uuid, mpsc::Sender<ServerEvent>>>,
|
||||
sysinfo_cancel: RwLock<Option<CancellationToken>>,
|
||||
}
|
||||
|
||||
impl WebSocketHandler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sysinfo_subscribers: Arc::new(DashMap::new()),
|
||||
sysinfo_handles: RwLock::new(None),
|
||||
sysinfo_cancel: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_sender(
|
||||
&self,
|
||||
mut sender: SplitSink<WebSocket, Message>,
|
||||
mut rx: tokio::sync::mpsc::Receiver<ServerEvent>,
|
||||
) {
|
||||
/// 向客户端推送信息
|
||||
async fn handle_sender(&self, mut sender: SplitSink<WebSocket, Message>, mut rx: mpsc::Receiver<ServerEvent>) {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(text) => {
|
||||
if let Err(e) = sender.send(Message::Text(text.into())).await {
|
||||
error!("Failed to send message: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text = match serde_json::to_string(&event) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!("Failed to serialize event: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = sender.send(Message::Text(text.into())).await {
|
||||
error!("Failed to send message: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从客户端接收信息
|
||||
async fn handle_receiver(
|
||||
&self,
|
||||
mut receiver: SplitStream<WebSocket>,
|
||||
tx: tokio::sync::mpsc::Sender<ServerEvent>,
|
||||
tx: mpsc::Sender<ServerEvent>,
|
||||
uuid: Uuid,
|
||||
log_writer: LogHelper,
|
||||
) {
|
||||
// 日志和任务状态的处理本身就是由 stream 驱动的,可以直接为每个 ws 连接维护独立的任务处理器
|
||||
// 系统信息是服务端轮询然后推送的,如果单独维护会导致每个连接都独立轮询系统信息,造成不必要的浪费
|
||||
// 因此采用了全局的订阅者管理,所有连接共享同一个系统信息轮询任务
|
||||
let (mut log_handle, mut task_handle) = (None, None);
|
||||
let (mut log_cancel, mut task_cancel) = (None, None);
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
match serde_json::from_str::<ClientEvent>(&text) {
|
||||
Ok(ClientEvent::Subscribe(event_type)) => match event_type {
|
||||
EventType::Logs => {
|
||||
if log_handle.as_ref().is_none_or(|h: &JoinHandle<()>| h.is_finished()) {
|
||||
let log_writer_clone = log_writer.clone();
|
||||
let tx_clone = tx.clone();
|
||||
let history = log_writer_clone.log_history.lock();
|
||||
let history_logs: Vec<String> = history.iter().cloned().collect();
|
||||
drop(history);
|
||||
log_handle = Some(tokio::spawn(async move {
|
||||
let rx = log_writer_clone.sender.subscribe();
|
||||
let log_stream = futures::stream::iter(history_logs.into_iter())
|
||||
.chain(BroadcastStream::new(rx).filter_map(async |msg| msg.ok()))
|
||||
.map(ServerEvent::Logs);
|
||||
pin!(log_stream);
|
||||
while let Some(event) = log_stream.next().await {
|
||||
if let Err(e) = tx_clone.send(event).await {
|
||||
error!("Failed to send log event: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
EventType::Tasks => {
|
||||
if task_handle.as_ref().is_none_or(|h: &JoinHandle<()>| h.is_finished()) {
|
||||
let tx_clone = tx.clone();
|
||||
task_handle = Some(tokio::spawn(async move {
|
||||
let mut stream =
|
||||
WatchStream::new(TASK_STATUS_NOTIFIER.subscribe()).map(ServerEvent::Tasks);
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Err(e) = tx_clone.send(event).await {
|
||||
error!("Failed to send task status: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
EventType::SysInfo => self.add_sysinfo_subscriber(uuid, tx.clone()).await,
|
||||
},
|
||||
Ok(ClientEvent::Unsubscribe(event_type)) => match event_type {
|
||||
EventType::Logs => {
|
||||
if let Some(handle) = log_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
EventType::Tasks => {
|
||||
if let Some(handle) = task_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
EventType::SysInfo => {
|
||||
self.remove_sysinfo_subscriber(uuid).await;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to parse client message: {:?}", e);
|
||||
let Message::Text(text) = msg else {
|
||||
continue;
|
||||
};
|
||||
let client_event = match serde_json::from_str::<ClientEvent>(&text) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
error!("Failed to parse client message: {:?}, error: {:?}", text, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match client_event {
|
||||
ClientEvent::Subscribe(EventType::Logs) => {
|
||||
if log_cancel.is_none() {
|
||||
log_cancel = Some(self.new_log_handler(tx.clone(), &log_writer));
|
||||
}
|
||||
}
|
||||
ClientEvent::Unsubscribe(EventType::Logs) => {
|
||||
if let Some(cancel) = log_cancel.take() {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
ClientEvent::Subscribe(EventType::Tasks) => {
|
||||
if task_cancel.is_none() {
|
||||
task_cancel = Some(self.new_task_handler(tx.clone()));
|
||||
}
|
||||
}
|
||||
ClientEvent::Unsubscribe(EventType::Tasks) => {
|
||||
if let Some(cancel) = task_cancel.take() {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
ClientEvent::Subscribe(EventType::SysInfo) => {
|
||||
self.add_sysinfo_subscriber(uuid, tx.clone());
|
||||
}
|
||||
ClientEvent::Unsubscribe(EventType::SysInfo) => {
|
||||
self.remove_sysinfo_subscriber(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 连接关闭,清除仍然残留的任务
|
||||
if let Some(cancel) = log_cancel {
|
||||
cancel.cancel();
|
||||
}
|
||||
if let Some(cancel) = task_cancel {
|
||||
cancel.cancel();
|
||||
}
|
||||
self.remove_sysinfo_subscriber(uuid);
|
||||
}
|
||||
|
||||
/// 添加全局系统信息订阅者
|
||||
fn add_sysinfo_subscriber(&self, uuid: Uuid, sender: mpsc::Sender<ServerEvent>) {
|
||||
self.sysinfo_subscribers.insert(uuid, sender);
|
||||
if self.sysinfo_cancel.read().is_none() {
|
||||
let mut sys_info_cancel = self.sysinfo_cancel.write();
|
||||
if sys_info_cancel.is_some() {
|
||||
return;
|
||||
}
|
||||
*sys_info_cancel = Some(self.new_sysinfo_handler(self.sysinfo_subscribers.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除全局系统信息订阅者
|
||||
fn remove_sysinfo_subscriber(&self, uuid: Uuid) {
|
||||
self.sysinfo_subscribers.remove(&uuid);
|
||||
if self.sysinfo_subscribers.is_empty()
|
||||
&& let Some(token) = self.sysinfo_cancel.write().take()
|
||||
{
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建异步日志推送任务,返回任务的取消令牌
|
||||
fn new_log_handler(&self, tx: mpsc::Sender<ServerEvent>, log_writer: &LogHelper) -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
// 读取历史日志
|
||||
let history = log_writer.log_history.read();
|
||||
let history_logs = history.iter().cloned().collect::<Vec<String>>();
|
||||
drop(history);
|
||||
// 获取日志广播接收器
|
||||
let log_rx = log_writer.sender.subscribe();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
// 合并历史日志和实时日志流
|
||||
let log_stream = futures::stream::iter(history_logs)
|
||||
.chain(BroadcastStream::new(log_rx).filter_map(async |msg| msg.ok()))
|
||||
.map(ServerEvent::Logs);
|
||||
pin!(log_stream);
|
||||
while let Some(event) = log_stream.next().await {
|
||||
if let Err(e) = tx.send(event).await {
|
||||
error!("Failed to send log event: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(handle) = log_handle {
|
||||
handle.abort();
|
||||
}
|
||||
if let Some(handle) = task_handle {
|
||||
handle.abort();
|
||||
}
|
||||
self.remove_sysinfo_subscriber(uuid).await;
|
||||
.with_cancellation_token_owned(cancel_token.clone()),
|
||||
);
|
||||
cancel_token
|
||||
}
|
||||
|
||||
// 添加订阅者
|
||||
async fn add_sysinfo_subscriber(&self, uuid: Uuid, sender: tokio::sync::mpsc::Sender<ServerEvent>) {
|
||||
self.sysinfo_subscribers.insert(uuid, sender);
|
||||
if !self.sysinfo_subscribers.is_empty()
|
||||
&& self
|
||||
.sysinfo_handles
|
||||
.read()
|
||||
.as_ref()
|
||||
.is_none_or(|h: &JoinHandle<()>| h.is_finished())
|
||||
{
|
||||
let sysinfo_subscribers = self.sysinfo_subscribers.clone();
|
||||
let mut write_guard = self.sysinfo_handles.write();
|
||||
if write_guard.as_ref().is_some_and(|h: &JoinHandle<()>| !h.is_finished()) {
|
||||
return;
|
||||
/// 创建异步任务状态推送任务,返回任务的取消令牌
|
||||
fn new_task_handler(&self, tx: mpsc::Sender<ServerEvent>) -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
let mut stream = WatchStream::new(DownloadTaskManager::get().subscribe()).map(ServerEvent::Tasks);
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Err(e) = tx.send(event).await {
|
||||
error!("Failed to send task status: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
*write_guard = Some(tokio::spawn(async move {
|
||||
let mut system = System::new();
|
||||
let mut disks = Disks::new();
|
||||
let sys_refresh_kind = sys_refresh_kind();
|
||||
let disk_refresh_kind = disk_refresh_kind();
|
||||
.with_cancellation_token_owned(cancel_token.clone()),
|
||||
);
|
||||
cancel_token
|
||||
}
|
||||
|
||||
/// 创建异步系统信息推送任务,返回任务的取消令牌
|
||||
fn new_sysinfo_handler(
|
||||
&self,
|
||||
sysinfo_subscribers: Arc<DashMap<Uuid, mpsc::Sender<ServerEvent>>>,
|
||||
) -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
tokio::spawn(async move {
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
let (tick_tx, mut tick_rx) = mpsc::channel(3);
|
||||
// 在阻塞线程中轮询系统信息,防止阻塞异步运行时
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// 对于 linux/mac/windows 平台,该方法永远返回 Some(pid),expect 基本是安全的
|
||||
let self_pid = get_current_pid().expect("Unsupported platform");
|
||||
let mut stream =
|
||||
IntervalStream::new(tokio::time::interval(Duration::from_secs(2))).filter_map(move |_| {
|
||||
system.refresh_specifics(sys_refresh_kind);
|
||||
disks.refresh_specifics(true, disk_refresh_kind);
|
||||
let process = match system.process(self_pid) {
|
||||
Some(p) => p,
|
||||
None => return futures::future::ready(None),
|
||||
};
|
||||
futures::future::ready(Some(SysInfo {
|
||||
total_memory: system.total_memory(),
|
||||
used_memory: system.used_memory(),
|
||||
process_memory: process.memory(),
|
||||
used_cpu: system.global_cpu_usage(),
|
||||
process_cpu: process.cpu_usage() / system.cpus().len() as f32,
|
||||
total_disk: disks.iter().map(|d| d.total_space()).sum(),
|
||||
available_disk: disks.iter().map(|d| d.available_space()).sum(),
|
||||
}))
|
||||
});
|
||||
while let Some(sys_info) = stream.next().await {
|
||||
let sys_info = Arc::new(sys_info);
|
||||
future::join_all(sysinfo_subscribers.iter().map(async |subscriber| {
|
||||
if let Err(e) = subscriber.send(ServerEvent::SysInfo(sys_info.clone())).await {
|
||||
error!(
|
||||
"Failed to send sysinfo event to subscriber {}: {:?}",
|
||||
subscriber.key(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let mut system = System::new();
|
||||
let mut disks = Disks::new();
|
||||
while tick_rx.blocking_recv().is_some() {
|
||||
system.refresh_needed(self_pid);
|
||||
disks.refresh_needed(self_pid);
|
||||
let process = match system.process(self_pid) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let (available, total) = disks
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.available_space() > 0
|
||||
&& d.total_space() > 0
|
||||
// 简单过滤一些虚拟文件系统
|
||||
&& !["overlay", "tmpfs", "sysfs", "proc"]
|
||||
.contains(&d.file_system().to_string_lossy().as_ref())
|
||||
})
|
||||
.unique_by(|d| d.name())
|
||||
.fold((0, 0), |(mut available, mut total), d| {
|
||||
available += d.available_space();
|
||||
total += d.total_space();
|
||||
(available, total)
|
||||
});
|
||||
let sys_info = SysInfo {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
total_memory: system.total_memory(),
|
||||
used_memory: system.used_memory(),
|
||||
process_memory: process.memory(),
|
||||
used_cpu: system.global_cpu_usage(),
|
||||
process_cpu: process.cpu_usage() / system.cpus().len() as f32,
|
||||
total_disk: total,
|
||||
available_disk: available,
|
||||
};
|
||||
if tx.blocking_send(sys_info).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_sysinfo_subscriber(&self, uuid: Uuid) {
|
||||
self.sysinfo_subscribers.remove(&uuid);
|
||||
if self.sysinfo_subscribers.is_empty()
|
||||
&& let Some(handle) = self.sysinfo_handles.write().take()
|
||||
{
|
||||
handle.abort();
|
||||
}
|
||||
});
|
||||
// 异步部分负责获取由阻塞线程发送过来的系统信息,并推送给所有订阅者
|
||||
// 收到取消信号时,设置标志位,确保阻塞线程正常退出
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(2));
|
||||
loop {
|
||||
select! {
|
||||
_ = cancel_token_clone.cancelled() => {
|
||||
drop(tick_tx);
|
||||
break;
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
let _ = tick_tx.send(()).await;
|
||||
}
|
||||
Some(sys_info) = rx.recv() => {
|
||||
future::join_all(sysinfo_subscribers.iter().map(async |subscriber| {
|
||||
if let Err(e) = subscriber.send(ServerEvent::SysInfo(sys_info)).await {
|
||||
error!(
|
||||
"Failed to send sysinfo event to subscriber {}: {:?}",
|
||||
subscriber.key(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cancel_token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,13 +315,24 @@ async fn handle_socket(socket: WebSocket, log_writer: LogHelper) {
|
||||
tokio::spawn(WEBSOCKET_HANDLER.handle_receiver(ws_receiver, tx, uuid, log_writer));
|
||||
}
|
||||
|
||||
fn sys_refresh_kind() -> RefreshKind {
|
||||
RefreshKind::nothing()
|
||||
.with_cpu(CpuRefreshKind::nothing().with_cpu_usage())
|
||||
.with_memory(MemoryRefreshKind::nothing().with_ram())
|
||||
.with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory())
|
||||
trait SysInfoExt {
|
||||
fn refresh_needed(&mut self, self_pid: Pid);
|
||||
}
|
||||
|
||||
fn disk_refresh_kind() -> DiskRefreshKind {
|
||||
DiskRefreshKind::nothing().with_storage()
|
||||
impl SysInfoExt for System {
|
||||
fn refresh_needed(&mut self, self_pid: Pid) {
|
||||
self.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
|
||||
self.refresh_cpu_specifics(CpuRefreshKind::nothing().with_cpu_usage());
|
||||
self.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[self_pid]),
|
||||
true,
|
||||
ProcessRefreshKind::nothing().with_cpu().with_memory(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SysInfoExt for Disks {
|
||||
fn refresh_needed(&mut self, _self_pid: Pid) {
|
||||
self.refresh_specifics(true, DiskRefreshKind::nothing().with_storage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::error::BiliError;
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
pub struct PageAnalyzer {
|
||||
info: serde_json::Value,
|
||||
pub(crate) info: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, strum::FromRepr, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
|
||||
@@ -101,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,
|
||||
@@ -131,14 +130,14 @@ pub enum Stream {
|
||||
|
||||
// 通用的获取流链接的方法,交由 Downloader 使用
|
||||
impl Stream {
|
||||
pub fn urls(&self) -> Vec<&str> {
|
||||
pub fn urls(&self, enable_cdn_sorting: bool) -> Vec<&str> {
|
||||
match self {
|
||||
Self::Flv(url) | Self::Html5Mp4(url) | Self::EpisodeTryMp4(url) => vec![url],
|
||||
Self::DashVideo { url, backup_url, .. } | Self::DashAudio { url, backup_url, .. } => {
|
||||
let mut urls = std::iter::once(url.as_str())
|
||||
.chain(backup_url.iter().map(|s| s.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
if VersionedConfig::get().load().cdn_sorting {
|
||||
if enable_cdn_sorting {
|
||||
urls.sort_by_key(|u| {
|
||||
if u.contains("upos-") {
|
||||
0 // 服务商 cdn
|
||||
@@ -218,7 +217,7 @@ impl PageAnalyzer {
|
||||
.info
|
||||
.pointer_mut("/dash/video")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
.ok_or(BiliError::RiskControlOccurred)?
|
||||
.ok_or(BiliError::VideoStreamsEmpty)?
|
||||
.iter_mut()
|
||||
{
|
||||
let (Some(url), Some(quality), Some(codecs_id)) = (
|
||||
@@ -264,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");
|
||||
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 {
|
||||
@@ -424,16 +426,17 @@ mod tests {
|
||||
Some(AudioQuality::Quality192k),
|
||||
),
|
||||
];
|
||||
let config = VersionedConfig::get().read();
|
||||
for (bvid, video_quality, video_codec, audio_quality) in testcases.into_iter() {
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, bvid.to_owned());
|
||||
let video = Video::new(&client, bvid.to_owned(), &config.credential);
|
||||
let pages = video.get_pages().await.expect("failed to get pages");
|
||||
let first_page = pages.into_iter().next().expect("no page found");
|
||||
let best_stream = video
|
||||
.get_page_analyzer(&first_page)
|
||||
.await
|
||||
.expect("failed to get page analyzer")
|
||||
.best_stream(&VersionedConfig::get().load().filter_option)
|
||||
.best_stream(&config.filter_option)
|
||||
.expect("failed to get best stream");
|
||||
dbg!(bvid, &best_stream);
|
||||
match best_stream {
|
||||
@@ -469,7 +472,7 @@ mod tests {
|
||||
codecs: VideoCodecs::AVC,
|
||||
};
|
||||
assert_eq!(
|
||||
stream.urls(),
|
||||
stream.urls(true),
|
||||
vec![
|
||||
"https://upos-sz-mirrorcos.bilivideo.com",
|
||||
"https://cn-tj-cu-01-11.bilivideo.com",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, bail};
|
||||
use leaky_bucket::RateLimiter;
|
||||
use parking_lot::Once;
|
||||
use reqwest::{Method, header};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use ua_generator::ua;
|
||||
|
||||
use crate::bilibili::Credential;
|
||||
use crate::bilibili::credential::WbiImg;
|
||||
use crate::config::{RateLimit, VersionedCache, VersionedConfig};
|
||||
use crate::config::{RateLimit, VersionedCache};
|
||||
|
||||
// 一个对 reqwest::Client 的简单封装,用于 Bilibili 请求
|
||||
#[derive(Clone)]
|
||||
@@ -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()
|
||||
@@ -60,56 +67,82 @@ impl Default for Client {
|
||||
}
|
||||
}
|
||||
|
||||
enum Limiter {
|
||||
Latest(VersionedCache<Option<RateLimiter>>),
|
||||
Snapshot(Arc<Option<RateLimiter>>),
|
||||
}
|
||||
|
||||
pub struct BiliClient {
|
||||
pub client: Client,
|
||||
limiter: VersionedCache<Option<RateLimiter>>,
|
||||
limiter: Limiter,
|
||||
}
|
||||
|
||||
impl BiliClient {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::new();
|
||||
let limiter = VersionedCache::new(|config| {
|
||||
Ok(config
|
||||
.concurrent_limit
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|RateLimit { limit, duration }| {
|
||||
RateLimiter::builder()
|
||||
.initial(*limit)
|
||||
.refill(*limit)
|
||||
.max(*limit)
|
||||
.interval(Duration::from_millis(*duration))
|
||||
.build()
|
||||
}))
|
||||
})
|
||||
.expect("failed to create rate limiter");
|
||||
let limiter = Limiter::Latest(
|
||||
VersionedCache::new(|config| {
|
||||
Ok(config
|
||||
.concurrent_limit
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|RateLimit { limit, duration }| {
|
||||
RateLimiter::builder()
|
||||
.initial(*limit)
|
||||
.refill(*limit)
|
||||
.max(*limit)
|
||||
.interval(Duration::from_millis(*duration))
|
||||
.build()
|
||||
}))
|
||||
})
|
||||
.expect("failed to create rate limiter"),
|
||||
);
|
||||
Self { client, limiter }
|
||||
}
|
||||
|
||||
/// 获取当前 BiliClient 的快照,快照中的限流器固定不变
|
||||
pub fn snapshot(&self) -> Result<Self> {
|
||||
let Limiter::Latest(inner) = &self.limiter else {
|
||||
// 语法上没问题,但语义上不允许对快照进行快照
|
||||
bail!("cannot snapshot a snapshot BiliClient");
|
||||
};
|
||||
Ok(Self {
|
||||
client: self.client.clone(),
|
||||
limiter: Limiter::Snapshot(inner.snapshot()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取一个预构建的请求,通过该方法获取请求时会检查并等待速率限制
|
||||
pub async fn request(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
|
||||
if let Some(limiter) = self.limiter.load().as_ref() {
|
||||
limiter.acquire_one().await;
|
||||
pub async fn request(&self, method: Method, url: &str, credential: &Credential) -> reqwest::RequestBuilder {
|
||||
match &self.limiter {
|
||||
Limiter::Latest(inner) => {
|
||||
if let Some(limiter) = inner.read().as_ref() {
|
||||
limiter.acquire_one().await;
|
||||
}
|
||||
}
|
||||
Limiter::Snapshot(inner) => {
|
||||
if let Some(limiter) = inner.as_ref() {
|
||||
limiter.acquire_one().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let credential = &VersionedConfig::get().load().credential;
|
||||
self.client.request(method, url, Some(credential))
|
||||
}
|
||||
|
||||
pub async fn check_refresh(&self, connection: &DatabaseConnection) -> Result<()> {
|
||||
let credential = &VersionedConfig::get().load().credential;
|
||||
/// 检查并刷新 Credential,不需要刷新返回 Ok(None),需要刷新返回 Ok(Some(new_credential))
|
||||
pub async fn check_refresh(&self, credential: &Credential) -> Result<Option<Credential>> {
|
||||
if !credential.need_refresh(&self.client).await? {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let new_credential = credential.refresh(&self.client).await?;
|
||||
VersionedConfig::get()
|
||||
.update_credential(new_credential, connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(Some(credential.refresh(&self.client).await?))
|
||||
}
|
||||
|
||||
/// 获取 wbi img,用于生成请求签名
|
||||
pub async fn wbi_img(&self) -> Result<WbiImg> {
|
||||
let credential = &VersionedConfig::get().load().credential;
|
||||
pub async fn wbi_img(&self, credential: &Credential) -> Result<WbiImg> {
|
||||
credential.wbi_img(&self.client).await
|
||||
}
|
||||
|
||||
pub fn inner_client(&self) -> &reqwest::Client {
|
||||
&self.client.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate, VideoInfo};
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
|
||||
pub enum CollectionType {
|
||||
@@ -74,6 +73,7 @@ pub struct CollectionItem {
|
||||
pub struct Collection<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub collection: CollectionItem,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -112,8 +112,12 @@ impl<'de> Deserialize<'de> for CollectionInfo {
|
||||
}
|
||||
|
||||
impl<'a> Collection<'a> {
|
||||
pub fn new(client: &'a BiliClient, collection: CollectionItem) -> Self {
|
||||
Self { client, collection }
|
||||
pub fn new(client: &'a BiliClient, collection: CollectionItem, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
collection,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<CollectionInfo> {
|
||||
@@ -127,55 +131,54 @@ impl<'a> Collection<'a> {
|
||||
|
||||
async fn get_series_info(&self) -> Result<Value> {
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/series/series")
|
||||
.request(Method::GET, "https://api.bilibili.com/x/series/series", self.credential)
|
||||
.await
|
||||
.query(&[("series_id", self.collection.sid.as_str())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||
let page = page.to_string();
|
||||
let (url, query) = match self.collection.collection_type {
|
||||
CollectionType::Series => (
|
||||
"https://api.bilibili.com/x/series/archives",
|
||||
encoded_query(
|
||||
vec![
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("series_id", self.collection.sid.as_str()),
|
||||
("only_normal", "true"),
|
||||
("sort", "desc"),
|
||||
("pn", page.as_str()),
|
||||
("ps", "30"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
),
|
||||
),
|
||||
CollectionType::Season => (
|
||||
"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list",
|
||||
encoded_query(
|
||||
vec![
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("season_id", self.collection.sid.as_str()),
|
||||
("sort_reverse", "true"),
|
||||
("page_num", page.as_str()),
|
||||
("page_size", "30"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
),
|
||||
),
|
||||
let req = match self.collection.collection_type {
|
||||
CollectionType::Series => self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/series/archives",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("pn", page)])
|
||||
.query(&[
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("series_id", self.collection.sid.as_str()),
|
||||
("only_normal", "true"),
|
||||
("sort", "desc"),
|
||||
("ps", "30"),
|
||||
]),
|
||||
CollectionType::Season => self
|
||||
.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("page_num", page)])
|
||||
.query(&[
|
||||
("mid", self.collection.mid.as_str()),
|
||||
("season_id", self.collection.sid.as_str()),
|
||||
("sort_reverse", "true"),
|
||||
("page_size", "30"),
|
||||
]),
|
||||
};
|
||||
self.client
|
||||
.request(Method::GET, url)
|
||||
.await
|
||||
.query(&query)
|
||||
.send()
|
||||
req.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<Value>()
|
||||
.await?
|
||||
.validate()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use cookie::Cookie;
|
||||
use cow_utils::CowUtils;
|
||||
use regex::Regex;
|
||||
use reqwest::{Method, header};
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
@@ -11,7 +9,7 @@ use rsa::sha2::Sha256;
|
||||
use rsa::{Oaep, RsaPublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{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,
|
||||
@@ -19,6 +17,13 @@ const MIXIN_KEY_ENC_TAB: [usize; 64] = [
|
||||
20, 34, 44, 52,
|
||||
];
|
||||
|
||||
mod qrcode_status_code {
|
||||
pub const SUCCESS: i64 = 0;
|
||||
pub const NOT_SCANNED: i64 = 86101;
|
||||
pub const SCANNED_UNCONFIRMED: i64 = 86090;
|
||||
pub const EXPIRED: i64 = 86038;
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
pub sessdata: String,
|
||||
@@ -30,17 +35,35 @@ pub struct Credential {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WbiImg {
|
||||
img_url: String,
|
||||
sub_url: String,
|
||||
pub(crate) img_url: String,
|
||||
pub(crate) sub_url: String,
|
||||
}
|
||||
|
||||
impl From<WbiImg> for Option<String> {
|
||||
/// 尝试将 WbiImg 转换成 mixin_key
|
||||
fn from(value: WbiImg) -> Self {
|
||||
let key = match (
|
||||
get_filename(value.img_url.as_str()),
|
||||
get_filename(value.sub_url.as_str()),
|
||||
) {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Qrcode {
|
||||
pub url: String,
|
||||
pub qrcode_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum PollStatus {
|
||||
Success {
|
||||
credential: Credential,
|
||||
},
|
||||
Pending {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
scanned: bool,
|
||||
},
|
||||
Expired {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl WbiImg {
|
||||
pub fn into_mixin_key(self) -> Option<String> {
|
||||
let key = match (get_filename(self.img_url.as_str()), get_filename(self.sub_url.as_str())) {
|
||||
(Some(img_key), Some(sub_key)) => img_key.to_string() + sub_key,
|
||||
_ => return None,
|
||||
};
|
||||
@@ -55,13 +78,85 @@ 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()?;
|
||||
Ok(serde_json::from_value(res["data"]["wbi_img"].take())?)
|
||||
}
|
||||
|
||||
pub async fn generate_qrcode(client: &Client) -> Result<Qrcode> {
|
||||
let mut res = client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://passport.bilibili.com/x/passport-login/web/qrcode/generate",
|
||||
None,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn poll_qrcode(client: &Client, qrcode_key: &str) -> Result<PollStatus> {
|
||||
let mut resp = client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://passport.bilibili.com/x/passport-login/web/qrcode/poll",
|
||||
None,
|
||||
)
|
||||
.query(&[("qrcode_key", qrcode_key)])
|
||||
.send()
|
||||
.await?
|
||||
.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")?;
|
||||
|
||||
match code {
|
||||
qrcode_status_code::SUCCESS => {
|
||||
let mut credential = Self::extract(headers, json)?;
|
||||
credential.buvid3 = Self::get_buvid3(client).await?;
|
||||
Ok(PollStatus::Success { credential })
|
||||
}
|
||||
qrcode_status_code::NOT_SCANNED => Ok(PollStatus::Pending {
|
||||
message: "未扫描".to_owned(),
|
||||
scanned: false,
|
||||
}),
|
||||
qrcode_status_code::SCANNED_UNCONFIRMED => Ok(PollStatus::Pending {
|
||||
message: "已扫描,请在手机上确认登录".to_owned(),
|
||||
scanned: true,
|
||||
}),
|
||||
qrcode_status_code::EXPIRED => Ok(PollStatus::Expired {
|
||||
message: "二维码已过期".to_owned(),
|
||||
}),
|
||||
_ => {
|
||||
bail!(BiliError::InvalidResponse(json.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 buvid3 浏览器指纹
|
||||
///
|
||||
/// 参考 https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/buvid3_4.md
|
||||
async fn get_buvid3(client: &Client) -> Result<String> {
|
||||
let resp = client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-frontend/getbuvid", None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
resp["data"]["buvid"]
|
||||
.as_str()
|
||||
.context("missing 'buvid' field in data")
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 检查凭据是否有效
|
||||
pub async fn need_refresh(&self, client: &Client) -> Result<bool> {
|
||||
let res = client
|
||||
@@ -72,7 +167,7 @@ impl Credential {
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -81,9 +176,17 @@ impl Credential {
|
||||
|
||||
pub async fn refresh(&self, client: &Client) -> Result<Self> {
|
||||
let correspond_path = Self::get_correspond_path();
|
||||
let csrf = self.get_refresh_csrf(client, correspond_path).await?;
|
||||
let new_credential = self.get_new_credential(client, &csrf).await?;
|
||||
self.confirm_refresh(client, &new_credential).await?;
|
||||
let csrf = self
|
||||
.get_refresh_csrf(client, correspond_path)
|
||||
.await
|
||||
.context("获取 refresh_csrf 失败")?;
|
||||
let new_credential = self
|
||||
.get_new_credential(client, &csrf)
|
||||
.await
|
||||
.context("刷新 Credential 失败")?;
|
||||
self.confirm_refresh(client, &new_credential)
|
||||
.await
|
||||
.context("确认更新 Credential 失败")?;
|
||||
Ok(new_credential)
|
||||
}
|
||||
|
||||
@@ -98,11 +201,11 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
-----END PUBLIC KEY-----",
|
||||
)
|
||||
.expect("fail to decode public key");
|
||||
let ts = chrono::Local::now().timestamp_millis();
|
||||
// 精确到毫秒的时间戳可能出现时间比服务器快的情况,提前 20s 以防万一
|
||||
let ts = chrono::Local::now().timestamp_millis() - 20000;
|
||||
let data = format!("refresh_{}", ts).into_bytes();
|
||||
let mut rng = rand::rng();
|
||||
let encrypted = key
|
||||
.encrypt(&mut rng, Oaep::new::<Sha256>(), &data)
|
||||
.encrypt(&mut rand::rng(), Oaep::new::<Sha256>(), &data)
|
||||
.expect("fail to encrypt");
|
||||
hex::encode(encrypted)
|
||||
}
|
||||
@@ -117,12 +220,12 @@ 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())
|
||||
}
|
||||
|
||||
async fn get_new_credential(&self, client: &Client, csrf: &str) -> Result<Credential> {
|
||||
let mut res = client
|
||||
let mut resp = client
|
||||
.request(
|
||||
Method::POST,
|
||||
"https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
|
||||
@@ -138,38 +241,11 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
// 必须在 .json 前取出 headers,否则 res 会被消耗
|
||||
let headers = std::mem::take(res.headers_mut());
|
||||
let res = res.json::<serde_json::Value>().await?.validate()?;
|
||||
let set_cookies = headers.get_all(header::SET_COOKIE);
|
||||
let mut credential = Self {
|
||||
buvid3: self.buvid3.clone(),
|
||||
..Self::default()
|
||||
};
|
||||
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
|
||||
let cookies: Vec<Cookie> = set_cookies
|
||||
.iter()
|
||||
.filter_map(|x| x.to_str().ok())
|
||||
.filter_map(|x| Cookie::parse(x).ok())
|
||||
.filter(|x| required_cookies.contains(x.name()))
|
||||
.collect();
|
||||
ensure!(
|
||||
cookies.len() == required_cookies.len(),
|
||||
"not all required cookies found"
|
||||
);
|
||||
for cookie in cookies {
|
||||
match cookie.name() {
|
||||
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
|
||||
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
|
||||
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
match res["data"]["refresh_token"].as_str() {
|
||||
Some(token) => credential.ac_time_value = token.to_string(),
|
||||
None => bail!("refresh_token not found"),
|
||||
}
|
||||
.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)?;
|
||||
credential.buvid3 = self.buvid3.clone();
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
@@ -187,12 +263,42 @@ JNrRuoEUXpabUzGB8QIDAQAB
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 header 和 json,获取除 buvid3 字段外全部填充的 Credential
|
||||
fn extract(headers: header::HeaderMap, json: serde_json::Value) -> Result<Credential> {
|
||||
let mut credential = Credential::default();
|
||||
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
|
||||
let cookies: Vec<Cookie> = headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|x| x.to_str().ok())
|
||||
.filter_map(|x| Cookie::parse(x).ok())
|
||||
.filter(|x| required_cookies.contains(x.name()))
|
||||
.collect();
|
||||
ensure!(
|
||||
cookies.len() == required_cookies.len(),
|
||||
"not all required cookies found"
|
||||
);
|
||||
for cookie in cookies {
|
||||
match cookie.name() {
|
||||
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
|
||||
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
|
||||
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
match json["data"]["refresh_token"].as_str() {
|
||||
Some(token) => credential.ac_time_value = token.to_string(),
|
||||
None => bail!("refresh_token not found"),
|
||||
}
|
||||
Ok(credential)
|
||||
}
|
||||
}
|
||||
|
||||
// 用指定的 pattern 正则表达式在 doc 中查找,返回第一个匹配的捕获组
|
||||
@@ -213,47 +319,8 @@ fn get_filename(url: &str) -> Option<&str> {
|
||||
.map(|(s, _)| s)
|
||||
}
|
||||
|
||||
pub fn encoded_query<'a>(
|
||||
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
|
||||
mixin_key: Option<impl AsRef<str>>,
|
||||
) -> Vec<(&'a str, Cow<'a, str>)> {
|
||||
match mixin_key {
|
||||
Some(key) => _encoded_query(params, key.as_ref(), chrono::Local::now().timestamp().to_string()),
|
||||
None => params.into_iter().map(|(k, v)| (k, v.into())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn _encoded_query<'a>(
|
||||
params: Vec<(&'a str, impl Into<Cow<'a, str>>)>,
|
||||
mixin_key: &str,
|
||||
timestamp: String,
|
||||
) -> Vec<(&'a str, Cow<'a, str>)> {
|
||||
let disallowed = ['!', '\'', '(', ')', '*'];
|
||||
let mut params: Vec<(&'a str, Cow<'a, str>)> = params
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
match Into::<Cow<'a, str>>::into(v) {
|
||||
Cow::Borrowed(v) => v.cow_replace(&disallowed[..], ""),
|
||||
Cow::Owned(v) => v.replace(&disallowed[..], "").into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
params.push(("wts", timestamp.into()));
|
||||
params.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let query = serde_urlencoded::to_string(¶ms)
|
||||
.expect("fail to encode query")
|
||||
.replace('+', "%20");
|
||||
params.push(("w_rid", format!("{:x}", md5::compute(query.clone() + mixin_key)).into()));
|
||||
params
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use assert_matches::assert_matches;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -285,54 +352,92 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wbi_key() {
|
||||
let key = WbiImg {
|
||||
img_url: "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string(),
|
||||
sub_url: "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string(),
|
||||
};
|
||||
let key = Option::<String>::from(key).expect("fail to convert key");
|
||||
assert_eq!(key.as_str(), "ea1db124af3c7062474693fa704f4ff8");
|
||||
// 没有特殊字符
|
||||
assert_matches!(
|
||||
&_encoded_query(
|
||||
vec![("foo", "114"), ("bar", "514"), ("zab", "1919810")],
|
||||
key.as_str(),
|
||||
"1702204169".to_string(),
|
||||
)[..],
|
||||
[
|
||||
("bar", Cow::Borrowed(a)),
|
||||
("foo", Cow::Borrowed(b)),
|
||||
("wts", Cow::Owned(c)),
|
||||
("zab", Cow::Borrowed(d)),
|
||||
("w_rid", Cow::Owned(e)),
|
||||
] => {
|
||||
assert_eq!(*a, "514");
|
||||
assert_eq!(*b, "114");
|
||||
assert_eq!(c, "1702204169");
|
||||
assert_eq!(*d, "1919810");
|
||||
assert_eq!(e, "8f6f2b5b3d485fe1886cec6a0be8c5d4");
|
||||
}
|
||||
fn test_extract_credential_success() {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
"SESSDATA=test_sessdata; Path=/; Domain=bilibili.com".parse().unwrap(),
|
||||
);
|
||||
// 有特殊字符
|
||||
assert_matches!(
|
||||
&_encoded_query(
|
||||
vec![("foo", "'1(1)4'"), ("bar", "!5*1!14"), ("zab", "1919810")],
|
||||
key.as_str(),
|
||||
"1702204169".to_string(),
|
||||
)[..],
|
||||
[
|
||||
("bar", Cow::Owned(a)),
|
||||
("foo", Cow::Owned(b)),
|
||||
("wts", Cow::Owned(c)),
|
||||
("zab", Cow::Borrowed(d)),
|
||||
("w_rid", Cow::Owned(e)),
|
||||
] => {
|
||||
assert_eq!(a, "5114");
|
||||
assert_eq!(b, "114");
|
||||
assert_eq!(c, "1702204169");
|
||||
assert_eq!(*d, "1919810");
|
||||
assert_eq!(e, "6a2c86c4b0648ce062ba0dac2de91a85");
|
||||
}
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
"bili_jct=test_jct; Path=/; Domain=bilibili.com".parse().unwrap(),
|
||||
);
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
"DedeUserID=123456; Path=/; Domain=bilibili.com".parse().unwrap(),
|
||||
);
|
||||
|
||||
let json = serde_json::json!({
|
||||
"data": {
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
});
|
||||
|
||||
let credential = Credential::extract(headers, json).unwrap();
|
||||
|
||||
assert_eq!(credential.sessdata, "test_sessdata");
|
||||
assert_eq!(credential.bili_jct, "test_jct");
|
||||
assert_eq!(credential.dedeuserid, "123456");
|
||||
assert_eq!(credential.ac_time_value, "test_refresh_token");
|
||||
assert!(credential.buvid3.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_credential_missing_sessdata() {
|
||||
let headers = header::HeaderMap::new();
|
||||
let json = serde_json::json!({
|
||||
"data": {
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
});
|
||||
assert!(Credential::extract(headers, json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_credential_missing_refresh_token() {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.append(header::SET_COOKIE, "SESSDATA=test_sessdata".parse().unwrap());
|
||||
headers.append(header::SET_COOKIE, "bili_jct=test_jct".parse().unwrap());
|
||||
headers.append(header::SET_COOKIE, "DedeUserID=123456".parse().unwrap());
|
||||
let json = serde_json::json!({
|
||||
"data": {}
|
||||
});
|
||||
assert!(Credential::extract(headers, json).is_err());
|
||||
}
|
||||
|
||||
#[ignore = "requires manual testing with real QR code scan"]
|
||||
#[tokio::test]
|
||||
async fn test_qrcode_login_flow() -> Result<()> {
|
||||
let client = Client::new();
|
||||
// 1. 生成二维码
|
||||
let qr_response = Credential::generate_qrcode(&client).await?;
|
||||
println!("二维码 URL: {}", qr_response.url);
|
||||
println!("qrcode_key: {}", qr_response.qrcode_key);
|
||||
println!("\n请使用 B 站 APP 扫描二维码...\n");
|
||||
// 2. 轮询登录状态(最多轮询 90 次,每 2 秒一次,共 180 秒)
|
||||
for i in 1..=90 {
|
||||
println!("第 {} 次轮询...", i);
|
||||
let status = Credential::poll_qrcode(&client, &qr_response.qrcode_key).await?;
|
||||
match status {
|
||||
PollStatus::Success { credential } => {
|
||||
println!("\n登录成功!");
|
||||
println!("SESSDATA: {}", credential.sessdata);
|
||||
println!("bili_jct: {}", credential.bili_jct);
|
||||
println!("buvid3: {}", credential.buvid3);
|
||||
println!("DedeUserID: {}", credential.dedeuserid);
|
||||
println!("ac_time_value: {}", credential.ac_time_value);
|
||||
return Ok(());
|
||||
}
|
||||
PollStatus::Pending { message, scanned } => {
|
||||
println!("状态: {}, 已扫描: {}", message, scanned);
|
||||
}
|
||||
PollStatus::Expired { message } => {
|
||||
println!("\n二维码已过期: {}", message);
|
||||
anyhow::bail!("二维码过期");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
bail!("轮询超时")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
use tokio::fs::{self, File};
|
||||
|
||||
use crate::bilibili::PageInfo;
|
||||
use crate::bilibili::danmaku::canvas::CanvasConfig;
|
||||
use crate::bilibili::danmaku::{AssWriter, Danmu};
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::bilibili::{DanmakuOption, PageInfo};
|
||||
|
||||
pub struct DanmakuWriter<'a> {
|
||||
page: &'a PageInfo,
|
||||
@@ -18,12 +17,11 @@ impl<'a> DanmakuWriter<'a> {
|
||||
DanmakuWriter { page, danmaku }
|
||||
}
|
||||
|
||||
pub async fn write(self, path: PathBuf) -> Result<()> {
|
||||
pub async fn write(self, path: PathBuf, danmaku_option: &DanmakuOption) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let config = VersionedConfig::get().load_full();
|
||||
let canvas_config = CanvasConfig::new(&config.danmaku_option, self.page);
|
||||
let canvas_config = CanvasConfig::new(danmaku_option, self.page);
|
||||
let mut writer =
|
||||
AssWriter::construct(File::create(path).await?, self.page.name.clone(), canvas_config.clone()).await?;
|
||||
let mut canvas = canvas_config.canvas();
|
||||
|
||||
89
crates/bili_sync/src/bilibili/dynamic.rs
Normal file
89
crates/bili_sync/src/bilibili/dynamic.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_stream::try_stream;
|
||||
use chrono::DateTime;
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
|
||||
pub struct Dynamic<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub upper_id: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> Dynamic<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
upper_id,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_dynamics(&self, offset: Option<String>) -> Result<Value> {
|
||||
self.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[
|
||||
("host_mid", self.upper_id.as_str()),
|
||||
("offset", offset.as_deref().unwrap_or("")),
|
||||
("type", "video"),
|
||||
])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
|
||||
try_stream! {
|
||||
let mut offset = None;
|
||||
loop {
|
||||
let mut res = self
|
||||
.get_dynamics(offset.take())
|
||||
.await
|
||||
.with_context(|| "failed to get dynamics")?;
|
||||
let items = res["data"]["items"].as_array_mut().context("items not exist")?;
|
||||
for item in items.iter_mut() {
|
||||
if item["type"].as_str().is_none_or(|t| t != "DYNAMIC_TYPE_AV") {
|
||||
continue;
|
||||
}
|
||||
let pub_ts = item["modules"]["module_author"]["pub_ts"].take();
|
||||
let pub_dt = pub_ts
|
||||
.as_i64()
|
||||
.or_else(|| pub_ts.as_str().and_then(|s| s.parse::<i64>().ok()))
|
||||
.and_then(DateTime::from_timestamp_secs)
|
||||
.with_context(|| format!("invalid pub_ts: {:?}", pub_ts))?;
|
||||
let mut video_info: VideoInfo =
|
||||
serde_json::from_value(item["modules"]["module_dynamic"]["major"]["archive"].take())?;
|
||||
// 这些地方不使用 let else 是因为 try_stream! 宏不支持
|
||||
if let VideoInfo::Dynamic { ref mut pubtime, .. } = video_info {
|
||||
*pubtime = pub_dt;
|
||||
yield video_info;
|
||||
} else {
|
||||
Err(anyhow!("video info is not dynamic"))?;
|
||||
}
|
||||
}
|
||||
if let (Some(has_more), Some(new_offset)) =
|
||||
(res["data"]["has_more"].as_bool(), res["data"]["offset"].as_str())
|
||||
{
|
||||
if !has_more {
|
||||
break;
|
||||
}
|
||||
offset = Some(new_offset.to_string());
|
||||
} else {
|
||||
Err(anyhow!("no has_more or offset found"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum BiliError {
|
||||
#[error("risk control occurred")]
|
||||
RiskControlOccurred,
|
||||
#[error("request failed, status code: {0}, message: {1}")]
|
||||
RequestFailed(i64, String),
|
||||
#[error("response missing 'code' or 'message' field, full response: {0}")]
|
||||
InvalidResponse(String),
|
||||
#[error("API returned error code {0}, full response: {1}")]
|
||||
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 | BiliError::InvalidStatusCode(_, _)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ use async_stream::try_stream;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate, VideoInfo};
|
||||
pub struct FavoriteList<'a> {
|
||||
client: &'a BiliClient,
|
||||
fid: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -22,19 +23,27 @@ pub struct Upper<T> {
|
||||
pub face: String,
|
||||
}
|
||||
impl<'a> FavoriteList<'a> {
|
||||
pub fn new(client: &'a BiliClient, fid: String) -> Self {
|
||||
Self { client, fid }
|
||||
pub fn new(client: &'a BiliClient, fid: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
fid,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<FavoriteListInfo> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v3/fav/folder/info")
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/folder/info",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("media_id", &self.fid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -43,7 +52,11 @@ impl<'a> FavoriteList<'a> {
|
||||
|
||||
async fn get_videos(&self, page: u32) -> Result<Value> {
|
||||
self.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v3/fav/resource/list")
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/resource/list",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[
|
||||
("media_id", self.fid.as_str()),
|
||||
@@ -55,7 +68,7 @@ impl<'a> FavoriteList<'a> {
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate};
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate};
|
||||
|
||||
pub struct Me<'a> {
|
||||
client: &'a BiliClient,
|
||||
mid: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> Me<'a> {
|
||||
pub fn new(client: &'a BiliClient) -> Self {
|
||||
Self {
|
||||
client,
|
||||
mid: Self::my_id(),
|
||||
}
|
||||
pub fn new(client: &'a BiliClient, credential: &'a Credential) -> Self {
|
||||
Self { client, credential }
|
||||
}
|
||||
|
||||
pub async fn get_created_favorites(&self) -> Result<Option<Vec<FavoriteItem>>> {
|
||||
ensure!(!self.mid.is_empty(), "未获取到用户 ID,请确保填写设置中的 B 站认证信息");
|
||||
ensure!(
|
||||
!self.mid().is_empty(),
|
||||
"未获取到用户 ID,请确保填写设置中的 B 站认证信息"
|
||||
);
|
||||
let mut resp = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/created/list-all")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/folder/created/list-all",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("up_mid", &self.mid)])
|
||||
.query(&[("up_mid", &self.mid())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -33,48 +37,65 @@ impl<'a> Me<'a> {
|
||||
}
|
||||
|
||||
pub async fn get_followed_collections(&self, page_num: i32, page_size: i32) -> Result<Collections> {
|
||||
ensure!(!self.mid.is_empty(), "未获取到用户 ID,请确保填写设置中的 B 站认证信息");
|
||||
ensure!(
|
||||
!self.mid().is_empty(),
|
||||
"未获取到用户 ID,请确保填写设置中的 B 站认证信息"
|
||||
);
|
||||
let mut resp = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/collected/list")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/v3/fav/folder/collected/list",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[
|
||||
("up_mid", self.mid.as_str()),
|
||||
("pn", page_num.to_string().as_str()),
|
||||
("ps", page_size.to_string().as_str()),
|
||||
("platform", "web"),
|
||||
])
|
||||
.query(&[("up_mid", self.mid()), ("platform", "web")])
|
||||
.query(&[("pn", page_num), ("ps", page_size)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(resp["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_followed_uppers(&self, page_num: i32, page_size: i32) -> Result<FollowedUppers> {
|
||||
ensure!(!self.mid.is_empty(), "未获取到用户 ID,请确保填写设置中的 B 站认证信息");
|
||||
let mut resp = self
|
||||
pub async fn get_followed_uppers(
|
||||
&self,
|
||||
page_num: i32,
|
||||
page_size: i32,
|
||||
name: Option<&str>,
|
||||
) -> Result<FollowedUppers> {
|
||||
ensure!(
|
||||
!self.mid().is_empty(),
|
||||
"未获取到用户 ID,请确保填写设置中的 B 站认证信息"
|
||||
);
|
||||
let url = if name.is_some() {
|
||||
"https://api.bilibili.com/x/relation/followings/search"
|
||||
} else {
|
||||
"https://api.bilibili.com/x/relation/followings"
|
||||
};
|
||||
let mut request = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/relation/followings")
|
||||
.request(Method::GET, url, self.credential)
|
||||
.await
|
||||
.query(&[
|
||||
("vmid", self.mid.as_str()),
|
||||
("pn", page_num.to_string().as_str()),
|
||||
("ps", page_size.to_string().as_str()),
|
||||
])
|
||||
.query(&[("vmid", self.mid())])
|
||||
.query(&[("pn", page_num), ("ps", page_size)]);
|
||||
if let Some(name) = name {
|
||||
request = request.query(&[("name", name)]);
|
||||
}
|
||||
let mut resp = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(resp["data"].take())?)
|
||||
}
|
||||
|
||||
fn my_id() -> String {
|
||||
VersionedConfig::get().load().credential.dedeuserid.clone()
|
||||
fn mid(&self) -> &str {
|
||||
&self.credential.dedeuserid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +110,11 @@ pub struct FavoriteItem {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct CollectionItem {
|
||||
pub id: i64,
|
||||
pub fid: i64,
|
||||
pub mid: i64,
|
||||
pub state: i32,
|
||||
pub title: String,
|
||||
pub media_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use analyzer::{BestStream, FilterOption};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
pub use client::{BiliClient, Client};
|
||||
pub use collection::{Collection, CollectionItem, CollectionType};
|
||||
pub use credential::Credential;
|
||||
pub use credential::{Credential, PollStatus, Qrcode};
|
||||
pub use danmaku::DanmakuOption;
|
||||
pub use dynamic::Dynamic;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::FavoriteList;
|
||||
use favorite_list::Upper;
|
||||
pub use me::Me;
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{RequestBuilder, StatusCode};
|
||||
pub use submission::Submission;
|
||||
pub use video::{Dimension, PageInfo, Video};
|
||||
pub use watch_later::WatchLater;
|
||||
@@ -23,6 +26,7 @@ mod client;
|
||||
mod collection;
|
||||
mod credential;
|
||||
mod danmaku;
|
||||
mod dynamic;
|
||||
mod error;
|
||||
mod favorite_list;
|
||||
mod me;
|
||||
@@ -43,19 +47,77 @@ 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;
|
||||
|
||||
fn validate(self) -> Result<Self::Output> {
|
||||
let (code, msg) = match (self["code"].as_i64(), self["message"].as_str()) {
|
||||
(Some(code), Some(msg)) => (code, msg),
|
||||
_ => bail!("no code or message found"),
|
||||
};
|
||||
ensure!(code == 0, BiliError::RequestFailed(code, msg.to_owned()));
|
||||
let code = self["code"]
|
||||
.as_i64()
|
||||
.with_context(|| BiliError::InvalidResponse(self.to_string()))?;
|
||||
if code == -352 || !self["data"]["v_voucher"].is_null() {
|
||||
bail!(BiliError::RiskControlOccurred(self.to_string()));
|
||||
}
|
||||
ensure!(code == 0, BiliError::ErrorResponse(code, self.to_string()));
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fn wbi_sign(self, mixin_key: Option<impl AsRef<str>>) -> Result<Self::Output>;
|
||||
}
|
||||
|
||||
impl WbiSign for RequestBuilder {
|
||||
type Output = RequestBuilder;
|
||||
|
||||
fn wbi_sign(self, mixin_key: Option<impl AsRef<str>>) -> Result<Self::Output> {
|
||||
let Some(mixin_key) = mixin_key else {
|
||||
return Ok(self);
|
||||
};
|
||||
let (client, req) = self.build_split();
|
||||
let mut req = req?;
|
||||
sign_request(&mut req, mixin_key.as_ref(), chrono::Utc::now().timestamp())?;
|
||||
Ok(RequestBuilder::from_parts(client, req))
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_request(req: &mut reqwest::Request, mixin_key: &str, timestamp: i64) -> Result<()> {
|
||||
let mut query_pairs = req.url().query_pairs().collect::<Vec<_>>();
|
||||
let timestamp = timestamp.to_string();
|
||||
query_pairs.push(("wts".into(), Cow::Borrowed(timestamp.as_str())));
|
||||
query_pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let query_str = serde_urlencoded::to_string(query_pairs)?.replace('+', "%20");
|
||||
let w_rid = format!("{:x}", md5::compute(query_str + mixin_key));
|
||||
req.url_mut()
|
||||
.query_pairs_mut()
|
||||
.extend_pairs([("w_rid", w_rid), ("wts", timestamp)]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
/// 注意此处的顺序是有要求的,因为对于 untagged 的 enum 来说,serde 会按照顺序匹配
|
||||
@@ -76,6 +138,9 @@ pub enum VideoInfo {
|
||||
ctime: DateTime<Utc>,
|
||||
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||
pubtime: DateTime<Utc>,
|
||||
is_upower_exclusive: bool,
|
||||
is_upower_play: bool,
|
||||
redirect_url: Option<String>,
|
||||
pages: Vec<PageInfo>,
|
||||
state: i32,
|
||||
},
|
||||
@@ -135,24 +200,44 @@ pub enum VideoInfo {
|
||||
#[serde(rename = "created", with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
},
|
||||
// 从动态获取的视频信息(此处 pubtime 未在结构中,因此使用 default + 手动赋值)
|
||||
Dynamic {
|
||||
title: String,
|
||||
bvid: String,
|
||||
desc: String,
|
||||
cover: String,
|
||||
#[serde(default)]
|
||||
pubtime: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Method;
|
||||
|
||||
use super::*;
|
||||
use crate::bilibili::credential::WbiImg;
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::database::setup_database;
|
||||
use crate::utils::init_logger;
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_video_info_type() {
|
||||
async fn test_video_info_type() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
init_logger("None,bili_sync=debug", None);
|
||||
let bili_client = BiliClient::new();
|
||||
// 请求 UP 主视频必须要获取 mixin key,使用 key 计算请求参数的签名,否则直接提示权限不足返回空
|
||||
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
|
||||
panic!("获取 mixin key 失败");
|
||||
};
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
let collection = Collection::new(
|
||||
&bili_client,
|
||||
@@ -161,6 +246,7 @@ mod tests {
|
||||
sid: "4523".to_string(),
|
||||
collection_type: CollectionType::Season,
|
||||
},
|
||||
&credential,
|
||||
);
|
||||
let videos = collection
|
||||
.into_video_stream()
|
||||
@@ -171,7 +257,7 @@ mod tests {
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Collection { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试收藏夹
|
||||
let favorite = FavoriteList::new(&bili_client, "3144336058".to_string());
|
||||
let favorite = FavoriteList::new(&bili_client, "3144336058".to_string(), &credential);
|
||||
let videos = favorite
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -181,7 +267,7 @@ mod tests {
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Favorite { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试稍后再看
|
||||
let watch_later = WatchLater::new(&bili_client);
|
||||
let watch_later = WatchLater::new(&bili_client, &credential);
|
||||
let videos = watch_later
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -191,7 +277,7 @@ mod tests {
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::WatchLater { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试投稿
|
||||
let submission = Submission::new(&bili_client, "956761".to_string());
|
||||
let submission = Submission::new(&bili_client, "956761".to_string(), &credential);
|
||||
let videos = submission
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
@@ -200,17 +286,32 @@ mod tests {
|
||||
.await;
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Submission { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试动态
|
||||
let dynamic = Dynamic::new(&bili_client, "659898".to_string(), &credential);
|
||||
let videos = dynamic
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
.filter_map(|v| futures::future::ready(v.ok()))
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Dynamic { .. })));
|
||||
assert!(videos.iter().skip(1).rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_subtitle_parse() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let bili_client = BiliClient::new();
|
||||
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
|
||||
panic!("获取 mixin key 失败");
|
||||
};
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string());
|
||||
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string(), &credential);
|
||||
let pages = video.get_pages().await?;
|
||||
println!("pages: {:?}", pages);
|
||||
let subtitles = video.get_subtitles(&pages[0]).await?;
|
||||
@@ -223,4 +324,116 @@ mod tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_upower_parse() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let bili_client = BiliClient::new();
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
for (bvid, (upower_exclusive, upower_play)) in [
|
||||
("BV1HxXwYEEqt", (true, false)), // 充电专享且无权观看
|
||||
("BV16w41187fx", (true, true)), // 充电专享但有权观看
|
||||
("BV1n34jzPEYq", (false, false)), // 普通视频
|
||||
] {
|
||||
let video = Video::new(&bili_client, bvid.to_string(), credential);
|
||||
let info = video.get_view_info().await?;
|
||||
let VideoInfo::Detail {
|
||||
is_upower_exclusive,
|
||||
is_upower_play,
|
||||
..
|
||||
} = info
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(is_upower_exclusive, upower_exclusive, "bvid: {}", bvid);
|
||||
assert_eq!(is_upower_play, upower_play, "bvid: {}", bvid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_ep_parse() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let credential = &VersionedConfig::get().read().credential;
|
||||
let bili_client = BiliClient::new();
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(credential)
|
||||
.await?
|
||||
.into_mixin_key()
|
||||
.context("no mixin key")?;
|
||||
set_global_mixin_key(mixin_key);
|
||||
for (bvid, redirect_is_none) in [
|
||||
("BV1SF411g796", false), // EP
|
||||
("BV13xtnzPEye", false), // 番剧
|
||||
("BV1kT4NzTEZj", true), // 普通视频
|
||||
] {
|
||||
let video = Video::new(&bili_client, bvid.to_string(), credential);
|
||||
let info = video.get_view_info().await?;
|
||||
let VideoInfo::Detail { redirect_url, .. } = info else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(redirect_url.is_none(), redirect_is_none, "bvid: {}", bvid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wbi_key() -> Result<()> {
|
||||
let key = WbiImg {
|
||||
img_url: "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string(),
|
||||
sub_url: "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string(),
|
||||
};
|
||||
let key = key.into_mixin_key().context("no mixin key")?;
|
||||
assert_eq!(key.as_str(), "ea1db124af3c7062474693fa704f4ff8");
|
||||
let client = Client::new();
|
||||
let mut req = client
|
||||
.request(Method::GET, "https://www.baidu.com/", None)
|
||||
.query(&[("foo", "114"), ("bar", "514")])
|
||||
.query(&[("zab", "1919810")])
|
||||
.build()?;
|
||||
sign_request(&mut req, key.as_str(), 1702204169).unwrap();
|
||||
let query: Vec<_> = req.url().query_pairs().collect();
|
||||
assert_eq!(
|
||||
query,
|
||||
vec![
|
||||
("foo".into(), "114".into()),
|
||||
("bar".into(), "514".into()),
|
||||
("zab".into(), "1919810".into()),
|
||||
("w_rid".into(), "8f6f2b5b3d485fe1886cec6a0be8c5d4".into()),
|
||||
("wts".into(), "1702204169".into()),
|
||||
]
|
||||
);
|
||||
let key = WbiImg {
|
||||
img_url: "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string(),
|
||||
sub_url: "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string(),
|
||||
};
|
||||
let key = key.into_mixin_key().context("no mixin key")?;
|
||||
let mut req = client
|
||||
.request(Method::GET, "https://www.baidu.com/", None)
|
||||
.query(&[("mid", "11997177"), ("token", "")])
|
||||
.query(&[("platform", "web"), ("web_location", "1550101")])
|
||||
.build()?;
|
||||
sign_request(&mut req, key.as_str(), 1703513649).unwrap();
|
||||
let query: Vec<_> = req.url().query_pairs().collect();
|
||||
assert_eq!(
|
||||
query,
|
||||
vec![
|
||||
("mid".into(), "11997177".into()),
|
||||
("token".into(), "".into()),
|
||||
("platform".into(), "web".into()),
|
||||
("web_location".into(), "1550101".into()),
|
||||
("w_rid".into(), "7d4428b3f2f9ee2811e116ec6fd41a4f".into()),
|
||||
("wts".into(), "1703513649".into()),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,42 @@ use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::favorite_list::Upper;
|
||||
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, Dynamic, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
pub struct Submission<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub upper_id: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> From<Submission<'a>> for Dynamic<'a> {
|
||||
fn from(submission: Submission<'a>) -> Self {
|
||||
Dynamic::new(submission.client, submission.upper_id, submission.credential)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Submission<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String) -> Self {
|
||||
Self { client, upper_id }
|
||||
pub fn new(client: &'a BiliClient, upper_id: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
upper_id,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> Result<Upper<String>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/card")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/card",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("mid", self.upper_id.as_str())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -34,23 +48,25 @@ impl<'a> Submission<'a> {
|
||||
|
||||
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||
self.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/space/wbi/arc/search")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/space/wbi/arc/search",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![
|
||||
("mid", self.upper_id.as_str()),
|
||||
("order", "pubdate"),
|
||||
("order_avoided", "true"),
|
||||
("platform", "web"),
|
||||
("web_location", "1550101"),
|
||||
("pn", page.to_string().as_str()),
|
||||
("ps", "30"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
))
|
||||
.query(&[
|
||||
("mid", self.upper_id.as_str()),
|
||||
("order", "pubdate"),
|
||||
("order_avoided", "true"),
|
||||
("platform", "web"),
|
||||
("web_location", "1550101"),
|
||||
("ps", "30"),
|
||||
])
|
||||
.query(&[("pn", page)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
|
||||
@@ -6,14 +6,14 @@ use reqwest::Method;
|
||||
|
||||
use crate::bilibili::analyzer::PageAnalyzer;
|
||||
use crate::bilibili::client::BiliClient;
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
|
||||
use crate::bilibili::subtitle::{SubTitle, SubTitleBody, SubTitleInfo, SubTitlesInfo};
|
||||
use crate::bilibili::{MIXIN_KEY, Validate, VideoInfo};
|
||||
use crate::bilibili::{Credential, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
|
||||
|
||||
pub struct Video<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub bvid: String,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
@@ -35,36 +35,49 @@ pub struct Dimension {
|
||||
}
|
||||
|
||||
impl<'a> Video<'a> {
|
||||
pub fn new(client: &'a BiliClient, bvid: String) -> Self {
|
||||
Self { client, bvid }
|
||||
pub fn new(client: &'a BiliClient, bvid: String, credential: &'a Credential) -> Self {
|
||||
Self {
|
||||
client,
|
||||
bvid,
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
/// 直接调用视频信息接口获取详细的视频信息,视频信息中包含了视频的分页信息
|
||||
pub async fn get_view_info(&self) -> Result<VideoInfo> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/wbi/view",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("bvid", &self.bvid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/pagelist")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/player/pagelist",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -74,12 +87,16 @@ impl<'a> Video<'a> {
|
||||
pub async fn get_tags(&self) -> Result<Vec<String>> {
|
||||
let res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/web-interface/view/detail/tag",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("bvid", &self.bvid)])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -105,12 +122,17 @@ impl<'a> Video<'a> {
|
||||
async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i64) -> Result<Vec<DanmakuElem>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "http://api.bilibili.com/x/v2/dm/web/seg.so")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/v2/dm/wbi/web/seg.so",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&[("type", 1), ("oid", page.cid), ("segment_index", segment_idx)])
|
||||
.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!(
|
||||
@@ -125,22 +147,24 @@ impl<'a> Video<'a> {
|
||||
pub async fn get_page_analyzer(&self, page: &PageInfo) -> Result<PageAnalyzer> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/playurl")
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/player/wbi/playurl",
|
||||
self.credential,
|
||||
)
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![
|
||||
("bvid", self.bvid.as_str()),
|
||||
("cid", page.cid.to_string().as_str()),
|
||||
("qn", "127"),
|
||||
("otype", "json"),
|
||||
("fnval", "4048"),
|
||||
("fourk", "1"),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
))
|
||||
.query(&[
|
||||
("bvid", self.bvid.as_str()),
|
||||
("qn", "127"),
|
||||
("otype", "json"),
|
||||
("fnval", "4048"),
|
||||
("fourk", "1"),
|
||||
])
|
||||
.query(&[("cid", page.cid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -150,15 +174,14 @@ impl<'a> Video<'a> {
|
||||
pub async fn get_subtitles(&self, page: &PageInfo) -> Result<Vec<SubTitle>> {
|
||||
let mut res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2")
|
||||
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2", self.credential)
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![("cid", &page.cid.to_string()), ("bvid", &self.bvid)],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
))
|
||||
.query(&[("bvid", self.bvid.as_str())])
|
||||
.query(&[("cid", page.cid)])
|
||||
.wbi_sign(MIXIN_KEY.load().as_deref())?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.error_for_status_ext()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()?;
|
||||
@@ -184,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,23 +3,28 @@ use async_stream::try_stream;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Credential, ErrorForStatusExt, Validate, VideoInfo};
|
||||
pub struct WatchLater<'a> {
|
||||
client: &'a BiliClient,
|
||||
credential: &'a Credential,
|
||||
}
|
||||
|
||||
impl<'a> WatchLater<'a> {
|
||||
pub fn new(client: &'a BiliClient) -> Self {
|
||||
Self { client }
|
||||
pub fn new(client: &'a BiliClient, credential: &'a Credential) -> Self {
|
||||
Self { client, credential }
|
||||
}
|
||||
|
||||
async fn get_videos(&self) -> Result<Value> {
|
||||
self.client
|
||||
.request(reqwest::Method::GET, "https://api.bilibili.com/x/v2/history/toview")
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
"https://api.bilibili.com/x/v2/history/toview",
|
||||
self.credential,
|
||||
)
|
||||
.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;
|
||||
@@ -13,6 +14,15 @@ pub struct Args {
|
||||
|
||||
#[arg(short, long, default_value = "None,bili_sync=info", env = "RUST_LOG")]
|
||||
pub log_level: String,
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
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::LegacyConfig;
|
||||
use crate::config::default::{default_auth_token, default_bind_address, default_time_format};
|
||||
use crate::config::item::{ConcurrentLimit, NFOTimeType};
|
||||
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,
|
||||
};
|
||||
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 {
|
||||
@@ -22,9 +32,19 @@ pub struct Config {
|
||||
pub credential: Credential,
|
||||
pub filter_option: FilterOption,
|
||||
pub danmaku_option: DanmakuOption,
|
||||
#[serde(default)]
|
||||
pub skip_option: SkipOption,
|
||||
pub video_name: String,
|
||||
pub page_name: String,
|
||||
pub interval: u64,
|
||||
#[serde(default)]
|
||||
pub notifiers: Option<Arc<Vec<Notifier>>>,
|
||||
#[serde(default = "default_favorite_path")]
|
||||
pub favorite_default_path: String,
|
||||
#[serde(default = "default_collection_path")]
|
||||
pub collection_default_path: String,
|
||||
#[serde(default = "default_submission_path")]
|
||||
pub submission_default_path: String,
|
||||
pub interval: Trigger,
|
||||
pub upper_path: PathBuf,
|
||||
pub nfo_time_type: NFOTimeType,
|
||||
pub concurrent_limit: ConcurrentLimit,
|
||||
@@ -65,25 +85,29 @@ impl Config {
|
||||
if !(self.concurrent_limit.video > 0 && self.concurrent_limit.page > 0) {
|
||||
errors.push("video 和 page 允许的并发数必须大于 0");
|
||||
}
|
||||
match &self.interval {
|
||||
Trigger::Interval(secs) => {
|
||||
if *secs <= 60 {
|
||||
errors.push("下载任务执行间隔时间必须大于 60 秒");
|
||||
}
|
||||
}
|
||||
Trigger::Cron(cron) => {
|
||||
if CronParser::builder()
|
||||
.seconds(croner::parser::Seconds::Required)
|
||||
.dom_and_dow(true)
|
||||
.build()
|
||||
.parse(cron)
|
||||
.is_err()
|
||||
{
|
||||
errors.push("Cron 表达式无效,正确格式为“秒 分 时 日 月 周”");
|
||||
}
|
||||
}
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_default() -> Self {
|
||||
Self {
|
||||
cdn_sorting: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -94,9 +118,14 @@ impl Default for Config {
|
||||
credential: Credential::default(),
|
||||
filter_option: FilterOption::default(),
|
||||
danmaku_option: DanmakuOption::default(),
|
||||
skip_option: SkipOption::default(),
|
||||
video_name: "{{title}}".to_owned(),
|
||||
page_name: "{{bvid}}".to_owned(),
|
||||
interval: 1200,
|
||||
notifiers: None,
|
||||
favorite_default_path: default_favorite_path(),
|
||||
collection_default_path: default_collection_path(),
|
||||
submission_default_path: default_submission_path(),
|
||||
interval: Trigger::default(),
|
||||
upper_path: CONFIG_DIR.join("upper_face"),
|
||||
nfo_time_type: NFOTimeType::FavTime,
|
||||
concurrent_limit: ConcurrentLimit::default(),
|
||||
@@ -106,24 +135,3 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LegacyConfig> for Config {
|
||||
fn from(legacy: LegacyConfig) -> Self {
|
||||
Self {
|
||||
auth_token: legacy.auth_token,
|
||||
bind_address: legacy.bind_address,
|
||||
credential: legacy.credential,
|
||||
filter_option: legacy.filter_option,
|
||||
danmaku_option: legacy.danmaku_option,
|
||||
video_name: legacy.video_name,
|
||||
page_name: legacy.page_name,
|
||||
interval: legacy.interval,
|
||||
upper_path: legacy.upper_path,
|
||||
nfo_time_type: legacy.nfo_time_type,
|
||||
concurrent_limit: legacy.concurrent_limit,
|
||||
time_format: legacy.time_format,
|
||||
cdn_sorting: legacy.cdn_sorting,
|
||||
version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
use rand::seq::IndexedRandom;
|
||||
|
||||
pub(super) fn default_time_format() -> String {
|
||||
"%Y-%m-%d".to_string()
|
||||
}
|
||||
|
||||
/// 默认的 auth_token 实现,生成随机 16 位字符串
|
||||
pub(super) fn default_auth_token() -> String {
|
||||
let byte_choices = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=";
|
||||
@@ -13,6 +9,22 @@ pub(super) fn default_auth_token() -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn default_bind_address() -> String {
|
||||
pub(crate) fn default_bind_address() -> String {
|
||||
"0.0.0.0:12345".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn default_time_format() -> String {
|
||||
"%Y-%m-%d".to_string()
|
||||
}
|
||||
|
||||
pub fn default_favorite_path() -> String {
|
||||
"收藏夹/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
pub fn default_collection_path() -> String {
|
||||
"合集/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
pub fn default_submission_path() -> String {
|
||||
"投稿/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use handlebars::handlebars_helper;
|
||||
|
||||
use crate::config::versioned_cache::VersionedCache;
|
||||
use crate::config::{Config, PathSafeTemplate};
|
||||
use crate::notifier::{Notifier, webhook_template_content, webhook_template_key};
|
||||
|
||||
pub static TEMPLATE: LazyLock<VersionedCache<handlebars::Handlebars<'static>>> =
|
||||
LazyLock::new(|| VersionedCache::new(create_template).expect("Failed to create handlebars template"));
|
||||
@@ -12,8 +13,18 @@ pub static TEMPLATE: LazyLock<VersionedCache<handlebars::Handlebars<'static>>> =
|
||||
fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars.path_safe_register("video", config.video_name.to_owned())?;
|
||||
handlebars.path_safe_register("page", config.page_name.to_owned())?;
|
||||
handlebars.path_safe_register("video", config.video_name.clone())?;
|
||||
handlebars.path_safe_register("page", config.page_name.clone())?;
|
||||
handlebars.path_safe_register("favorite_default_path", config.favorite_default_path.clone())?;
|
||||
handlebars.path_safe_register("collection_default_path", config.collection_default_path.clone())?;
|
||||
handlebars.path_safe_register("submission_default_path", config.submission_default_path.clone())?;
|
||||
if let Some(notifiers) = &config.notifiers {
|
||||
for notifier in notifiers.iter() {
|
||||
if let Notifier::Webhook { url, template, .. } = notifier {
|
||||
handlebars.register_template_string(&webhook_template_key(url), webhook_template_content(template))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(handlebars)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::utils::filenamify::filenamify;
|
||||
|
||||
/// 稍后再看的配置
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct WatchLaterConfig {
|
||||
pub enabled: bool,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// NFO 文件使用的时间类型
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NFOTimeType {
|
||||
#[default]
|
||||
@@ -69,6 +60,28 @@ impl Default for ConcurrentLimit {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct SkipOption {
|
||||
pub no_poster: bool,
|
||||
pub no_video_nfo: bool,
|
||||
pub no_upper: bool,
|
||||
pub no_danmaku: bool,
|
||||
pub no_subtitle: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum Trigger {
|
||||
Interval(u64),
|
||||
Cron(String),
|
||||
}
|
||||
|
||||
impl Default for Trigger {
|
||||
fn default() -> Self {
|
||||
Trigger::Interval(1200)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PathSafeTemplate {
|
||||
fn path_safe_register(&mut self, name: &'static str, template: impl Into<String>) -> Result<()>;
|
||||
fn path_safe_render(&self, name: &'static str, data: &serde_json::Value) -> Result<String>;
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::de::{Deserializer, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bilibili::{CollectionItem, CollectionType, Credential, DanmakuOption, FilterOption};
|
||||
use crate::config::Config;
|
||||
use crate::config::default::{default_auth_token, default_bind_address, default_time_format};
|
||||
use crate::config::item::{ConcurrentLimit, NFOTimeType, WatchLaterConfig};
|
||||
use crate::utils::model::migrate_legacy_config;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LegacyConfig {
|
||||
#[serde(default = "default_auth_token")]
|
||||
pub auth_token: String,
|
||||
#[serde(default = "default_bind_address")]
|
||||
pub bind_address: String,
|
||||
pub credential: Credential,
|
||||
pub filter_option: FilterOption,
|
||||
#[serde(default)]
|
||||
pub danmaku_option: DanmakuOption,
|
||||
pub favorite_list: HashMap<String, PathBuf>,
|
||||
#[serde(
|
||||
default,
|
||||
serialize_with = "serialize_collection_list",
|
||||
deserialize_with = "deserialize_collection_list"
|
||||
)]
|
||||
pub collection_list: HashMap<CollectionItem, PathBuf>,
|
||||
#[serde(default)]
|
||||
pub submission_list: HashMap<String, PathBuf>,
|
||||
#[serde(default)]
|
||||
pub watch_later: WatchLaterConfig,
|
||||
pub video_name: String,
|
||||
pub page_name: String,
|
||||
pub interval: u64,
|
||||
pub upper_path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub nfo_time_type: NFOTimeType,
|
||||
#[serde(default)]
|
||||
pub concurrent_limit: ConcurrentLimit,
|
||||
#[serde(default = "default_time_format")]
|
||||
pub time_format: String,
|
||||
#[serde(default)]
|
||||
pub cdn_sorting: bool,
|
||||
}
|
||||
|
||||
impl LegacyConfig {
|
||||
async fn load_from_file(path: &Path) -> Result<Self> {
|
||||
let legacy_config_str = tokio::fs::read_to_string(path).await?;
|
||||
Ok(toml::from_str(&legacy_config_str)?)
|
||||
}
|
||||
|
||||
pub async fn migrate_from_file(path: &Path, connection: &DatabaseConnection) -> Result<Config> {
|
||||
let legacy_config = Self::load_from_file(path).await?;
|
||||
migrate_legacy_config(&legacy_config, connection).await?;
|
||||
Ok(legacy_config.into())
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
后面是用于自定义 Collection 的序列化、反序列化的样板代码
|
||||
*/
|
||||
pub(super) fn serialize_collection_list<S>(
|
||||
collection_list: &HashMap<CollectionItem, PathBuf>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(collection_list.len()))?;
|
||||
for (k, v) in collection_list {
|
||||
let prefix = match k.collection_type {
|
||||
CollectionType::Series => "series",
|
||||
CollectionType::Season => "season",
|
||||
};
|
||||
map.serialize_entry(&[prefix, &k.mid, &k.sid].join(":"), v)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
pub(super) fn deserialize_collection_list<'de, D>(deserializer: D) -> Result<HashMap<CollectionItem, PathBuf>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct CollectionListVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for CollectionListVisitor {
|
||||
type Value = HashMap<CollectionItem, PathBuf>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a map of collection list")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
let mut collection_list = HashMap::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, PathBuf>()? {
|
||||
let collection_item = match key.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
[prefix, mid, sid] => {
|
||||
let collection_type = match *prefix {
|
||||
"series" => CollectionType::Series,
|
||||
"season" => CollectionType::Season,
|
||||
_ => {
|
||||
return Err(serde::de::Error::custom(
|
||||
"invalid collection type, should be series or season",
|
||||
));
|
||||
}
|
||||
};
|
||||
CollectionItem {
|
||||
mid: mid.to_string(),
|
||||
sid: sid.to_string(),
|
||||
collection_type,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(serde::de::Error::custom(
|
||||
"invalid collection key, should be series:mid:sid or season:mid:sid",
|
||||
));
|
||||
}
|
||||
};
|
||||
collection_list.insert(collection_item, value);
|
||||
}
|
||||
Ok(collection_list)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(CollectionListVisitor)
|
||||
}
|
||||
@@ -3,14 +3,13 @@ mod current;
|
||||
mod default;
|
||||
mod handlebar;
|
||||
mod item;
|
||||
mod legacy;
|
||||
mod versioned_cache;
|
||||
mod versioned_config;
|
||||
|
||||
pub use crate::config::args::{ARGS, version};
|
||||
pub use crate::config::current::{CONFIG_DIR, Config};
|
||||
pub(crate) use crate::config::default::default_bind_address;
|
||||
pub use crate::config::handlebar::TEMPLATE;
|
||||
pub use crate::config::item::{NFOTimeType, PathSafeTemplate, RateLimit};
|
||||
pub use crate::config::legacy::LegacyConfig;
|
||||
pub use crate::config::item::{ConcurrentDownloadLimit, NFOTimeType, PathSafeTemplate, RateLimit, Trigger};
|
||||
pub use crate::config::versioned_cache::VersionedCache;
|
||||
pub use crate::config::versioned_config::VersionedConfig;
|
||||
|
||||
@@ -1,54 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::{ArcSwap, Guard};
|
||||
use tokio_util::future::FutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::{Config, VersionedConfig};
|
||||
|
||||
pub struct VersionedCache<T> {
|
||||
inner: ArcSwap<T>,
|
||||
version: AtomicU64,
|
||||
builder: fn(&Config) -> Result<T>,
|
||||
mutex: parking_lot::Mutex<()>,
|
||||
inner: Arc<ArcSwap<T>>,
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
impl<T> VersionedCache<T> {
|
||||
/// 一个跟随全局配置变化自动更新的缓存
|
||||
impl<T: Send + Sync + 'static> VersionedCache<T> {
|
||||
pub fn new(builder: fn(&Config) -> Result<T>) -> Result<Self> {
|
||||
let current_config = VersionedConfig::get().load();
|
||||
let current_version = current_config.version;
|
||||
let initial_value = builder(¤t_config)?;
|
||||
Ok(Self {
|
||||
inner: ArcSwap::from_pointee(initial_value),
|
||||
version: AtomicU64::new(current_version),
|
||||
builder,
|
||||
mutex: parking_lot::Mutex::new(()),
|
||||
})
|
||||
let mut rx = VersionedConfig::get().subscribe();
|
||||
let initial_value = builder(&rx.borrow_and_update())?;
|
||||
let cancel_token = CancellationToken::new();
|
||||
let inner = Arc::new(ArcSwap::from_pointee(initial_value));
|
||||
let inner_clone = inner.clone();
|
||||
tokio::spawn(
|
||||
async move {
|
||||
while rx.changed().await.is_ok() {
|
||||
match builder(&rx.borrow()) {
|
||||
Ok(new_value) => {
|
||||
inner_clone.store(Arc::new(new_value));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update versioned cache: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.with_cancellation_token_owned(cancel_token.clone()),
|
||||
);
|
||||
Ok(Self { inner, cancel_token })
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Guard<Arc<T>> {
|
||||
self.reload_if_needed();
|
||||
/// 获取一个临时的只读引用
|
||||
pub fn read(&self) -> Guard<Arc<T>> {
|
||||
self.inner.load()
|
||||
}
|
||||
|
||||
fn reload_if_needed(&self) {
|
||||
let current_config = VersionedConfig::get().load();
|
||||
let current_version = current_config.version;
|
||||
let version = self.version.load(Ordering::Relaxed);
|
||||
if version < current_version {
|
||||
let _lock = self.mutex.lock();
|
||||
if self.version.load(Ordering::Relaxed) >= current_version {
|
||||
return;
|
||||
}
|
||||
match (self.builder)(¤t_config) {
|
||||
Err(e) => {
|
||||
error!("Failed to rebuild versioned cache: {:?}", e);
|
||||
}
|
||||
Ok(new_value) => {
|
||||
self.inner.store(Arc::new(new_value));
|
||||
self.version.store(current_version, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 获取当前缓存的完整快照
|
||||
pub fn snapshot(&self) -> Arc<T> {
|
||||
self.inner.load_full()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for VersionedCache<T> {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,58 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use anyhow::{Result, bail};
|
||||
use arc_swap::{ArcSwap, Guard};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio::sync::{OnceCell, watch};
|
||||
|
||||
use crate::bilibili::Credential;
|
||||
use crate::config::{CONFIG_DIR, Config, LegacyConfig};
|
||||
use crate::config::Config;
|
||||
|
||||
pub static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
|
||||
static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
|
||||
|
||||
pub struct VersionedConfig {
|
||||
inner: ArcSwap<Config>,
|
||||
update_lock: tokio::sync::Mutex<()>,
|
||||
tx: watch::Sender<Arc<Config>>,
|
||||
rx: watch::Receiver<Arc<Config>>,
|
||||
}
|
||||
|
||||
impl VersionedConfig {
|
||||
/// 初始化全局的 `VersionedConfig`,初始化失败或者已初始化过则返回错误
|
||||
pub async fn init(connection: &DatabaseConnection) -> Result<()> {
|
||||
let mut config = match Config::load_from_database(connection).await? {
|
||||
Some(Ok(config)) => config,
|
||||
Some(Err(e)) => bail!("解析数据库配置失败: {}", e),
|
||||
None => {
|
||||
let config = match LegacyConfig::migrate_from_file(&CONFIG_DIR.join("config.toml"), connection).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
if e.downcast_ref::<std::io::Error>()
|
||||
.is_none_or(|e| e.kind() != std::io::ErrorKind::NotFound)
|
||||
{
|
||||
bail!("未成功读取并迁移旧版本配置:{:#}", e);
|
||||
} else {
|
||||
let config = Config::default();
|
||||
warn!(
|
||||
"生成 auth_token:{},可使用该 token 登录 web UI,该信息仅在首次运行时打印",
|
||||
config.auth_token
|
||||
);
|
||||
config
|
||||
}
|
||||
pub async fn init(connection: &DatabaseConnection) -> Result<&'static VersionedConfig> {
|
||||
VERSIONED_CONFIG
|
||||
.get_or_try_init(|| async move {
|
||||
let mut config = match Config::load_from_database(connection).await? {
|
||||
Some(Ok(config)) => config,
|
||||
Some(Err(e)) => bail!("解析数据库配置失败: {}", e),
|
||||
None => {
|
||||
let config = Config::default();
|
||||
warn!(
|
||||
"生成 auth_token:{},可使用该 token 登录 web UI,该信息仅在首次运行时打印",
|
||||
config.auth_token
|
||||
);
|
||||
config.save_to_database(connection).await?;
|
||||
config
|
||||
}
|
||||
};
|
||||
config.save_to_database(connection).await?;
|
||||
config
|
||||
}
|
||||
};
|
||||
// version 本身不具有实际意义,仅用于并发更新时的版本控制,在初始化时可以直接清空
|
||||
config.version = 0;
|
||||
let versioned_config = VersionedConfig::new(config);
|
||||
VERSIONED_CONFIG
|
||||
.set(versioned_config)
|
||||
.map_err(|e| anyhow!("VERSIONED_CONFIG has already been initialized: {}", e))?;
|
||||
Ok(())
|
||||
// version 本身不具有实际意义,仅用于并发更新时的版本控制,在初始化时可以直接清空
|
||||
config.version = 0;
|
||||
Ok(VersionedConfig::new(config))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 单元测试直接使用测试专用的配置即可
|
||||
pub fn get() -> &'static VersionedConfig {
|
||||
use std::sync::LazyLock;
|
||||
static TEST_CONFIG: LazyLock<VersionedConfig> = LazyLock::new(|| VersionedConfig::new(Config::test_default()));
|
||||
return &TEST_CONFIG;
|
||||
/// 仅在测试环境使用,该方法会尝试从测试数据库中加载配置并写入到全局的 VERSIONED_CONFIG
|
||||
pub async fn init_for_test(connection: &DatabaseConnection) -> Result<&'static VersionedConfig> {
|
||||
VERSIONED_CONFIG
|
||||
.get_or_try_init(|| async move {
|
||||
let Some(Ok(config)) = Config::load_from_database(&connection).await? else {
|
||||
bail!("no config found in test database");
|
||||
};
|
||||
Ok(VersionedConfig::new(config))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
@@ -66,37 +61,52 @@ impl VersionedConfig {
|
||||
VERSIONED_CONFIG.get().expect("VERSIONED_CONFIG is not initialized")
|
||||
}
|
||||
|
||||
pub fn new(config: Config) -> Self {
|
||||
#[cfg(test)]
|
||||
/// 尝试获取全局的 `VersionedConfig`,如果未初始化则退回默认配置
|
||||
pub fn get() -> &'static VersionedConfig {
|
||||
use std::sync::LazyLock;
|
||||
static FALLBACK_CONFIG: LazyLock<VersionedConfig> = LazyLock::new(|| VersionedConfig::new(Config::default()));
|
||||
// 优先从全局变量获取,未初始化则退回默认配置
|
||||
return VERSIONED_CONFIG.get().unwrap_or_else(|| &FALLBACK_CONFIG);
|
||||
}
|
||||
|
||||
fn new(config: Config) -> Self {
|
||||
let inner = ArcSwap::from_pointee(config);
|
||||
let (tx, rx) = watch::channel(inner.load_full());
|
||||
Self {
|
||||
inner: ArcSwap::from_pointee(config),
|
||||
inner,
|
||||
update_lock: tokio::sync::Mutex::new(()),
|
||||
tx,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Guard<Arc<Config>> {
|
||||
pub fn read(&self) -> Guard<Arc<Config>> {
|
||||
self.inner.load()
|
||||
}
|
||||
|
||||
pub fn load_full(&self) -> Arc<Config> {
|
||||
pub fn snapshot(&self) -> Arc<Config> {
|
||||
self.inner.load_full()
|
||||
}
|
||||
|
||||
pub async fn update_credential(&self, new_credential: Credential, connection: &DatabaseConnection) -> Result<()> {
|
||||
// 确保更新内容与写入数据库的操作是原子性的
|
||||
pub fn subscribe(&self) -> watch::Receiver<Arc<Config>> {
|
||||
self.rx.clone()
|
||||
}
|
||||
|
||||
pub async fn update_credential(
|
||||
&self,
|
||||
new_credential: Credential,
|
||||
connection: &DatabaseConnection,
|
||||
) -> Result<Arc<Config>> {
|
||||
let _lock = self.update_lock.lock().await;
|
||||
loop {
|
||||
let old_config = self.inner.load();
|
||||
let mut new_config = old_config.as_ref().clone();
|
||||
new_config.credential = new_credential.clone();
|
||||
new_config.version += 1;
|
||||
if Arc::ptr_eq(
|
||||
&old_config,
|
||||
&self.inner.compare_and_swap(&old_config, Arc::new(new_config)),
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.inner.load().save_to_database(connection).await
|
||||
let mut new_config = self.inner.load().as_ref().clone();
|
||||
new_config.credential = new_credential;
|
||||
new_config.version += 1;
|
||||
new_config.save_to_database(connection).await?;
|
||||
let new_config = Arc::new(new_config);
|
||||
self.inner.store(new_config.clone());
|
||||
self.tx.send(new_config.clone())?;
|
||||
Ok(new_config)
|
||||
}
|
||||
|
||||
/// 外部 API 会调用这个方法,如果更新失败直接返回错误
|
||||
@@ -107,14 +117,10 @@ impl VersionedConfig {
|
||||
bail!("配置版本不匹配,请刷新页面修改后重新提交");
|
||||
}
|
||||
new_config.version += 1;
|
||||
let new_config = Arc::new(new_config);
|
||||
if !Arc::ptr_eq(
|
||||
&old_config,
|
||||
&self.inner.compare_and_swap(&old_config, new_config.clone()),
|
||||
) {
|
||||
bail!("配置版本不匹配,请刷新页面修改后重新提交");
|
||||
}
|
||||
new_config.save_to_database(connection).await?;
|
||||
let new_config = Arc::new(new_config);
|
||||
self.inner.store(new_config.clone());
|
||||
self.tx.send(new_config.clone())?;
|
||||
Ok(new_config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use bili_sync_migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
||||
use sea_orm::sqlx::{ConnectOptions as SqlxConnectOptions, Sqlite};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection, SqlxSqliteConnector};
|
||||
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, SqlxSqliteConnector, Statement};
|
||||
|
||||
use crate::config::CONFIG_DIR;
|
||||
|
||||
fn database_url() -> String {
|
||||
format!("sqlite://{}?mode=rwc", CONFIG_DIR.join("data.sqlite").to_string_lossy())
|
||||
fn database_url(path: &Path) -> String {
|
||||
format!("sqlite://{}?mode=rwc", path.to_string_lossy())
|
||||
}
|
||||
|
||||
async fn database_connection() -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url());
|
||||
async fn database_connection(database_url: &str) -> Result<DatabaseConnection> {
|
||||
let mut option = ConnectOptions::new(database_url);
|
||||
option
|
||||
.max_connections(50)
|
||||
.min_connections(5)
|
||||
@@ -35,18 +34,38 @@ async fn database_connection() -> Result<DatabaseConnection> {
|
||||
))
|
||||
}
|
||||
|
||||
async fn migrate_database() -> Result<()> {
|
||||
async fn migrate_database(database_url: &str) -> Result<()> {
|
||||
// 注意此处使用内部构造的 DatabaseConnection,而不是通过 database_connection() 获取
|
||||
// 这是因为使用多个连接的 Connection 会导致奇怪的迁移顺序问题,而使用默认的连接选项不会
|
||||
let connection = Database::connect(database_url()).await?;
|
||||
let connection = Database::connect(database_url).await?;
|
||||
// 避免 https://github.com/amtoaer/bili-sync/issues/571 问题,迁移前根据 migration 确认当前版本
|
||||
// 如果用户从 2.6.0 以下版本直接升级,migration 不满足需求,直接报错而不执行迁移
|
||||
if connection
|
||||
.query_one(Statement::from_string(
|
||||
connection.get_database_backend(),
|
||||
"SELECT 1 FROM seaql_migrations WHERE version = 'm20250613_043257_add_config';",
|
||||
))
|
||||
.await
|
||||
.is_ok_and(|res| res.is_none())
|
||||
{
|
||||
// 查询成功且结果为空,即没有 m20250613_043257_add_config,说明版本低于 2.6.0
|
||||
bail!("该版本仅支持从 2.6.x 以上的版本升级,请先升级至 2.6.x 或 2.7.x 完成配置迁移,再升级至最新版本。");
|
||||
}
|
||||
Ok(Migrator::up(&connection, None).await?)
|
||||
}
|
||||
|
||||
/// 进行数据库迁移并获取数据库连接,供外部使用
|
||||
pub async fn setup_database() -> Result<DatabaseConnection> {
|
||||
tokio::fs::create_dir_all(CONFIG_DIR.as_path()).await.context(
|
||||
"Failed to create config directory. Please check if you have granted necessary permissions to your folder.",
|
||||
)?;
|
||||
migrate_database().await.context("Failed to migrate database")?;
|
||||
database_connection().await.context("Failed to connect to database")
|
||||
pub async fn setup_database(path: &Path) -> Result<DatabaseConnection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.context(
|
||||
"Failed to create config directory. Please check if you have granted necessary permissions to your folder.",
|
||||
)?;
|
||||
}
|
||||
let database_url = database_url(path);
|
||||
migrate_database(&database_url)
|
||||
.await
|
||||
.context("Failed to migrate database")?;
|
||||
database_connection(&database_url)
|
||||
.await
|
||||
.context("Failed to connect to database")
|
||||
}
|
||||
|
||||
@@ -4,15 +4,18 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use async_tempfile::TempFile;
|
||||
use futures::TryStreamExt;
|
||||
use reqwest::{Method, header};
|
||||
use tokio::fs::{self, File, OpenOptions};
|
||||
use reqwest::{Method, StatusCode, header};
|
||||
use tokio::fs::{self};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio::process::Command;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::bilibili::Client;
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::bilibili::{Client, ErrorForStatusExt};
|
||||
use crate::config::{ARGS, ConcurrentDownloadLimit};
|
||||
|
||||
pub struct Downloader {
|
||||
client: Client,
|
||||
}
|
||||
@@ -25,28 +28,134 @@ impl Downloader {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn fetch(&self, url: &str, path: &Path) -> Result<()> {
|
||||
if VersionedConfig::get().load().concurrent_limit.download.enable {
|
||||
self.fetch_parallel(url, path).await
|
||||
pub async fn fetch(&self, url: &str, path: &Path, concurrent_download: &ConcurrentDownloadLimit) -> Result<()> {
|
||||
let mut temp_file = TempFile::new().await?;
|
||||
self.fetch_internal(url, &mut temp_file, false, concurrent_download)
|
||||
.await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::copy(temp_file.file_path(), path).await?;
|
||||
// temp_file 的 drop 需要 std::fs::remove_file
|
||||
// 如果交由 rust 自动执行虽然逻辑正确但会略微阻塞异步上下文
|
||||
// 尽量主动调用,保证正常执行的情况下文件清除操作由 spawn_blocking 在专门线程中完成
|
||||
temp_file.drop_async().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn multi_fetch(
|
||||
&self,
|
||||
urls: &[&str],
|
||||
path: &Path,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
let temp_file = self.multi_fetch_internal(urls, true, concurrent_download).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::copy(temp_file.file_path(), path).await?;
|
||||
temp_file.drop_async().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn multi_fetch_and_merge(
|
||||
&self,
|
||||
video_urls: &[&str],
|
||||
audio_urls: &[&str],
|
||||
path: &Path,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
let (video_temp_file, audio_temp_file) = tokio::try_join!(
|
||||
self.multi_fetch_internal(video_urls, true, concurrent_download),
|
||||
self.multi_fetch_internal(audio_urls, true, concurrent_download)
|
||||
)?;
|
||||
let final_temp_file = TempFile::new().await?;
|
||||
let output = Command::new(ARGS.ffmpeg_path.as_deref().unwrap_or("ffmpeg"))
|
||||
.args([
|
||||
"-i",
|
||||
video_temp_file.file_path().to_string_lossy().as_ref(),
|
||||
"-i",
|
||||
audio_temp_file.file_path().to_string_lossy().as_ref(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-strict",
|
||||
"unofficial",
|
||||
"-f",
|
||||
"mp4",
|
||||
"-y",
|
||||
final_temp_file.file_path().to_string_lossy().as_ref(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run ffmpeg")?;
|
||||
if !output.status.success() {
|
||||
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::copy(final_temp_file.file_path(), path).await?;
|
||||
tokio::join!(
|
||||
video_temp_file.drop_async(),
|
||||
audio_temp_file.drop_async(),
|
||||
final_temp_file.drop_async()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn multi_fetch_internal(
|
||||
&self,
|
||||
urls: &[&str],
|
||||
is_stream: bool,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<TempFile> {
|
||||
if urls.is_empty() {
|
||||
bail!("no urls provided");
|
||||
}
|
||||
let mut temp_file = TempFile::new().await?;
|
||||
for (idx, url) in urls.iter().enumerate() {
|
||||
match self
|
||||
.fetch_internal(url, &mut temp_file, is_stream, concurrent_download)
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(temp_file),
|
||||
Err(e) => {
|
||||
if idx == urls.len() - 1 {
|
||||
temp_file.drop_async().await;
|
||||
return Err(e).with_context(|| format!("failed to download file from all {} urls", urls.len()));
|
||||
}
|
||||
temp_file.set_len(0).await?;
|
||||
temp_file.rewind().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn fetch_internal(
|
||||
&self,
|
||||
url: &str,
|
||||
file: &mut TempFile,
|
||||
is_stream: bool,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
if concurrent_download.enable {
|
||||
self.fetch_parallel(url, file, is_stream, concurrent_download).await
|
||||
} else {
|
||||
self.fetch_serial(url, path).await
|
||||
self.fetch_serial(url, file).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_serial(&self, url: &str, path: &Path) -> Result<()> {
|
||||
async fn fetch_serial(&self, url: &str, file: &mut TempFile) -> Result<()> {
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::GET, url, None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.error_for_status_ext()?;
|
||||
let expected = resp.header_content_length();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let mut file = File::create(path).await?;
|
||||
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
|
||||
let received = tokio::io::copy(&mut stream_reader, &mut file).await?;
|
||||
let received = tokio::io::copy(&mut stream_reader, file).await?;
|
||||
file.flush().await?;
|
||||
if let Some(expected) = expected {
|
||||
ensure!(
|
||||
@@ -59,39 +168,55 @@ impl Downloader {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_parallel(&self, url: &str, path: &Path) -> Result<()> {
|
||||
let (concurrency, threshold) = {
|
||||
let config = VersionedConfig::get().load();
|
||||
(
|
||||
config.concurrent_limit.download.concurrency,
|
||||
config.concurrent_limit.download.threshold,
|
||||
)
|
||||
async fn fetch_parallel(
|
||||
&self,
|
||||
url: &str,
|
||||
file: &mut TempFile,
|
||||
is_stream: bool,
|
||||
concurrent_download: &ConcurrentDownloadLimit,
|
||||
) -> Result<()> {
|
||||
let (concurrency, threshold) = (concurrent_download.concurrency, concurrent_download.threshold);
|
||||
let file_size = if is_stream {
|
||||
// B 站视频、音频流存在 HEAD 为 404 但 GET 正常的情况,此处假设支持分块,直接使用携带 Range 头的 GET 请求探测
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::GET, url, None)
|
||||
.header(header::RANGE, "bytes=0-0")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status_ext()?;
|
||||
if resp.status() != StatusCode::PARTIAL_CONTENT {
|
||||
return self.fetch_serial(url, file).await;
|
||||
}
|
||||
resp.header_file_size()
|
||||
} else {
|
||||
// 对于普通文件,直接使用常规的 HEAD 请求探测
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::HEAD, url, None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status_ext()?;
|
||||
if resp
|
||||
.headers()
|
||||
.get(header::ACCEPT_RANGES)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Ranges#none
|
||||
.is_none_or(|v| v.to_str().unwrap_or_default() == "none")
|
||||
{
|
||||
return self.fetch_serial(url, file).await;
|
||||
}
|
||||
resp.header_content_length()
|
||||
};
|
||||
let Some(file_size) = file_size else {
|
||||
return self.fetch_serial(url, file).await;
|
||||
};
|
||||
let resp = self
|
||||
.client
|
||||
.request(Method::HEAD, url, None)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let file_size = resp.header_content_length().unwrap_or_default();
|
||||
let chunk_size = file_size / concurrency as u64;
|
||||
if resp
|
||||
.headers()
|
||||
.get(header::ACCEPT_RANGES)
|
||||
.is_none_or(|v| v.to_str().unwrap_or_default() == "none") // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Ranges#none
|
||||
|| chunk_size < threshold
|
||||
{
|
||||
return self.fetch_serial(url, path).await;
|
||||
if chunk_size < threshold {
|
||||
return self.fetch_serial(url, file).await;
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let file = File::create(path).await?;
|
||||
file.set_len(file_size).await?;
|
||||
drop(file);
|
||||
let mut tasks = JoinSet::new();
|
||||
let url = Arc::new(url.to_string());
|
||||
let path = Arc::new(path.to_path_buf());
|
||||
for i in 0..concurrency {
|
||||
let start = i as u64 * chunk_size;
|
||||
let end = if i == concurrency - 1 {
|
||||
@@ -99,17 +224,17 @@ impl Downloader {
|
||||
} else {
|
||||
start + chunk_size
|
||||
} - 1;
|
||||
let (url_clone, path_clone, client_clone) = (url.clone(), path.clone(), self.client.clone());
|
||||
let (url_clone, client_clone) = (url.clone(), self.client.clone());
|
||||
let mut file_clone = file.open_rw().await?;
|
||||
tasks.spawn(async move {
|
||||
let mut file = OpenOptions::new().write(true).open(path_clone.as_ref()).await?;
|
||||
file.seek(SeekFrom::Start(start)).await?;
|
||||
file_clone.seek(SeekFrom::Start(start)).await?;
|
||||
let range_header = format!("bytes={}-{}", start, end);
|
||||
let resp = client_clone
|
||||
.request(Method::GET, &url_clone, None)
|
||||
.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,
|
||||
@@ -119,8 +244,8 @@ impl Downloader {
|
||||
);
|
||||
}
|
||||
let mut stream_reader = StreamReader::new(resp.bytes_stream().map_err(std::io::Error::other));
|
||||
let received = tokio::io::copy(&mut stream_reader, &mut file).await?;
|
||||
file.flush().await?;
|
||||
let received = tokio::io::copy(&mut stream_reader, &mut file_clone).await?;
|
||||
file_clone.flush().await?;
|
||||
ensure!(
|
||||
received == end - start + 1,
|
||||
"downloaded bytes mismatch: expected {}, got {}",
|
||||
@@ -135,51 +260,15 @@ impl Downloader {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_with_fallback(&self, urls: &[&str], path: &Path) -> Result<()> {
|
||||
if urls.is_empty() {
|
||||
bail!("no urls provided");
|
||||
}
|
||||
let mut res = Ok(());
|
||||
for url in urls {
|
||||
match self.fetch(url, path).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(err) => {
|
||||
res = Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.context("failed to download file")
|
||||
}
|
||||
|
||||
pub async fn merge(&self, video_path: &Path, audio_path: &Path, output_path: &Path) -> Result<()> {
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
video_path.to_string_lossy().as_ref(),
|
||||
"-i",
|
||||
audio_path.to_string_lossy().as_ref(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-strict",
|
||||
"unofficial",
|
||||
"-y",
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run ffmpeg")?;
|
||||
if !output.status.success() {
|
||||
bail!("ffmpeg error: {}", str::from_utf8(&output.stderr).unwrap_or("unknown"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// reqwest.content_length() 居然指的是 body_size 而非 content-length header,没办法自己实现一下
|
||||
/// https://github.com/seanmonstar/reqwest/issues/1814
|
||||
trait ResponseExt {
|
||||
/// 获取 Content-Length 头的值
|
||||
fn header_content_length(&self) -> Option<u64>;
|
||||
/// 获取 Content-Range 头中的文件总大小部分
|
||||
fn header_file_size(&self) -> Option<u64>;
|
||||
}
|
||||
|
||||
impl ResponseExt for reqwest::Response {
|
||||
@@ -189,4 +278,67 @@ impl ResponseExt for reqwest::Response {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn header_file_size(&self) -> Option<u64> {
|
||||
self.headers()
|
||||
.get(header::CONTENT_RANGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| {
|
||||
// Content-Range: bytes 0-0/800946
|
||||
s.rsplit_once('/')
|
||||
})
|
||||
.and_then(|(_, size_str)| size_str.parse::<u64>().ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::bilibili::{BestStream, BiliClient, Video};
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::database::setup_database;
|
||||
use crate::downloader::Downloader;
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_parse_and_download_video() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
let config = VersionedConfig::get().read();
|
||||
let client = BiliClient::new();
|
||||
let video = Video::new(&client, "BV1QJmaYKEv4".to_owned(), &config.credential);
|
||||
let pages = video.get_pages().await.expect("failed to get pages");
|
||||
let first_page = pages.into_iter().next().expect("no page found");
|
||||
let mut page_analyzer = video
|
||||
.get_page_analyzer(&first_page)
|
||||
.await
|
||||
.expect("failed to get page analyzer");
|
||||
let json_info = serde_json::to_string_pretty(&page_analyzer.info)?;
|
||||
tokio::fs::write("./debug_playurl.json", json_info).await?;
|
||||
let best_stream = page_analyzer
|
||||
.best_stream(&config.filter_option)
|
||||
.expect("failed to get best stream");
|
||||
let BestStream::VideoAudio {
|
||||
video,
|
||||
audio: Some(audio),
|
||||
} = best_stream
|
||||
else {
|
||||
panic!("best stream is not video & audio");
|
||||
};
|
||||
dbg!(&video);
|
||||
dbg!(&audio);
|
||||
let downloader = Downloader::new(client.client);
|
||||
downloader
|
||||
.multi_fetch_and_merge(
|
||||
&video.urls(true),
|
||||
&audio.urls(true),
|
||||
Path::new("./output.mp4"),
|
||||
&config.concurrent_limit.download,
|
||||
)
|
||||
.await
|
||||
.expect("failed to download video");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
use std::io;
|
||||
|
||||
use anyhow::Result;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[error("Request too frequently")]
|
||||
pub struct DownloadAbortError();
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[error("Process page error")]
|
||||
pub struct ProcessPageError();
|
||||
|
||||
pub enum ExecutionStatus {
|
||||
Skipped,
|
||||
@@ -17,7 +8,7 @@ pub enum ExecutionStatus {
|
||||
Ignored(anyhow::Error),
|
||||
Failed(anyhow::Error),
|
||||
// 任务可以返回该状态固定自己的 status
|
||||
FixedFailed(u32, anyhow::Error),
|
||||
Fixed(u32),
|
||||
}
|
||||
|
||||
// 目前 stable rust 似乎不支持自定义类型使用 ? 运算符,只能先在返回值使用 Result,再这样套层娃
|
||||
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod database;
|
||||
mod downloader;
|
||||
mod error;
|
||||
mod notifier;
|
||||
mod task;
|
||||
mod utils;
|
||||
mod workflow;
|
||||
@@ -17,23 +18,30 @@ use std::fmt::Debug;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use bilibili::BiliClient;
|
||||
use parking_lot::Mutex;
|
||||
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;
|
||||
|
||||
use crate::api::{LogHelper, MAX_HISTORY_LOGS};
|
||||
use crate::config::{ARGS, VersionedConfig};
|
||||
use crate::config::{ARGS, CONFIG_DIR, VersionedConfig};
|
||||
use crate::database::setup_database;
|
||||
use crate::utils::init_logger;
|
||||
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();
|
||||
@@ -44,14 +52,13 @@ async fn main() {
|
||||
&tracker,
|
||||
token.clone(),
|
||||
);
|
||||
if !cfg!(debug_assertions) {
|
||||
spawn_task(
|
||||
"定时下载",
|
||||
video_downloader(connection.clone(), bili_client),
|
||||
&tracker,
|
||||
token.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
spawn_task(
|
||||
"定时下载",
|
||||
video_downloader(connection.clone(), bili_client),
|
||||
&tracker,
|
||||
token.clone(),
|
||||
);
|
||||
|
||||
tracker.close();
|
||||
handle_shutdown(connection, tracker, token).await
|
||||
@@ -77,20 +84,34 @@ 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(Mutex::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
|
||||
let log_history = Arc::new(RwLock::new(VecDeque::with_capacity(MAX_HISTORY_LOGS + 1)));
|
||||
let log_writer = LogHelper::new(tx, log_history.clone());
|
||||
|
||||
init_logger(&ARGS.log_level, Some(log_writer.clone()));
|
||||
info!("欢迎使用 Bili-Sync,当前程序版本:{}", config::version());
|
||||
info!("项目地址:https://github.com/amtoaer/bili-sync");
|
||||
let connection = setup_database().await.expect("数据库初始化失败");
|
||||
|
||||
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
|
||||
.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) {
|
||||
|
||||
80
crates/bili_sync/src/notifier/mod.rs
Normal file
80
crates/bili_sync/src/notifier/mod.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use futures::future;
|
||||
use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::TEMPLATE;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum Notifier {
|
||||
Telegram {
|
||||
bot_token: String,
|
||||
chat_id: String,
|
||||
},
|
||||
Webhook {
|
||||
url: String,
|
||||
template: Option<String>,
|
||||
#[serde(skip)]
|
||||
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
|
||||
ignore_cache: Option<()>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn webhook_template_key(url: &str) -> String {
|
||||
format!("payload_{}", url)
|
||||
}
|
||||
|
||||
pub fn webhook_template_content(template: &Option<String>) -> &str {
|
||||
template
|
||||
.as_deref()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or(r#"{"text": "{{{message}}}"}"#)
|
||||
}
|
||||
|
||||
pub trait NotifierAllExt {
|
||||
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
impl NotifierAllExt for Vec<Notifier> {
|
||||
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()> {
|
||||
future::join_all(self.iter().map(|notifier| notifier.notify(client, message))).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Notifier {
|
||||
pub async fn notify(&self, client: &reqwest::Client, message: &str) -> Result<()> {
|
||||
match self {
|
||||
Notifier::Telegram { bot_token, chat_id } => {
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
|
||||
let params = [("chat_id", chat_id.as_str()), ("text", message)];
|
||||
client.post(&url).form(¶ms).send().await?;
|
||||
}
|
||||
Notifier::Webhook {
|
||||
url,
|
||||
template,
|
||||
ignore_cache,
|
||||
} => {
|
||||
let key = webhook_template_key(url);
|
||||
let data = serde_json::json!(
|
||||
{
|
||||
"message": message,
|
||||
}
|
||||
);
|
||||
let handlebar = TEMPLATE.read();
|
||||
let payload = match ignore_cache {
|
||||
Some(_) => handlebar.render_template(webhook_template_content(template), &data)?,
|
||||
None => handlebar.render(&key, &data)?,
|
||||
};
|
||||
client
|
||||
.post(url)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(payload)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::api::{LogHelper, router};
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::config::{VersionedConfig, default_bind_address};
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[preserve_source = false]
|
||||
@@ -30,11 +30,30 @@ pub async fn http_server(
|
||||
.layer(Extension(database_connection))
|
||||
.layer(Extension(bili_client))
|
||||
.layer(Extension(log_writer));
|
||||
let config = VersionedConfig::get().load_full();
|
||||
let listener = tokio::net::TcpListener::bind(&config.bind_address)
|
||||
.await
|
||||
.context("bind address failed")?;
|
||||
info!("开始运行管理页:http://{}", config.bind_address);
|
||||
let (bind_address, listener) = {
|
||||
let bind_address = VersionedConfig::get().read().bind_address.to_owned();
|
||||
let listen_res = tokio::net::TcpListener::bind(&bind_address)
|
||||
.await
|
||||
.context("bind address failed");
|
||||
match listen_res {
|
||||
Ok(listener) => (bind_address, listener),
|
||||
Err(e) => {
|
||||
let default_bind_address = default_bind_address();
|
||||
if default_bind_address == bind_address {
|
||||
return Err(e);
|
||||
}
|
||||
warn!(
|
||||
"绑定到地址 {} 失败:{:#},尝试绑定到默认地址 {}",
|
||||
bind_address, e, default_bind_address
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(&default_bind_address)
|
||||
.await
|
||||
.context("bind default address failed")?;
|
||||
(default_bind_address, listener)
|
||||
}
|
||||
}
|
||||
};
|
||||
info!("开始运行管理页:http://{}", bind_address);
|
||||
Ok(axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ mod http_server;
|
||||
mod video_downloader;
|
||||
|
||||
pub use http_server::http_server;
|
||||
pub use video_downloader::video_downloader;
|
||||
pub use video_downloader::{DownloadTaskManager, TaskStatus, video_downloader};
|
||||
|
||||
@@ -1,62 +1,373 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::time;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{OnceCell, watch};
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
|
||||
use crate::adapter::VideoSource;
|
||||
use crate::bilibili::{self, BiliClient};
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::bilibili::{self, BiliClient, BiliError};
|
||||
use crate::config::{ARGS, Config, TEMPLATE, Trigger, VersionedConfig};
|
||||
use crate::utils::model::get_enabled_video_sources;
|
||||
use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
|
||||
use crate::utils::notify::error_and_notify;
|
||||
use crate::workflow::process_video_source;
|
||||
|
||||
static INSTANCE: OnceCell<DownloadTaskManager> = OnceCell::const_new();
|
||||
|
||||
/// 启动周期下载视频的任务
|
||||
pub async fn video_downloader(connection: DatabaseConnection, bili_client: Arc<BiliClient>) {
|
||||
let mut anchor = chrono::Local::now().date_naive();
|
||||
loop {
|
||||
info!("开始执行本轮视频下载任务..");
|
||||
let _lock = TASK_STATUS_NOTIFIER.start_running().await;
|
||||
let config = VersionedConfig::get().load_full();
|
||||
'inner: {
|
||||
if let Err(e) = config.check() {
|
||||
error!("配置检查失败,跳过本轮执行:\n{:#}", e);
|
||||
break 'inner;
|
||||
}
|
||||
match bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) {
|
||||
Ok(Some(mixin_key)) => bilibili::set_global_mixin_key(mixin_key),
|
||||
Ok(_) => {
|
||||
error!("解析 mixin key 失败,等待下一轮执行");
|
||||
break 'inner;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("获取 mixin key 遇到错误:{:#},等待下一轮执行", e);
|
||||
break 'inner;
|
||||
}
|
||||
};
|
||||
if anchor != chrono::Local::now().date_naive() {
|
||||
if let Err(e) = bili_client.check_refresh(&connection).await {
|
||||
error!("检查刷新 Credential 遇到错误:{:#},等待下一轮执行", e);
|
||||
break 'inner;
|
||||
}
|
||||
anchor = chrono::Local::now().date_naive();
|
||||
}
|
||||
let Ok(video_sources) = get_enabled_video_sources(&connection).await else {
|
||||
error!("获取视频源列表失败,等待下一轮执行");
|
||||
break 'inner;
|
||||
};
|
||||
if video_sources.is_empty() {
|
||||
info!("没有可用的视频源,等待下一轮执行");
|
||||
break 'inner;
|
||||
}
|
||||
for video_source in video_sources {
|
||||
let display_name = video_source.display_name();
|
||||
if let Err(e) = process_video_source(video_source, &bili_client, &connection).await {
|
||||
error!("处理 {} 时遇到错误:{:#},等待下一轮执行", display_name, e);
|
||||
}
|
||||
}
|
||||
info!("本轮任务执行完毕,等待下一轮执行");
|
||||
pub async fn video_downloader(connection: DatabaseConnection, bili_client: Arc<BiliClient>) -> Result<()> {
|
||||
let task_manager = DownloadTaskManager::init(connection, bili_client).await?;
|
||||
task_manager.start().await
|
||||
}
|
||||
|
||||
pub struct DownloadTaskManager {
|
||||
sched: Arc<tokio::sync::Mutex<JobScheduler>>,
|
||||
cx: Arc<TaskContext>,
|
||||
shutdown_rx: watch::Receiver<Result<()>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default, Clone, Copy, Debug)]
|
||||
pub struct TaskStatus {
|
||||
is_running: bool,
|
||||
last_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
last_finish: Option<chrono::DateTime<chrono::Local>>,
|
||||
next_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
struct TaskContext {
|
||||
connection: DatabaseConnection,
|
||||
bili_client: Arc<BiliClient>,
|
||||
running: tokio::sync::Mutex<()>,
|
||||
status_tx: watch::Sender<TaskStatus>,
|
||||
status_rx: watch::Receiver<TaskStatus>,
|
||||
video_task_id: tokio::sync::Mutex<Option<uuid::Uuid>>, // 存储当前视频下载任务的 UUID
|
||||
}
|
||||
|
||||
impl DownloadTaskManager {
|
||||
/// 初始化 DownloadTaskManager 单例
|
||||
pub async fn init(
|
||||
connection: DatabaseConnection,
|
||||
bili_client: Arc<BiliClient>,
|
||||
) -> Result<&'static DownloadTaskManager> {
|
||||
INSTANCE
|
||||
.get_or_try_init(|| DownloadTaskManager::new(connection, bili_client))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 获取 DownloadTaskManager 单例,未初始化时直接 panic
|
||||
pub fn get() -> &'static DownloadTaskManager {
|
||||
INSTANCE.get().expect("DownloadTaskManager is not initialized")
|
||||
}
|
||||
|
||||
/// 订阅下载任务的状态更新
|
||||
pub fn subscribe(&self) -> watch::Receiver<TaskStatus> {
|
||||
self.cx.status_rx.clone()
|
||||
}
|
||||
|
||||
/// 手动执行一次下载任务
|
||||
pub async fn download_once(&self) -> Result<()> {
|
||||
let _ = self
|
||||
.sched
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_one_shot_async(
|
||||
Duration::from_secs(0),
|
||||
DownloadTaskManager::download_video_task(self.cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动任务调度器
|
||||
async fn start(&self) -> Result<()> {
|
||||
self.sched.lock().await.start().await?;
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
shutdown_rx.changed().await?;
|
||||
self.sched.lock().await.shutdown().await.context("任务调度器关闭失败")?;
|
||||
if let Err(e) = &*shutdown_rx.borrow() {
|
||||
bail!("{:#}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 私有的调度器构造函数
|
||||
async fn new(connection: DatabaseConnection, bili_client: Arc<BiliClient>) -> Result<Self> {
|
||||
let sched = Arc::new(tokio::sync::Mutex::new(JobScheduler::new().await?));
|
||||
let (status_tx, status_rx) = watch::channel(TaskStatus::default());
|
||||
let (running, video_task_id) = (tokio::sync::Mutex::new(()), tokio::sync::Mutex::new(None));
|
||||
let cx = Arc::new(TaskContext {
|
||||
connection,
|
||||
bili_client,
|
||||
running,
|
||||
status_tx,
|
||||
status_rx,
|
||||
video_task_id,
|
||||
});
|
||||
// 读取初始配置
|
||||
let mut rx = VersionedConfig::get().subscribe();
|
||||
let initial_config = rx.borrow_and_update().clone();
|
||||
if ARGS.disable_credential_refresh {
|
||||
warn!("已禁用凭据检查与刷新任务,bili-sync 将不会自动检查刷新 Credential,需要用户自行维护");
|
||||
} else {
|
||||
// 初始化凭据检查与刷新任务,该任务必须成功,否则直接退出
|
||||
sched
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_async_tz(
|
||||
"0 0 1 * * *",
|
||||
chrono::Local,
|
||||
DownloadTaskManager::check_and_refresh_credential_task(cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
// 初始化并添加视频下载任务,将任务 ID 保存到 TaskManager 中
|
||||
let video_task_id = async {
|
||||
let job_run = DownloadTaskManager::download_video_task(cx.clone());
|
||||
let job = match &initial_config.interval {
|
||||
Trigger::Interval(interval) => Job::new_repeated_async(Duration::from_secs(*interval), job_run)?,
|
||||
Trigger::Cron(cron) => Job::new_async_tz(cron, chrono::Local, job_run)?,
|
||||
};
|
||||
Result::<_, anyhow::Error>::Ok(sched.lock().await.add(job).await?)
|
||||
}
|
||||
.await;
|
||||
let video_task_id = match video_task_id {
|
||||
Ok(id) => Some(id),
|
||||
Err(err) => {
|
||||
error_and_notify(
|
||||
&initial_config,
|
||||
&cx.bili_client,
|
||||
format!("初始化视频下载任务失败:{:#}", err),
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
*cx.video_task_id.lock().await = video_task_id;
|
||||
// 发起一个一次性的任务,更新一下下次运行的时间
|
||||
if let Some(video_task_id) = video_task_id {
|
||||
sched
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_one_shot_async(
|
||||
Duration::from_secs(0),
|
||||
DownloadTaskManager::refresh_next_run(video_task_id, cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
// 发起一个新任务,用来监听配置变更,动态更新视频下载任务
|
||||
let cx_clone = cx.clone();
|
||||
let sched_clone = sched.clone();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(Ok(()));
|
||||
tokio::spawn(async move {
|
||||
let update_task_result = async {
|
||||
while rx.changed().await.is_ok() {
|
||||
let new_config = rx.borrow().clone();
|
||||
let cx = cx_clone.clone();
|
||||
let mut video_task_id = cx.video_task_id.lock().await;
|
||||
if let Some(old_video_task_id) = *video_task_id {
|
||||
// 这里必须成功,不然后面会重复添加任务
|
||||
sched_clone
|
||||
.lock()
|
||||
.await
|
||||
.remove(&old_video_task_id)
|
||||
.await
|
||||
.context("移除旧的视频下载任务失败")?;
|
||||
}
|
||||
let new_video_task_id = async {
|
||||
let job_run = DownloadTaskManager::download_video_task(cx.clone());
|
||||
let job = match &new_config.interval {
|
||||
Trigger::Interval(interval) => {
|
||||
Job::new_repeated_async(Duration::from_secs(*interval), job_run)?
|
||||
}
|
||||
Trigger::Cron(cron) => Job::new_async_tz(cron, chrono::Local, job_run)?,
|
||||
};
|
||||
Result::<_, anyhow::Error>::Ok(sched_clone.lock().await.add(job).await?)
|
||||
}
|
||||
.await;
|
||||
let new_video_task_id = match new_video_task_id {
|
||||
Ok(id) => Some(id),
|
||||
Err(err) => {
|
||||
error_and_notify(
|
||||
&initial_config,
|
||||
&cx.bili_client,
|
||||
format!("重载视频下载任务失败:{:#}", err),
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
*video_task_id = new_video_task_id;
|
||||
if let Some(video_task_id) = new_video_task_id {
|
||||
sched_clone
|
||||
.lock()
|
||||
.await
|
||||
.add(Job::new_one_shot_async(
|
||||
Duration::from_secs(0),
|
||||
DownloadTaskManager::refresh_next_run(video_task_id, cx.clone()),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Result::<(), anyhow::Error>::Ok(())
|
||||
}
|
||||
.await;
|
||||
// 如果执行正常,上面应该是永远不会退出的
|
||||
let _ = shutdown_tx.send(update_task_result);
|
||||
});
|
||||
Ok(Self { sched, cx, shutdown_rx })
|
||||
}
|
||||
|
||||
fn check_and_refresh_credential_task(
|
||||
cx: Arc<TaskContext>,
|
||||
) -> impl FnMut(uuid::Uuid, JobScheduler) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
move |_uuid, _l| {
|
||||
let cx = cx.clone();
|
||||
Box::pin(async move {
|
||||
let _lock = cx.running.lock().await;
|
||||
let config = VersionedConfig::get().read();
|
||||
info!("开始执行本轮凭据检查与刷新任务..");
|
||||
match check_and_refresh_credential(&cx.connection, &cx.bili_client, &config).await {
|
||||
Ok(_) => info!("本轮凭据检查与刷新任务执行完毕"),
|
||||
Err(e) => {
|
||||
error_and_notify(
|
||||
&config,
|
||||
&cx.bili_client,
|
||||
format!("本轮凭据检查与刷新任务执行遇到错误:{:#}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_next_run(
|
||||
video_task_id: uuid::Uuid,
|
||||
cx: Arc<TaskContext>,
|
||||
) -> impl FnMut(uuid::Uuid, JobScheduler) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
move |_uuid, mut l| {
|
||||
let cx = cx.clone();
|
||||
Box::pin(async move {
|
||||
let old_status = *cx.status_rx.borrow();
|
||||
let next_run = l
|
||||
.next_tick_for_job(video_task_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|dt| dt.with_timezone(&chrono::Local));
|
||||
let _ = cx.status_tx.send(TaskStatus { next_run, ..old_status });
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn download_video_task(
|
||||
cx: Arc<TaskContext>,
|
||||
) -> impl FnMut(uuid::Uuid, JobScheduler) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
move |uuid, mut l| {
|
||||
let cx = cx.clone();
|
||||
Box::pin(async move {
|
||||
let Ok(_lock) = cx.running.try_lock() else {
|
||||
warn!("上一次视频下载任务尚未结束,跳过本次执行..");
|
||||
return;
|
||||
};
|
||||
let _ = cx.status_tx.send(TaskStatus {
|
||||
is_running: true,
|
||||
last_run: Some(chrono::Local::now()),
|
||||
last_finish: None,
|
||||
next_run: None,
|
||||
});
|
||||
info!("开始执行本轮视频下载任务..");
|
||||
let mut config = VersionedConfig::get().snapshot();
|
||||
match download_video(&cx.connection, &cx.bili_client, &mut config).await {
|
||||
Ok(_) => info!("本轮视频下载任务执行完毕"),
|
||||
Err(e) => {
|
||||
error_and_notify(
|
||||
&config,
|
||||
&cx.bili_client,
|
||||
format!("本轮视频下载任务执行遇到错误:{:#}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 注意此处尽量从 updating 中读取 uuid,因为当前任务可能是不存在 next_tick 的 oneshot 任务
|
||||
let task_uuid = (*cx.video_task_id.lock().await).unwrap_or(uuid);
|
||||
let next_run = l
|
||||
.next_tick_for_job(task_uuid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|dt| dt.with_timezone(&chrono::Local));
|
||||
let last_status = *cx.status_rx.borrow();
|
||||
let _ = cx.status_tx.send(TaskStatus {
|
||||
is_running: false,
|
||||
last_run: last_status.last_run,
|
||||
last_finish: Some(chrono::Local::now()),
|
||||
next_run,
|
||||
});
|
||||
})
|
||||
}
|
||||
TASK_STATUS_NOTIFIER.finish_running(_lock);
|
||||
time::sleep(time::Duration::from_secs(config.interval)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_and_refresh_credential(
|
||||
connection: &DatabaseConnection,
|
||||
bili_client: &BiliClient,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
match bili_client
|
||||
.check_refresh(&config.credential)
|
||||
.await
|
||||
.context("检查刷新 Credential 失败")?
|
||||
{
|
||||
None => {
|
||||
info!("Credential 无需刷新");
|
||||
}
|
||||
Some(new_credential) => {
|
||||
VersionedConfig::get()
|
||||
.update_credential(new_credential, connection)
|
||||
.await
|
||||
.context("新 Credential 持久化失败")?;
|
||||
info!("Credential 已刷新并保存");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_video(
|
||||
connection: &DatabaseConnection,
|
||||
bili_client: &BiliClient,
|
||||
config: &mut Arc<Config>,
|
||||
) -> Result<()> {
|
||||
config.check().context("配置检查失败")?;
|
||||
let mixin_key = bili_client
|
||||
.wbi_img(&config.credential)
|
||||
.await
|
||||
.context("获取 wbi_img 失败")?
|
||||
.into_mixin_key()
|
||||
.context("解析 mixin key 失败")?;
|
||||
bilibili::set_global_mixin_key(mixin_key);
|
||||
let template = TEMPLATE.snapshot();
|
||||
let bili_client = bili_client.snapshot()?;
|
||||
let video_sources = get_enabled_video_sources(connection)
|
||||
.await
|
||||
.context("获取视频源列表失败")?;
|
||||
if video_sources.is_empty() {
|
||||
bail!("没有可用的视频源");
|
||||
}
|
||||
for video_source in video_sources {
|
||||
let display_name = video_source.display_name();
|
||||
if let Err(e) = process_video_source(video_source, &bili_client, connection, &template, config).await {
|
||||
error_and_notify(
|
||||
config,
|
||||
&bili_client,
|
||||
format!("处理 {} 时遇到错误:{:#},跳过该视频源", display_name, e),
|
||||
);
|
||||
if let Ok(e) = e.downcast::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
warn!("检测到风控,终止此轮视频下载任务..");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ impl VideoInfo {
|
||||
let default = bili_sync_entity::video::ActiveModel {
|
||||
id: NotSet,
|
||||
created_at: NotSet,
|
||||
should_download: NotSet,
|
||||
// 此处不使用 ActiveModel::default() 是为了让其它字段有默认值
|
||||
..bili_sync_entity::video::Model::default().into_active_model()
|
||||
};
|
||||
@@ -49,7 +50,7 @@ impl VideoInfo {
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: Set(fav_time.naive_utc()),
|
||||
download_status: Set(0),
|
||||
valid: Set(attr == 0),
|
||||
valid: Set(attr == 0 || attr == 4),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name),
|
||||
upper_face: Set(upper.face),
|
||||
@@ -97,7 +98,23 @@ impl VideoInfo {
|
||||
valid: Set(true),
|
||||
..default
|
||||
},
|
||||
_ => unreachable!(),
|
||||
VideoInfo::Dynamic {
|
||||
title,
|
||||
bvid,
|
||||
desc,
|
||||
cover,
|
||||
pubtime,
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid),
|
||||
name: Set(title),
|
||||
intro: Set(desc),
|
||||
cover: Set(cover),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
category: Set(2), // 动态里的视频内容类型肯定是视频
|
||||
valid: Set(true),
|
||||
..default
|
||||
},
|
||||
VideoInfo::Detail { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +131,13 @@ impl VideoInfo {
|
||||
ctime,
|
||||
pubtime,
|
||||
state,
|
||||
is_upower_exclusive,
|
||||
is_upower_play,
|
||||
redirect_url,
|
||||
..
|
||||
} => bili_sync_entity::video::ActiveModel {
|
||||
bvid: Set(bvid),
|
||||
name: Set(title),
|
||||
category: Set(2),
|
||||
intro: Set(intro),
|
||||
cover: Set(cover),
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
@@ -129,7 +148,13 @@ impl VideoInfo {
|
||||
Set(pubtime.naive_utc()) // 未设置过 favtime,使用 pubtime 填充
|
||||
},
|
||||
download_status: Set(0),
|
||||
valid: Set(state == 0),
|
||||
// state == 0 表示开放浏览
|
||||
// is_upower_exclusive 和 is_upower_play 相等有两种情况:
|
||||
// 1. 都为 true,表示视频是充电专享但是已经充过电,有权观看
|
||||
// 2. 都为 false,表示视频是非充电视频
|
||||
// redirect_url 仅在视频为番剧、影视、纪录片等特殊视频时才会有值,如果为空说明是普通视频
|
||||
// 仅在三种条件都满足时,才认为视频是可下载的
|
||||
valid: Set(state == 0 && (is_upower_exclusive == is_upower_play) && redirect_url.is_none()),
|
||||
upper_id: Set(upper.mid),
|
||||
upper_name: Set(upper.name),
|
||||
upper_face: Set(upper.face),
|
||||
@@ -145,8 +170,9 @@ impl VideoInfo {
|
||||
VideoInfo::Collection { pubtime: time, .. }
|
||||
| VideoInfo::Favorite { fav_time: time, .. }
|
||||
| VideoInfo::WatchLater { fav_time: time, .. }
|
||||
| VideoInfo::Submission { ctime: time, .. } => time,
|
||||
_ => unreachable!(),
|
||||
| VideoInfo::Submission { ctime: time, .. }
|
||||
| VideoInfo::Dynamic { pubtime: time, .. } => time,
|
||||
VideoInfo::Detail { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
crates/bili_sync/src/utils/download_context.rs
Normal file
36
crates/bili_sync/src/utils/download_context.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::adapter::VideoSourceEnum;
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::Config;
|
||||
use crate::downloader::Downloader;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DownloadContext<'a> {
|
||||
pub bili_client: &'a BiliClient,
|
||||
pub video_source: &'a VideoSourceEnum,
|
||||
pub template: &'a handlebars::Handlebars<'a>,
|
||||
pub connection: &'a DatabaseConnection,
|
||||
pub downloader: &'a Downloader,
|
||||
pub config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> DownloadContext<'a> {
|
||||
pub fn new(
|
||||
bili_client: &'a BiliClient,
|
||||
video_source: &'a VideoSourceEnum,
|
||||
template: &'a handlebars::Handlebars<'a>,
|
||||
connection: &'a DatabaseConnection,
|
||||
downloader: &'a Downloader,
|
||||
config: &'a Config,
|
||||
) -> Self {
|
||||
Self {
|
||||
bili_client,
|
||||
video_source,
|
||||
template,
|
||||
connection,
|
||||
downloader,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
pub fn video_format_args(video_model: &bili_sync_entity::video::Model) -> serde_json::Value {
|
||||
let config = VersionedConfig::get().load();
|
||||
pub fn video_format_args(video_model: &bili_sync_entity::video::Model, time_format: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"bvid": &video_model.bvid,
|
||||
"title": &video_model.name,
|
||||
"upper_name": &video_model.upper_name,
|
||||
"upper_mid": &video_model.upper_id,
|
||||
"pubtime": &video_model.pubtime.and_utc().format(&config.time_format).to_string(),
|
||||
"fav_time": &video_model.favtime.and_utc().format(&config.time_format).to_string(),
|
||||
"pubtime": &video_model.pubtime.and_utc().format(time_format).to_string(),
|
||||
"fav_time": &video_model.favtime.and_utc().format(time_format).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn page_format_args(
|
||||
video_model: &bili_sync_entity::video::Model,
|
||||
page_model: &bili_sync_entity::page::Model,
|
||||
time_format: &str,
|
||||
) -> serde_json::Value {
|
||||
let config = VersionedConfig::get().load();
|
||||
json!({
|
||||
"bvid": &video_model.bvid,
|
||||
"title": &video_model.name,
|
||||
@@ -26,7 +23,7 @@ pub fn page_format_args(
|
||||
"upper_mid": &video_model.upper_id,
|
||||
"ptitle": &page_model.name,
|
||||
"pid": page_model.pid,
|
||||
"pubtime": video_model.pubtime.and_utc().format(&config.time_format).to_string(),
|
||||
"fav_time": video_model.favtime.and_utc().format(&config.time_format).to_string(),
|
||||
"pubtime": video_model.pubtime.and_utc().format(time_format).to_string(),
|
||||
"fav_time": video_model.favtime.and_utc().format(time_format).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
pub mod convert;
|
||||
pub mod download_context;
|
||||
pub mod filenamify;
|
||||
pub mod format_arg;
|
||||
pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod notify;
|
||||
pub mod rule;
|
||||
pub mod signal;
|
||||
pub mod status;
|
||||
pub mod task_notifier;
|
||||
pub mod validation;
|
||||
use tracing_subscriber::fmt;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use bili_sync_entity::*;
|
||||
use rand::seq::SliceRandom;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::DatabaseTransaction;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sea_query::{OnConflict, SimpleExpr};
|
||||
use sea_orm::{DatabaseTransaction, TransactionTrait};
|
||||
|
||||
use crate::adapter::{VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::VideoInfo;
|
||||
use crate::config::{Config, LegacyConfig};
|
||||
use crate::config::Config;
|
||||
use crate::utils::status::STATUS_COMPLETED;
|
||||
|
||||
/// 筛选未填充的视频
|
||||
@@ -134,6 +135,8 @@ pub async fn get_enabled_video_sources(connection: &DatabaseConnection) -> Resul
|
||||
sources.extend(watch_later.into_iter().map(VideoSourceEnum::from));
|
||||
sources.extend(submission.into_iter().map(VideoSourceEnum::from));
|
||||
sources.extend(collection.into_iter().map(VideoSourceEnum::from));
|
||||
// 此处将视频源随机打乱顺序,从概率上确保每个视频源都有机会优先执行,避免后面视频源的长期饥饿问题
|
||||
sources.shuffle(&mut rand::rng());
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
@@ -166,69 +169,3 @@ pub async fn save_db_config(config: &Config, connection: &DatabaseConnection) ->
|
||||
.context("Failed to save config to database")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 迁移旧版本配置(即将所有相关联的内容设置为 enabled)
|
||||
pub async fn migrate_legacy_config(config: &LegacyConfig, connection: &DatabaseConnection) -> Result<()> {
|
||||
let transaction = connection.begin().await.context("Failed to begin transaction")?;
|
||||
tokio::try_join!(
|
||||
migrate_favorite(config, &transaction),
|
||||
migrate_watch_later(config, &transaction),
|
||||
migrate_submission(config, &transaction),
|
||||
migrate_collection(config, &transaction)
|
||||
)?;
|
||||
transaction.commit().await.context("Failed to commit transaction")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_favorite(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
|
||||
favorite::Entity::update_many()
|
||||
.filter(favorite::Column::FId.is_in(config.favorite_list.keys().collect::<Vec<_>>()))
|
||||
.col_expr(favorite::Column::Enabled, Expr::value(true))
|
||||
.exec(connection)
|
||||
.await
|
||||
.context("Failed to migrate favorite config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_watch_later(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
|
||||
if config.watch_later.enabled {
|
||||
watch_later::Entity::update_many()
|
||||
.col_expr(watch_later::Column::Enabled, Expr::value(true))
|
||||
.exec(connection)
|
||||
.await
|
||||
.context("Failed to migrate watch later config")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_submission(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
|
||||
submission::Entity::update_many()
|
||||
.filter(submission::Column::UpperId.is_in(config.submission_list.keys().collect::<Vec<_>>()))
|
||||
.col_expr(submission::Column::Enabled, Expr::value(true))
|
||||
.exec(connection)
|
||||
.await
|
||||
.context("Failed to migrate submission config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_collection(config: &LegacyConfig, connection: &DatabaseTransaction) -> Result<()> {
|
||||
let tuples: Vec<(i64, i64, i32)> = config
|
||||
.collection_list
|
||||
.keys()
|
||||
.filter_map(|key| Some((key.sid.parse().ok()?, key.mid.parse().ok()?, key.collection_type.into())))
|
||||
.collect();
|
||||
collection::Entity::update_many()
|
||||
.filter(
|
||||
Expr::tuple([
|
||||
Expr::column(collection::Column::SId),
|
||||
Expr::column(collection::Column::MId),
|
||||
Expr::column(collection::Column::Type),
|
||||
])
|
||||
.in_tuples(tuples),
|
||||
)
|
||||
.col_expr(collection::Column::Enabled, Expr::value(true))
|
||||
.exec(connection)
|
||||
.await
|
||||
.context("Failed to migrate collection config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use quick_xml::events::{BytesCData, BytesText};
|
||||
use quick_xml::writer::Writer;
|
||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||
|
||||
use crate::config::{NFOTimeType, VersionedConfig};
|
||||
use crate::config::NFOTimeType;
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum NFO<'a> {
|
||||
@@ -22,7 +22,8 @@ pub struct Movie<'a> {
|
||||
pub bvid: &'a str,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: &'a str,
|
||||
pub aired: NaiveDateTime,
|
||||
pub upper_thumb: &'a str,
|
||||
pub premiered: NaiveDateTime,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -32,7 +33,8 @@ pub struct TVShow<'a> {
|
||||
pub bvid: &'a str,
|
||||
pub upper_id: i64,
|
||||
pub upper_name: &'a str,
|
||||
pub aired: NaiveDateTime,
|
||||
pub upper_thumb: &'a str,
|
||||
pub premiered: NaiveDateTime,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -96,12 +98,16 @@ impl NFO<'_> {
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(movie.upper_name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(movie.upper_thumb))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&movie.aired.format("%Y").to_string()))
|
||||
.write_text_content_async(BytesText::new(&movie.premiered.format("%Y").to_string()))
|
||||
.await?;
|
||||
if let Some(tags) = movie.tags {
|
||||
for tag in tags {
|
||||
@@ -117,8 +123,8 @@ impl NFO<'_> {
|
||||
.write_text_content_async(BytesText::new(movie.bvid))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&movie.aired.format("%Y-%m-%d").to_string()))
|
||||
.create_element("premiered")
|
||||
.write_text_content_async(BytesText::new(&movie.premiered.format("%Y-%m-%d").to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
@@ -150,12 +156,16 @@ impl NFO<'_> {
|
||||
.create_element("role")
|
||||
.write_text_content_async(BytesText::new(tvshow.upper_name))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("thumb")
|
||||
.write_text_content_async(BytesText::new(tvshow.upper_thumb))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
.await?;
|
||||
writer
|
||||
.create_element("year")
|
||||
.write_text_content_async(BytesText::new(&tvshow.aired.format("%Y").to_string()))
|
||||
.write_text_content_async(BytesText::new(&tvshow.premiered.format("%Y").to_string()))
|
||||
.await?;
|
||||
if let Some(tags) = tvshow.tags {
|
||||
for tag in tags {
|
||||
@@ -171,8 +181,8 @@ impl NFO<'_> {
|
||||
.write_text_content_async(BytesText::new(tvshow.bvid))
|
||||
.await?;
|
||||
writer
|
||||
.create_element("aired")
|
||||
.write_text_content_async(BytesText::new(&tvshow.aired.format("%Y-%m-%d").to_string()))
|
||||
.create_element("premiered")
|
||||
.write_text_content_async(BytesText::new(&tvshow.premiered.format("%Y-%m-%d").to_string()))
|
||||
.await?;
|
||||
Ok(writer)
|
||||
})
|
||||
@@ -252,6 +262,7 @@ mod tests {
|
||||
name: "name".to_string(),
|
||||
upper_id: 1,
|
||||
upper_name: "upper_name".to_string(),
|
||||
upper_face: "https://i1.hdslb.com/bfs/face/72e8f33cadc72e022fc34624cc69e1b12ebb72c0.jpg".to_string(),
|
||||
favtime: chrono::NaiveDateTime::new(
|
||||
chrono::NaiveDate::from_ymd_opt(2022, 2, 2).unwrap(),
|
||||
chrono::NaiveTime::from_hms_opt(2, 2, 2).unwrap(),
|
||||
@@ -265,7 +276,10 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
NFO::Movie((&video).into()).generate_nfo().await.unwrap(),
|
||||
NFO::Movie((&video).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<plot><![CDATA[原始视频:<a href="https://www.bilibili.com/video/BV1nWcSeeEkV/">BV1nWcSeeEkV</a><br/><br/>intro]]></plot>
|
||||
@@ -274,16 +288,20 @@ mod tests {
|
||||
<actor>
|
||||
<name>1</name>
|
||||
<role>upper_name</role>
|
||||
<thumb>https://i1.hdslb.com/bfs/face/72e8f33cadc72e022fc34624cc69e1b12ebb72c0.jpg</thumb>
|
||||
</actor>
|
||||
<year>2022</year>
|
||||
<genre>tag1</genre>
|
||||
<genre>tag2</genre>
|
||||
<uniqueid type="bilibili">BV1nWcSeeEkV</uniqueid>
|
||||
<aired>2022-02-02</aired>
|
||||
<premiered>2022-02-02</premiered>
|
||||
</movie>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFO::TVShow((&video).into()).generate_nfo().await.unwrap(),
|
||||
NFO::TVShow((&video).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<tvshow>
|
||||
<plot><![CDATA[原始视频:<a href="https://www.bilibili.com/video/BV1nWcSeeEkV/">BV1nWcSeeEkV</a><br/><br/>intro]]></plot>
|
||||
@@ -292,16 +310,20 @@ mod tests {
|
||||
<actor>
|
||||
<name>1</name>
|
||||
<role>upper_name</role>
|
||||
<thumb>https://i1.hdslb.com/bfs/face/72e8f33cadc72e022fc34624cc69e1b12ebb72c0.jpg</thumb>
|
||||
</actor>
|
||||
<year>2022</year>
|
||||
<genre>tag1</genre>
|
||||
<genre>tag2</genre>
|
||||
<uniqueid type="bilibili">BV1nWcSeeEkV</uniqueid>
|
||||
<aired>2022-02-02</aired>
|
||||
<premiered>2022-02-02</premiered>
|
||||
</tvshow>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
NFO::Upper((&video).into()).generate_nfo().await.unwrap(),
|
||||
NFO::Upper((&video).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<person>
|
||||
<plot/>
|
||||
@@ -318,7 +340,10 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
NFO::Episode((&page).into()).generate_nfo().await.unwrap(),
|
||||
NFO::Episode((&page).to_nfo(NFOTimeType::FavTime))
|
||||
.generate_nfo()
|
||||
.await
|
||||
.unwrap(),
|
||||
r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<episodedetails>
|
||||
<plot/>
|
||||
@@ -331,54 +356,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a video::Model> for Movie<'a> {
|
||||
fn from(video: &'a video::Model) -> Self {
|
||||
Self {
|
||||
name: &video.name,
|
||||
intro: &video.intro,
|
||||
bvid: &video.bvid,
|
||||
upper_id: video.upper_id,
|
||||
upper_name: &video.upper_name,
|
||||
aired: match VersionedConfig::get().load().nfo_time_type {
|
||||
NFOTimeType::FavTime => video.favtime,
|
||||
NFOTimeType::PubTime => video.pubtime,
|
||||
pub trait ToNFO<'a, T> {
|
||||
fn to_nfo(&'a self, nfo_time_type: NFOTimeType) -> T;
|
||||
}
|
||||
|
||||
impl<'a> ToNFO<'a, Movie<'a>> for &'a video::Model {
|
||||
fn to_nfo(&'a self, nfo_time_type: NFOTimeType) -> Movie<'a> {
|
||||
Movie {
|
||||
name: &self.name,
|
||||
intro: &self.intro,
|
||||
bvid: &self.bvid,
|
||||
upper_id: self.upper_id,
|
||||
upper_name: &self.upper_name,
|
||||
upper_thumb: &self.upper_face,
|
||||
premiered: match nfo_time_type {
|
||||
NFOTimeType::FavTime => self.favtime,
|
||||
NFOTimeType::PubTime => self.pubtime,
|
||||
},
|
||||
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
tags: self.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a video::Model> for TVShow<'a> {
|
||||
fn from(video: &'a video::Model) -> Self {
|
||||
Self {
|
||||
name: &video.name,
|
||||
intro: &video.intro,
|
||||
bvid: &video.bvid,
|
||||
upper_id: video.upper_id,
|
||||
upper_name: &video.upper_name,
|
||||
aired: match VersionedConfig::get().load().nfo_time_type {
|
||||
NFOTimeType::FavTime => video.favtime,
|
||||
NFOTimeType::PubTime => video.pubtime,
|
||||
impl<'a> ToNFO<'a, TVShow<'a>> for &'a video::Model {
|
||||
fn to_nfo(&'a self, nfo_time_type: NFOTimeType) -> TVShow<'a> {
|
||||
TVShow {
|
||||
name: &self.name,
|
||||
intro: &self.intro,
|
||||
bvid: &self.bvid,
|
||||
upper_id: self.upper_id,
|
||||
upper_name: &self.upper_name,
|
||||
upper_thumb: &self.upper_face,
|
||||
premiered: match nfo_time_type {
|
||||
NFOTimeType::FavTime => self.favtime,
|
||||
NFOTimeType::PubTime => self.pubtime,
|
||||
},
|
||||
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
tags: self.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a video::Model> for Upper {
|
||||
fn from(video: &'a video::Model) -> Self {
|
||||
Self {
|
||||
upper_id: video.upper_id.to_string(),
|
||||
pubtime: video.pubtime,
|
||||
impl<'a> ToNFO<'a, Upper> for &'a video::Model {
|
||||
fn to_nfo(&'a self, _nfo_time_type: NFOTimeType) -> Upper {
|
||||
Upper {
|
||||
upper_id: self.upper_id.to_string(),
|
||||
pubtime: self.pubtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a page::Model> for Episode<'a> {
|
||||
fn from(page: &'a page::Model) -> Self {
|
||||
Self {
|
||||
name: &page.name,
|
||||
pid: page.pid.to_string(),
|
||||
impl<'a> ToNFO<'a, Episode<'a>> for &'a page::Model {
|
||||
fn to_nfo(&'a self, _nfo_time_type: NFOTimeType) -> Episode<'a> {
|
||||
Episode {
|
||||
name: &self.name,
|
||||
pid: self.pid.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
crates/bili_sync/src/utils/notify.rs
Normal file
13
crates/bili_sync/src/utils/notify.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use crate::bilibili::BiliClient;
|
||||
use crate::config::Config;
|
||||
use crate::notifier::NotifierAllExt;
|
||||
|
||||
pub fn error_and_notify(config: &Config, bili_client: &BiliClient, msg: String) {
|
||||
error!("{msg}");
|
||||
if let Some(notifiers) = &config.notifiers
|
||||
&& !notifiers.is_empty()
|
||||
{
|
||||
let (notifiers, inner_client) = (notifiers.clone(), bili_client.inner_client().clone());
|
||||
tokio::spawn(async move { notifiers.notify_all(&inner_client, msg.as_str()).await });
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ impl Evaluatable<&str> for Condition<String> {
|
||||
match self {
|
||||
Condition::Equals(expected) => expected == value,
|
||||
Condition::Contains(substring) => value.contains(substring),
|
||||
Condition::IContains(substring) => value.to_lowercase().contains(&substring.to_lowercase()),
|
||||
Condition::Prefix(prefix) => value.starts_with(prefix),
|
||||
Condition::Suffix(suffix) => value.ends_with(suffix),
|
||||
Condition::MatchesRegex(_, regex) => regex.is_match(value),
|
||||
|
||||
@@ -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
|
||||
@@ -34,11 +48,14 @@ impl<const N: usize> Status<N> {
|
||||
let mut changed = false;
|
||||
for i in 0..N {
|
||||
let status = self.get_status(i);
|
||||
if !(status < STATUS_MAX_RETRY || status == STATUS_OK) {
|
||||
if status != STATUS_NOT_STARTED && status != STATUS_OK {
|
||||
self.set_status(i, STATUS_NOT_STARTED);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.set_completed(false);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -51,8 +68,8 @@ impl<const N: usize> Status<N> {
|
||||
// 但考虑特殊情况,新版本引入了一个新的子任务项,此时会出现明明有子任务未执行,但 completed 标记位仍然为 true 的情况
|
||||
// 当然可以在新版本迁移文件中全局重置 completed 标记位,但这样影响范围太大感觉不太好
|
||||
// 在后面进行这部分额外判断可以兼容这种情况,在由用户手动触发的 reset_failed 调用中修正 completed 标记位
|
||||
if self.should_run().into_iter().any(|x| x) {
|
||||
changed |= self.get_completed();
|
||||
if !changed && self.get_completed() && self.should_run().into_iter().any(|x| x) {
|
||||
changed = true;
|
||||
self.set_completed(false);
|
||||
}
|
||||
changed
|
||||
@@ -119,8 +136,8 @@ impl<const N: usize> Status<N> {
|
||||
|
||||
/// 根据子任务执行结果更新子任务的状态
|
||||
fn set_result(&mut self, result: &ExecutionStatus, offset: usize) {
|
||||
// 如果任务返回 FixedFailed 状态,那么无论之前的状态如何,都将状态设置为 FixedFailed 的状态
|
||||
if let ExecutionStatus::FixedFailed(status, _) = result {
|
||||
// 如果任务返回 Fixed 状态,那么无论之前的状态如何,都将状态设置为 Fixed 的状态
|
||||
if let ExecutionStatus::Fixed(status) = result {
|
||||
assert!(*status < 0b1000, "status should be less than 0b1000");
|
||||
self.set_status(offset, *status);
|
||||
} else if self.get_status(offset) < STATUS_MAX_RETRY {
|
||||
@@ -133,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);
|
||||
@@ -155,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);
|
||||
@@ -170,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 {
|
||||
@@ -183,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(&[
|
||||
@@ -201,9 +272,9 @@ mod tests {
|
||||
assert_eq!(status.should_run(), [false, false, false]);
|
||||
assert!(status.get_completed());
|
||||
status.update_status(&[
|
||||
ExecutionStatus::FixedFailed(1, anyhow!("")),
|
||||
ExecutionStatus::FixedFailed(4, anyhow!("")),
|
||||
ExecutionStatus::FixedFailed(7, anyhow!("")),
|
||||
ExecutionStatus::Fixed(1),
|
||||
ExecutionStatus::Fixed(4),
|
||||
ExecutionStatus::Fixed(7),
|
||||
]);
|
||||
assert_eq!(status.should_run(), [true, false, false]);
|
||||
assert!(!status.get_completed());
|
||||
@@ -214,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);
|
||||
}
|
||||
}
|
||||
@@ -223,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,
|
||||
@@ -235,12 +306,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_status_reset_failed() {
|
||||
// 重置一个已经失败的任务
|
||||
let mut status = Status::<3>::from([3, 4, 7]);
|
||||
// 重置一个出现部分失败但还有重试次数的任务,将所有的失败状态重置为 0
|
||||
let mut status = Status::<3, video::Column>::from([3, 4, 7]);
|
||||
assert!(!status.get_completed());
|
||||
assert!(status.reset_failed());
|
||||
assert!(!status.get_completed());
|
||||
assert_eq!(<[u32; 3]>::from(status), [3, 0, 7]);
|
||||
assert_eq!(<[u32; 3]>::from(status), [0, 0, 7]);
|
||||
// 没有内容需要重置,但 completed 标记位是错误的(模拟新增一个子任务状态的情况)
|
||||
// 此时 reset_failed 不会修正 completed 标记位,而 force_reset_failed 会
|
||||
status.set_completed(true);
|
||||
@@ -250,22 +321,28 @@ 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, video::Column>::from([4, 4, 4]);
|
||||
assert!(status.get_completed());
|
||||
assert!(status.reset_failed());
|
||||
assert!(!status.get_completed());
|
||||
assert_eq!(<[u32; 3]>::from(status), [0, 0, 0]);
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::MutexGuard;
|
||||
|
||||
use crate::config::VersionedConfig;
|
||||
|
||||
pub static TASK_STATUS_NOTIFIER: LazyLock<TaskStatusNotifier> = LazyLock::new(TaskStatusNotifier::new);
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct TaskStatus {
|
||||
is_running: bool,
|
||||
last_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
last_finish: Option<chrono::DateTime<chrono::Local>>,
|
||||
next_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
pub struct TaskStatusNotifier {
|
||||
mutex: tokio::sync::Mutex<()>,
|
||||
tx: tokio::sync::watch::Sender<Arc<TaskStatus>>,
|
||||
rx: tokio::sync::watch::Receiver<Arc<TaskStatus>>,
|
||||
}
|
||||
|
||||
impl TaskStatusNotifier {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = tokio::sync::watch::channel(Arc::new(TaskStatus::default()));
|
||||
Self {
|
||||
mutex: tokio::sync::Mutex::const_new(()),
|
||||
tx,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_running(&'_ self) -> MutexGuard<'_, ()> {
|
||||
let lock = self.mutex.lock().await;
|
||||
let _ = self.tx.send(Arc::new(TaskStatus {
|
||||
is_running: true,
|
||||
last_run: Some(chrono::Local::now()),
|
||||
last_finish: None,
|
||||
next_run: None,
|
||||
}));
|
||||
lock
|
||||
}
|
||||
|
||||
pub fn finish_running(&self, _lock: MutexGuard<()>) {
|
||||
let last_status = self.tx.borrow();
|
||||
let last_run = last_status.last_run;
|
||||
drop(last_status);
|
||||
let config = VersionedConfig::get().load();
|
||||
let now = chrono::Local::now();
|
||||
|
||||
let _ = self.tx.send(Arc::new(TaskStatus {
|
||||
is_running: false,
|
||||
last_run,
|
||||
last_finish: Some(now),
|
||||
next_run: now.checked_add_signed(chrono::Duration::seconds(config.interval as i64)),
|
||||
}));
|
||||
}
|
||||
|
||||
/// 精确探测任务执行状态,保证如果读取到“未运行”,那么在锁释放之前任务不会被执行
|
||||
pub fn detect_running(&self) -> Option<MutexGuard<'_, ()>> {
|
||||
self.mutex.try_lock().ok()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> tokio::sync::watch::Receiver<Arc<TaskStatus>> {
|
||||
self.rx.clone()
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,16 @@ use tokio::sync::Semaphore;
|
||||
|
||||
use crate::adapter::{VideoSource, VideoSourceEnum};
|
||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Video, VideoInfo};
|
||||
use crate::config::{ARGS, PathSafeTemplate, TEMPLATE, VersionedConfig};
|
||||
use crate::config::{ARGS, Config, PathSafeTemplate};
|
||||
use crate::downloader::Downloader;
|
||||
use crate::error::{DownloadAbortError, ExecutionStatus, ProcessPageError};
|
||||
use crate::error::ExecutionStatus;
|
||||
use crate::utils::download_context::DownloadContext;
|
||||
use crate::utils::format_arg::{page_format_args, video_format_args};
|
||||
use crate::utils::model::{
|
||||
create_pages, create_videos, filter_unfilled_videos, filter_unhandled_video_pages, update_pages_model,
|
||||
update_videos_model,
|
||||
};
|
||||
use crate::utils::nfo::NFO;
|
||||
use crate::utils::nfo::{NFO, ToNFO};
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
|
||||
|
||||
@@ -31,18 +32,24 @@ pub async fn process_video_source(
|
||||
video_source: VideoSourceEnum,
|
||||
bili_client: &BiliClient,
|
||||
connection: &DatabaseConnection,
|
||||
template: &handlebars::Handlebars<'_>,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
// 预创建视频源目录,提前检测目录是否可写
|
||||
video_source.create_dir_all().await?;
|
||||
// 从参数中获取视频列表的 Model 与视频流
|
||||
let (video_source, video_streams) = video_source.refresh(bili_client, connection).await?;
|
||||
let (video_source, video_streams) = video_source
|
||||
.refresh(bili_client, &config.credential, connection)
|
||||
.await?;
|
||||
// 从视频流中获取新视频的简要信息,写入数据库
|
||||
refresh_video_source(&video_source, video_streams, connection).await?;
|
||||
// 单独请求视频详情接口,获取视频的详情信息与所有的分页,写入数据库
|
||||
fetch_video_details(bili_client, &video_source, connection).await?;
|
||||
fetch_video_details(bili_client, &video_source, connection, config).await?;
|
||||
if ARGS.scan_only {
|
||||
warn!("已开启仅扫描模式,跳过视频下载..");
|
||||
} else {
|
||||
// 从数据库中查找所有未下载的视频与分页,下载并处理
|
||||
download_unprocessed_videos(bili_client, &video_source, connection).await?;
|
||||
download_unprocessed_videos(bili_client, &video_source, connection, template, config).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -58,10 +65,18 @@ pub async fn refresh_video_source<'a>(
|
||||
let mut max_datetime = latest_row_at;
|
||||
let mut error = Ok(());
|
||||
let mut video_streams = video_streams
|
||||
.take_while(|res| {
|
||||
.enumerate()
|
||||
.take_while(|(idx, res)| {
|
||||
match res {
|
||||
Err(e) => {
|
||||
error = Err(anyhow!(e.to_string()));
|
||||
// 这里拿到的 e 是引用,无法直接传递所有权
|
||||
// 对于 BiliError,我们需要克隆内部的错误并附带原来的上下文,方便外部检查错误类型
|
||||
// 对于其他错误只保留字符串信息用作提示
|
||||
if let Some(inner) = e.downcast_ref::<BiliError>() {
|
||||
error = Err(inner.clone()).context(e.to_string());
|
||||
} else {
|
||||
error = Err(anyhow!("{:#}", e));
|
||||
}
|
||||
futures::future::ready(false)
|
||||
}
|
||||
Ok(v) => {
|
||||
@@ -72,11 +87,11 @@ pub async fn refresh_video_source<'a>(
|
||||
if release_datetime > &max_datetime {
|
||||
max_datetime = *release_datetime;
|
||||
}
|
||||
futures::future::ready(video_source.should_take(release_datetime, &latest_row_at))
|
||||
futures::future::ready(video_source.should_take(*idx, release_datetime, &latest_row_at))
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter_map(|res| futures::future::ready(video_source.should_filter(res, &latest_row_at)))
|
||||
.filter_map(|(idx, res)| futures::future::ready(video_source.should_filter(idx, res, &latest_row_at)))
|
||||
.chunks(10);
|
||||
let mut count = 0;
|
||||
while let Some(videos_info) = video_streams.next().await {
|
||||
@@ -100,16 +115,17 @@ pub async fn fetch_video_details(
|
||||
bili_client: &BiliClient,
|
||||
video_source: &VideoSourceEnum,
|
||||
connection: &DatabaseConnection,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
video_source.log_fetch_video_start();
|
||||
let videos_model = filter_unfilled_videos(video_source.filter_expr(), connection).await?;
|
||||
let semaphore = Semaphore::new(VersionedConfig::get().load().concurrent_limit.video);
|
||||
let semaphore = Semaphore::new(config.concurrent_limit.video);
|
||||
let semaphore_ref = &semaphore;
|
||||
let tasks = videos_model
|
||||
.into_iter()
|
||||
.map(|video_model| async move {
|
||||
let _permit = semaphore_ref.acquire().await.context("acquire semaphore failed")?;
|
||||
let video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let video = Video::new(bili_client, video_model.bvid.clone(), &config.credential);
|
||||
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||
match info {
|
||||
Err(e) => {
|
||||
@@ -117,7 +133,7 @@ pub async fn fetch_video_details(
|
||||
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
|
||||
&video_model.bvid, &video_model.name, e
|
||||
);
|
||||
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
if let Some(BiliError::ErrorResponse(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
|
||||
video_active_model.valid = Set(false);
|
||||
video_active_model.save(connection).await?;
|
||||
@@ -158,10 +174,13 @@ pub async fn download_unprocessed_videos(
|
||||
bili_client: &BiliClient,
|
||||
video_source: &VideoSourceEnum,
|
||||
connection: &DatabaseConnection,
|
||||
template: &handlebars::Handlebars<'_>,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
video_source.log_download_video_start();
|
||||
let semaphore = Semaphore::new(VersionedConfig::get().load().concurrent_limit.video);
|
||||
let semaphore = Semaphore::new(config.concurrent_limit.video);
|
||||
let downloader = Downloader::new(bili_client.client.clone());
|
||||
let cx = DownloadContext::new(bili_client, video_source, template, connection, &downloader, config);
|
||||
let unhandled_videos_pages = filter_unhandled_video_pages(video_source.filter_expr(), connection).await?;
|
||||
let mut assigned_upper = HashSet::new();
|
||||
let tasks = unhandled_videos_pages
|
||||
@@ -169,29 +188,20 @@ pub async fn download_unprocessed_videos(
|
||||
.map(|(video_model, pages_model)| {
|
||||
let should_download_upper = !assigned_upper.contains(&video_model.upper_id);
|
||||
assigned_upper.insert(video_model.upper_id);
|
||||
download_video_pages(
|
||||
bili_client,
|
||||
video_source,
|
||||
video_model,
|
||||
pages_model,
|
||||
connection,
|
||||
&semaphore,
|
||||
&downloader,
|
||||
should_download_upper,
|
||||
)
|
||||
download_video_pages(video_model, pages_model, &semaphore, should_download_upper, cx)
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let mut download_aborted = false;
|
||||
let mut risk_control_related_error = None;
|
||||
let mut stream = tasks
|
||||
// 触发风控时设置 download_aborted 标记并终止流
|
||||
.take_while(|res| {
|
||||
if res
|
||||
.as_ref()
|
||||
.is_err_and(|e| e.downcast_ref::<DownloadAbortError>().is_some())
|
||||
if let Err(e) = res
|
||||
&& let Some(e) = e.downcast_ref::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
download_aborted = true;
|
||||
risk_control_related_error = Some(e.clone());
|
||||
}
|
||||
futures::future::ready(!download_aborted)
|
||||
futures::future::ready(risk_control_related_error.is_none())
|
||||
})
|
||||
// 过滤掉没有触发风控的普通 Err,只保留正确返回的 Model
|
||||
.filter_map(|res| futures::future::ready(res.ok()))
|
||||
@@ -200,35 +210,37 @@ pub async fn download_unprocessed_videos(
|
||||
while let Some(models) = stream.next().await {
|
||||
update_videos_model(models, connection).await?;
|
||||
}
|
||||
if download_aborted {
|
||||
error!("下载触发风控,已终止所有任务,等待下一轮执行");
|
||||
if let Some(e) = risk_control_related_error {
|
||||
bail!(e);
|
||||
}
|
||||
video_source.log_download_video_end();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_video_pages(
|
||||
bili_client: &BiliClient,
|
||||
video_source: &VideoSourceEnum,
|
||||
video_model: video::Model,
|
||||
pages: Vec<page::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
page_models: Vec<page::Model>,
|
||||
semaphore: &Semaphore,
|
||||
downloader: &Downloader,
|
||||
should_download_upper: bool,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<video::ActiveModel> {
|
||||
let _permit = semaphore.acquire().await.context("acquire semaphore failed")?;
|
||||
let mut status = VideoStatus::from(video_model.download_status);
|
||||
let separate_status = status.should_run();
|
||||
let base_path = video_source.path().join(
|
||||
TEMPLATE
|
||||
.load()
|
||||
.path_safe_render("video", &video_format_args(&video_model))?,
|
||||
);
|
||||
// 未记录路径时填充,已经填充过路径时使用现有的
|
||||
let base_path = if !video_model.path.is_empty() {
|
||||
PathBuf::from(&video_model.path)
|
||||
} else {
|
||||
cx.video_source.path().join(
|
||||
cx.template
|
||||
.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 = VersionedConfig::get()
|
||||
.load()
|
||||
let base_upper_path = cx
|
||||
.config
|
||||
.upper_path
|
||||
.join(upper_id.chars().next().context("upper_id is empty")?.to_string())
|
||||
.join(upper_id);
|
||||
@@ -238,46 +250,37 @@ pub async fn download_video_pages(
|
||||
let (res_1, res_2, res_3, res_4, res_5) = tokio::join!(
|
||||
// 下载视频封面
|
||||
fetch_video_poster(
|
||||
separate_status[0] && !is_single_page,
|
||||
separate_status[0] && !is_single_page && !cx.config.skip_option.no_poster,
|
||||
&video_model,
|
||||
downloader,
|
||||
base_path.join("poster.jpg"),
|
||||
base_path.join("fanart.jpg"),
|
||||
cx
|
||||
),
|
||||
// 生成视频信息的 nfo
|
||||
generate_video_nfo(
|
||||
separate_status[1] && !is_single_page,
|
||||
separate_status[1] && !is_single_page && !cx.config.skip_option.no_video_nfo,
|
||||
&video_model,
|
||||
base_path.join("tvshow.nfo"),
|
||||
cx
|
||||
),
|
||||
// 下载 Up 主头像
|
||||
fetch_upper_face(
|
||||
separate_status[2] && should_download_upper,
|
||||
separate_status[2] && should_download_upper && !cx.config.skip_option.no_upper,
|
||||
&video_model,
|
||||
downloader,
|
||||
base_upper_path.join("folder.jpg"),
|
||||
cx
|
||||
),
|
||||
// 生成 Up 主信息的 nfo
|
||||
generate_upper_nfo(
|
||||
separate_status[3] && should_download_upper,
|
||||
separate_status[3] && should_download_upper && !cx.config.skip_option.no_upper,
|
||||
&video_model,
|
||||
base_upper_path.join("person.nfo"),
|
||||
cx,
|
||||
),
|
||||
// 分发并执行分页下载的任务
|
||||
dispatch_download_page(
|
||||
separate_status[4],
|
||||
bili_client,
|
||||
&video_model,
|
||||
pages,
|
||||
connection,
|
||||
downloader,
|
||||
&base_path
|
||||
)
|
||||
dispatch_download_page(separate_status[4], &video_model, page_models, &base_path, cx)
|
||||
);
|
||||
let results = [res_1, res_2, res_3, res_4, res_5]
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
let results = [res_1.into(), res_2.into(), res_3.into(), res_4.into(), res_5.into()];
|
||||
status.update_status(&results);
|
||||
results
|
||||
.iter()
|
||||
@@ -292,14 +295,18 @@ pub async fn download_video_pages(
|
||||
&video_model.name, task_name, e
|
||||
)
|
||||
}
|
||||
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => {
|
||||
ExecutionStatus::Failed(e) => {
|
||||
error!("处理视频「{}」{}失败:{:#}", &video_model.name, task_name, e)
|
||||
}
|
||||
ExecutionStatus::Fixed(_) => unreachable!(),
|
||||
});
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(4).context("page download result not found")?
|
||||
&& e.downcast_ref::<DownloadAbortError>().is_some()
|
||||
{
|
||||
return Err(e);
|
||||
for result in results {
|
||||
if let ExecutionStatus::Failed(e) = result
|
||||
&& let Ok(e) = e.downcast::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
bail!(e);
|
||||
}
|
||||
}
|
||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||
video_active_model.download_status = Set(status.into());
|
||||
@@ -310,31 +317,20 @@ pub async fn download_video_pages(
|
||||
/// 分发并执行分页下载任务,当且仅当所有分页成功下载或达到最大重试次数时返回 Ok,否则根据失败原因返回对应的错误
|
||||
pub async fn dispatch_download_page(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
pages: Vec<page::Model>,
|
||||
connection: &DatabaseConnection,
|
||||
downloader: &Downloader,
|
||||
page_models: Vec<page::Model>,
|
||||
base_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let child_semaphore = Semaphore::new(VersionedConfig::get().load().concurrent_limit.page);
|
||||
let tasks = pages
|
||||
let child_semaphore = Semaphore::new(cx.config.concurrent_limit.page);
|
||||
let tasks = page_models
|
||||
.into_iter()
|
||||
.map(|page_model| {
|
||||
download_page(
|
||||
bili_client,
|
||||
video_model,
|
||||
page_model,
|
||||
&child_semaphore,
|
||||
downloader,
|
||||
base_path,
|
||||
)
|
||||
})
|
||||
.map(|page_model| download_page(video_model, page_model, &child_semaphore, base_path, cx))
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let (mut download_aborted, mut target_status) = (false, STATUS_OK);
|
||||
let (mut risk_control_related_error, mut target_status) = (None, STATUS_OK);
|
||||
let mut stream = tasks
|
||||
.take_while(|res| {
|
||||
match res {
|
||||
@@ -350,45 +346,79 @@ pub async fn dispatch_download_page(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.downcast_ref::<DownloadAbortError>().is_some() {
|
||||
download_aborted = true;
|
||||
if let Some(e) = e.downcast_ref::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
risk_control_related_error = Some(e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅在发生风控时终止流,其它情况继续执行
|
||||
futures::future::ready(!download_aborted)
|
||||
futures::future::ready(risk_control_related_error.is_none())
|
||||
})
|
||||
.filter_map(|res| futures::future::ready(res.ok()))
|
||||
.chunks(10);
|
||||
while let Some(models) = stream.next().await {
|
||||
update_pages_model(models, connection).await?;
|
||||
update_pages_model(models, cx.connection).await?;
|
||||
}
|
||||
if download_aborted {
|
||||
error!("下载视频「{}」的分页时触发风控,将异常向上传递..", &video_model.name);
|
||||
bail!(DownloadAbortError());
|
||||
if let Some(e) = risk_control_related_error {
|
||||
bail!(e);
|
||||
}
|
||||
if target_status != STATUS_OK {
|
||||
return Ok(ExecutionStatus::FixedFailed(target_status, ProcessPageError().into()));
|
||||
}
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
// 视频中“分页下载”任务的状态始终与所有分页的最小状态一致
|
||||
Ok(ExecutionStatus::Fixed(target_status))
|
||||
}
|
||||
|
||||
/// 下载某个分页,未发生风控且正常运行时返回 Ok(Page::ActiveModel),其中 status 字段存储了新的下载状态,发生风控时返回 DownloadAbortError
|
||||
pub async fn download_page(
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
page_model: page::Model,
|
||||
semaphore: &Semaphore,
|
||||
downloader: &Downloader,
|
||||
base_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<page::ActiveModel> {
|
||||
let _permit = semaphore.acquire().await.context("acquire semaphore failed")?;
|
||||
let mut status = PageStatus::from(page_model.download_status);
|
||||
let separate_status = status.should_run();
|
||||
let is_single_page = video_model.single_page.context("single_page is null")?;
|
||||
let base_name = TEMPLATE
|
||||
.load()
|
||||
.path_safe_render("page", &page_format_args(video_model, &page_model))?;
|
||||
// 未记录路径时填充,已经填充过路径时使用现有的
|
||||
let (base_path, base_name) = if let Some(old_video_path) = &page_model.path
|
||||
&& !old_video_path.is_empty()
|
||||
{
|
||||
let old_video_path = Path::new(old_video_path);
|
||||
let old_video_filename = old_video_path
|
||||
.file_name()
|
||||
.context("invalid page path format")?
|
||||
.to_string_lossy();
|
||||
if is_single_page {
|
||||
// 单页下的路径是 {base_path}/{base_name}.mp4
|
||||
(
|
||||
old_video_path.parent().context("invalid page path format")?,
|
||||
old_video_filename.trim_end_matches(".mp4").to_string(),
|
||||
)
|
||||
} else {
|
||||
// 多页下的路径是 {base_path}/Season 1/{base_name} - S01Exx.mp4
|
||||
(
|
||||
old_video_path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.context("invalid page path format")?,
|
||||
old_video_filename
|
||||
.rsplit_once(" - ")
|
||||
.context("invalid page path format")?
|
||||
.0
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(
|
||||
base_path,
|
||||
cx.template.path_safe_render(
|
||||
"page",
|
||||
&page_format_args(video_model, &page_model, &cx.config.time_format),
|
||||
)?,
|
||||
)
|
||||
};
|
||||
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)),
|
||||
@@ -436,33 +466,41 @@ pub async fn download_page(
|
||||
let (res_1, res_2, res_3, res_4, res_5) = tokio::join!(
|
||||
// 下载分页封面
|
||||
fetch_page_poster(
|
||||
separate_status[0],
|
||||
separate_status[0] && !cx.config.skip_option.no_poster,
|
||||
video_model,
|
||||
&page_model,
|
||||
downloader,
|
||||
poster_path,
|
||||
fanart_path
|
||||
fanart_path,
|
||||
cx
|
||||
),
|
||||
// 下载分页视频
|
||||
fetch_page_video(
|
||||
separate_status[1],
|
||||
bili_client,
|
||||
video_model,
|
||||
downloader,
|
||||
&page_info,
|
||||
&video_path
|
||||
),
|
||||
fetch_page_video(separate_status[1], video_model, &page_info, &video_path, cx),
|
||||
// 生成分页视频信息的 nfo
|
||||
generate_page_nfo(separate_status[2], video_model, &page_model, nfo_path),
|
||||
generate_page_nfo(
|
||||
separate_status[2] && !cx.config.skip_option.no_video_nfo,
|
||||
video_model,
|
||||
&page_model,
|
||||
nfo_path,
|
||||
cx,
|
||||
),
|
||||
// 下载分页弹幕
|
||||
fetch_page_danmaku(separate_status[3], bili_client, video_model, &page_info, danmaku_path),
|
||||
fetch_page_danmaku(
|
||||
separate_status[3] && !cx.config.skip_option.no_danmaku,
|
||||
video_model,
|
||||
&page_info,
|
||||
danmaku_path,
|
||||
cx,
|
||||
),
|
||||
// 下载分页字幕
|
||||
fetch_page_subtitle(separate_status[4], bili_client, video_model, &page_info, &subtitle_path)
|
||||
fetch_page_subtitle(
|
||||
separate_status[4] && !cx.config.skip_option.no_subtitle,
|
||||
video_model,
|
||||
&page_info,
|
||||
&subtitle_path,
|
||||
cx
|
||||
)
|
||||
);
|
||||
let results = [res_1, res_2, res_3, res_4, res_5]
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
let results = [res_1.into(), res_2.into(), res_3.into(), res_4.into(), res_5.into()];
|
||||
status.update_status(&results);
|
||||
results
|
||||
.iter()
|
||||
@@ -482,16 +520,19 @@ pub async fn download_page(
|
||||
&video_model.name, page_model.pid, task_name, e
|
||||
)
|
||||
}
|
||||
ExecutionStatus::Failed(e) | ExecutionStatus::FixedFailed(_, e) => error!(
|
||||
ExecutionStatus::Failed(e) => error!(
|
||||
"处理视频「{}」第 {} 页{}失败:{:#}",
|
||||
&video_model.name, page_model.pid, task_name, e
|
||||
),
|
||||
ExecutionStatus::Fixed(_) => unreachable!(),
|
||||
});
|
||||
// 如果下载视频时触发风控,直接返回 DownloadAbortError
|
||||
if let ExecutionStatus::Failed(e) = results.into_iter().nth(1).context("video download result not found")?
|
||||
&& let Ok(BiliError::RiskControlOccurred) = e.downcast::<BiliError>()
|
||||
{
|
||||
bail!(DownloadAbortError());
|
||||
for result in results {
|
||||
if let ExecutionStatus::Failed(e) = result
|
||||
&& let Ok(e) = e.downcast::<BiliError>()
|
||||
&& e.is_risk_control_related()
|
||||
{
|
||||
bail!(e);
|
||||
}
|
||||
}
|
||||
let mut page_active_model: page::ActiveModel = page_model.into();
|
||||
page_active_model.download_status = Set(status.into());
|
||||
@@ -503,9 +544,9 @@ pub async fn fetch_page_poster(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
page_model: &page::Model,
|
||||
downloader: &Downloader,
|
||||
poster_path: PathBuf,
|
||||
fanart_path: Option<PathBuf>,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
@@ -521,7 +562,9 @@ pub async fn fetch_page_poster(
|
||||
None => video_model.cover.as_str(),
|
||||
}
|
||||
};
|
||||
downloader.fetch(url, &poster_path).await?;
|
||||
cx.downloader
|
||||
.fetch(url, &poster_path, &cx.config.concurrent_limit.download)
|
||||
.await?;
|
||||
if let Some(fanart_path) = fanart_path {
|
||||
fs::copy(&poster_path, &fanart_path).await?;
|
||||
}
|
||||
@@ -530,47 +573,53 @@ pub async fn fetch_page_poster(
|
||||
|
||||
pub async fn fetch_page_video(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
page_info: &PageInfo,
|
||||
page_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let streams = bili_video
|
||||
.get_page_analyzer(page_info)
|
||||
.await?
|
||||
.best_stream(&VersionedConfig::get().load().filter_option)?;
|
||||
.best_stream(&cx.config.filter_option)?;
|
||||
match streams {
|
||||
BestStream::Mixed(mix_stream) => downloader.fetch_with_fallback(&mix_stream.urls(), page_path).await?,
|
||||
BestStream::Mixed(mix_stream) => {
|
||||
cx.downloader
|
||||
.multi_fetch(
|
||||
&mix_stream.urls(cx.config.cdn_sorting),
|
||||
page_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
BestStream::VideoAudio {
|
||||
video: video_stream,
|
||||
audio: None,
|
||||
} => downloader.fetch_with_fallback(&video_stream.urls(), page_path).await?,
|
||||
} => {
|
||||
cx.downloader
|
||||
.multi_fetch(
|
||||
&video_stream.urls(cx.config.cdn_sorting),
|
||||
page_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
BestStream::VideoAudio {
|
||||
video: video_stream,
|
||||
audio: Some(audio_stream),
|
||||
} => {
|
||||
let (tmp_video_path, tmp_audio_path) = (
|
||||
page_path.with_extension("tmp_video"),
|
||||
page_path.with_extension("tmp_audio"),
|
||||
);
|
||||
let res = async {
|
||||
downloader
|
||||
.fetch_with_fallback(&video_stream.urls(), &tmp_video_path)
|
||||
.await?;
|
||||
downloader
|
||||
.fetch_with_fallback(&audio_stream.urls(), &tmp_audio_path)
|
||||
.await?;
|
||||
downloader.merge(&tmp_video_path, &tmp_audio_path, page_path).await
|
||||
}
|
||||
.await;
|
||||
let _ = fs::remove_file(tmp_video_path).await;
|
||||
let _ = fs::remove_file(tmp_audio_path).await;
|
||||
res?
|
||||
cx.downloader
|
||||
.multi_fetch_and_merge(
|
||||
&video_stream.urls(cx.config.cdn_sorting),
|
||||
&audio_stream.urls(cx.config.cdn_sorting),
|
||||
page_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
@@ -578,34 +627,34 @@ pub async fn fetch_page_video(
|
||||
|
||||
pub async fn fetch_page_danmaku(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
page_info: &PageInfo,
|
||||
danmaku_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
bili_video
|
||||
.get_danmaku_writer(page_info)
|
||||
.await?
|
||||
.write(danmaku_path)
|
||||
.write(danmaku_path, &cx.config.danmaku_option)
|
||||
.await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
pub async fn fetch_page_subtitle(
|
||||
should_run: bool,
|
||||
bili_client: &BiliClient,
|
||||
video_model: &video::Model,
|
||||
page_info: &PageInfo,
|
||||
subtitle_path: &Path,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
|
||||
let subtitles = bili_video.get_subtitles(page_info).await?;
|
||||
let tasks = subtitles
|
||||
.into_iter()
|
||||
@@ -623,15 +672,16 @@ pub async fn generate_page_nfo(
|
||||
video_model: &video::Model,
|
||||
page_model: &page::Model,
|
||||
nfo_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
let single_page = video_model.single_page.context("single_page is null")?;
|
||||
let nfo = if single_page {
|
||||
NFO::Movie(video_model.into())
|
||||
NFO::Movie(video_model.to_nfo(cx.config.nfo_time_type))
|
||||
} else {
|
||||
NFO::Episode(page_model.into())
|
||||
NFO::Episode(page_model.to_nfo(cx.config.nfo_time_type))
|
||||
};
|
||||
generate_nfo(nfo, nfo_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
@@ -640,14 +690,16 @@ pub async fn generate_page_nfo(
|
||||
pub async fn fetch_video_poster(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
poster_path: PathBuf,
|
||||
fanart_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
downloader.fetch(&video_model.cover, &poster_path).await?;
|
||||
cx.downloader
|
||||
.fetch(&video_model.cover, &poster_path, &cx.config.concurrent_limit.download)
|
||||
.await?;
|
||||
fs::copy(&poster_path, &fanart_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
@@ -655,13 +707,19 @@ pub async fn fetch_video_poster(
|
||||
pub async fn fetch_upper_face(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
downloader: &Downloader,
|
||||
upper_face_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
downloader.fetch(&video_model.upper_face, &upper_face_path).await?;
|
||||
cx.downloader
|
||||
.fetch(
|
||||
&video_model.upper_face,
|
||||
&upper_face_path,
|
||||
&cx.config.concurrent_limit.download,
|
||||
)
|
||||
.await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
@@ -669,11 +727,12 @@ pub async fn generate_upper_nfo(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
nfo_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
generate_nfo(NFO::Upper(video_model.into()), nfo_path).await?;
|
||||
generate_nfo(NFO::Upper(video_model.to_nfo(cx.config.nfo_time_type)), nfo_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
@@ -681,11 +740,12 @@ pub async fn generate_video_nfo(
|
||||
should_run: bool,
|
||||
video_model: &video::Model,
|
||||
nfo_path: PathBuf,
|
||||
cx: DownloadContext<'_>,
|
||||
) -> Result<ExecutionStatus> {
|
||||
if !should_run {
|
||||
return Ok(ExecutionStatus::Skipped);
|
||||
}
|
||||
generate_nfo(NFO::TVShow(video_model.into()), nfo_path).await?;
|
||||
generate_nfo(NFO::TVShow(video_model.to_nfo(cx.config.nfo_time_type)), nfo_path).await?;
|
||||
Ok(ExecutionStatus::Succeeded)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -11,6 +11,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
pub enum Condition<T: Serialize + Display> {
|
||||
Equals(T),
|
||||
Contains(T),
|
||||
#[serde(rename = "icontains")]
|
||||
IContains(T),
|
||||
#[serde(deserialize_with = "deserialize_regex", serialize_with = "serialize_regex")]
|
||||
MatchesRegex(String, #[derivative(PartialEq = "ignore")] regex::Regex),
|
||||
Prefix(T),
|
||||
@@ -41,6 +43,7 @@ impl<T: Serialize + Display> Display for Condition<T> {
|
||||
match self {
|
||||
Condition::Equals(v) => write!(f, "等于“{}”", v),
|
||||
Condition::Contains(v) => write!(f, "包含“{}”", v),
|
||||
Condition::IContains(v) => write!(f, "包含(不区分大小写)“{}”", v),
|
||||
Condition::MatchesRegex(pat, _) => write!(f, "匹配“{}”", pat),
|
||||
Condition::Prefix(v) => write!(f, "以“{}”开头", v),
|
||||
Condition::Suffix(v) => write!(f, "以“{}”结尾", v),
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct Model {
|
||||
pub upper_name: String,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub use_dynamic_api: bool,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
|
||||
@@ -5,5 +5,4 @@ edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
async-std = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
|
||||
@@ -9,6 +9,7 @@ mod m20250612_090826_add_enabled;
|
||||
mod m20250613_043257_add_config;
|
||||
mod m20250712_080013_add_video_created_at_index;
|
||||
mod m20250903_094454_add_rule_and_should_download;
|
||||
mod m20251009_123713_add_use_dynamic_api;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -25,6 +26,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250613_043257_add_config::Migration),
|
||||
Box::new(m20250712_080013_add_video_created_at_index::Migration),
|
||||
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
|
||||
Box::new(m20251009_123713_add_use_dynamic_api::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
use sea_orm_migration::schema::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.add_column(boolean(Submission::UseDynamicApi).default(false))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Submission::Table)
|
||||
.drop_column(Submission::UseDynamicApi)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Submission {
|
||||
Table,
|
||||
UseDynamicApi,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(bili_sync_migration::Migrator).await;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.7.0",
|
||||
text: "v2.10.4",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.7.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",
|
||||
|
||||
552
web/bun.lock
552
web/bun.lock
@@ -1,255 +1,257 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "my-app",
|
||||
"name": "bili-sync-web",
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"bits-ui": "^2.11.0",
|
||||
"bits-ui": "^2.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"layerchart": "2.0.0-next.27",
|
||||
"mode-watcher": "^1.0.6",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-sonner": "^1.0.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"globals": "^16.5.0",
|
||||
"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",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.2",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "7.0.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@dagrejs/dagre": ["@dagrejs/dagre@1.1.5", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ=="],
|
||||
"@dagrejs/dagre": ["@dagrejs/dagre@1.1.8", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw=="],
|
||||
|
||||
"@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.6", "", { "os": "aix", "cpu": "ppc64" }, "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.6", "", { "os": "android", "cpu": "arm" }, "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.6", "", { "os": "android", "cpu": "arm64" }, "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.6", "", { "os": "android", "cpu": "x64" }, "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.6", "", { "os": "linux", "cpu": "arm" }, "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.6", "", { "os": "linux", "cpu": "ia32" }, "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.6", "", { "os": "linux", "cpu": "x64" }, "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.6", "", { "os": "none", "cpu": "x64" }, "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.6", "", { "os": "sunos", "cpu": "x64" }, "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q=="],
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ=="],
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA=="],
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/compat": ["@eslint/compat@1.2.9", "", { "peerDependencies": { "eslint": "^9.10.0" }, "optionalPeers": ["eslint"] }, "sha512-gCdSY54n7k+driCadyMNv8JSPzYLeDVM/ikZRtvtROBpRdFSkS8W9A82MqsaY7lZuwL0wiapgD0NT1xT0hyJsA=="],
|
||||
"@eslint/compat": ["@eslint/compat@1.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0" }, "peerDependencies": { "eslint": "^8.40 || 9" }, "optionalPeers": ["eslint"] }, "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.20.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ=="],
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.2.2", "", {}, "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg=="],
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="],
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.27.0", "", {}, "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA=="],
|
||||
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.1", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="],
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="],
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.8.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA=="],
|
||||
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
"@layerstack/svelte-actions": ["@layerstack/svelte-actions@1.0.1-next.14", "", { "dependencies": { "@floating-ui/dom": "^1.7.0", "@layerstack/utils": "2.0.0-next.14", "d3-scale": "^4.0.2" } }, "sha512-MPBmVaB+GfNHvBkg5nJkPG18smoXKvsvJRpsdWnrUBfca+TieZLoaEzNxDH+9LG11dIXP9gghsXt1mUqbbyAsA=="],
|
||||
|
||||
"@layerstack/svelte-actions": ["@layerstack/svelte-actions@1.0.1-next.12", "", { "dependencies": { "@floating-ui/dom": "^1.7.0", "@layerstack/utils": "2.0.0-next.12", "d3-scale": "^4.0.2" } }, "sha512-dndWTlYu8b1u6vw2nrO7NssccoACArGG75WoNlyVC13KuENZlWdKE9Q79/wlnbq00NeQMNKMjJwRMsrKQj2ULA=="],
|
||||
"@layerstack/svelte-state": ["@layerstack/svelte-state@0.1.0-next.19", "", { "dependencies": { "@layerstack/utils": "2.0.0-next.14" } }, "sha512-yCYoQAIbeP8y1xmOB/r0+UundgP4JFnpNURgMki+26TotzoqrZ5oLpHvhPSVm60ks+buR3ebDBTeUFdHzxwzQQ=="],
|
||||
|
||||
"@layerstack/svelte-state": ["@layerstack/svelte-state@0.1.0-next.17", "", { "dependencies": { "@layerstack/utils": "2.0.0-next.12" } }, "sha512-z7e6mPJnypD80LEI/UDuH0bI6s8/nut06MB7rEkRcEfHJekhKSJgFhMnrYzLED7Mc2gTTD0X/wcYlakauWlU8A=="],
|
||||
"@layerstack/tailwind": ["@layerstack/tailwind@2.0.0-next.17", "", { "dependencies": { "@layerstack/utils": "^2.0.0-next.14", "clsx": "^2.1.1", "d3-array": "^3.2.4", "lodash-es": "^4.17.21", "tailwind-merge": "^3.2.0" } }, "sha512-ZSn6ouqpnzB6DKzSKLVwrUBOQsrzpDA/By2/ba9ApxgTGnaD1nyqNwrvmZ+kswdAwB4YnrGEAE4VZkKrB2+DaQ=="],
|
||||
|
||||
"@layerstack/tailwind": ["@layerstack/tailwind@2.0.0-next.15", "", { "dependencies": { "@layerstack/utils": "^2.0.0-next.12", "clsx": "^2.1.1", "d3-array": "^3.2.4", "lodash-es": "^4.17.21", "tailwind-merge": "^3.2.0" } }, "sha512-7tqKE3OV7/ybeDOORX++USYYCBJa7IgTya2czFpzbgXGo7CQDVyuv+0J1DggjRcEqhhXQA4MUhgnhcRaZvHxWg=="],
|
||||
|
||||
"@layerstack/utils": ["@layerstack/utils@2.0.0-next.12", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-fhGZUlSr3N+D44BYm37WKMGSEFyZBW+dwIqtGU8Cl54mR4TLQ/UwyGhdpgIHyH/x/8q1abE0fP0Dn6ZsrDE3BA=="],
|
||||
"@layerstack/utils": ["@layerstack/utils@2.0.0-next.14", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-1I2CS0Cwgs53W35qVg1eBdYhB/CiPvL3s0XE61b8jWkTHxgjBF65yYNgXjW74kv7WI7GsJcWMNBufPd0rnu9kA=="],
|
||||
|
||||
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.44.2", "", { "os": "android", "cpu": "arm" }, "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q=="],
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.44.2", "", { "os": "android", "cpu": "arm64" }, "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA=="],
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.44.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA=="],
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.44.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw=="],
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.44.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg=="],
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.44.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA=="],
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.44.2", "", { "os": "linux", "cpu": "arm" }, "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ=="],
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.44.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA=="],
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.44.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A=="],
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.44.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A=="],
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
|
||||
|
||||
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.44.2", "", { "os": "linux", "cpu": "none" }, "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g=="],
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
|
||||
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.44.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw=="],
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.44.2", "", { "os": "linux", "cpu": "none" }, "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg=="],
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.44.2", "", { "os": "linux", "cpu": "none" }, "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg=="],
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.44.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw=="],
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.44.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ=="],
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.44.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg=="],
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.44.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw=="],
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.44.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q=="],
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.2", "", { "os": "win32", "cpu": "x64" }, "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA=="],
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
|
||||
|
||||
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="],
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.22.2", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0", "vitefu": "^1.0.6" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-2MvEpSYabUrsJAoq5qCOBGAlkICjfjunrnLcx3YAk2XV7TvAIhomlKsAgR4H/4uns5rAfYmj7Wet5KRtc8dPIg=="],
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.0.0", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0-next.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-mma5GJ23pYiWpTNbN//g9XI3Hfob3aAlXPP42qRtvjgTAU6pfJyLyNPTdLjFuj+jfC9JslP4J3AkeiJNhjtLLA=="],
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.0", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ=="],
|
||||
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="],
|
||||
|
||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.8", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.8" } }, "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q=="],
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.8", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.8", "@tailwindcss/oxide-darwin-arm64": "4.1.8", "@tailwindcss/oxide-darwin-x64": "4.1.8", "@tailwindcss/oxide-freebsd-x64": "4.1.8", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.8", "@tailwindcss/oxide-linux-arm64-musl": "4.1.8", "@tailwindcss/oxide-linux-x64-gnu": "4.1.8", "@tailwindcss/oxide-linux-x64-musl": "4.1.8", "@tailwindcss/oxide-wasm32-wasi": "4.1.8", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.8", "@tailwindcss/oxide-win32-x64-msvc": "4.1.8" } }, "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A=="],
|
||||
"@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.8", "", { "os": "android", "cpu": "arm64" }, "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg=="],
|
||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8", "", { "os": "linux", "cpu": "arm" }, "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.8", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.8", "", { "os": "win32", "cpu": "x64" }, "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.8", "", { "dependencies": { "@tailwindcss/node": "4.1.8", "@tailwindcss/oxide": "4.1.8", "tailwindcss": "4.1.8" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
@@ -261,36 +263,42 @@
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.33.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="],
|
||||
"@types/node": ["@types/node@25.0.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.33.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="],
|
||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.33.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.33.0", "@typescript-eslint/types": "^8.33.0", "debug": "^4.3.4" } }, "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.33.0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0" } }, "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.33.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.51.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.51.0", "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.33.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/utils": "8.33.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.33.0", "", {}, "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.51.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.33.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.33.0", "@typescript-eslint/tsconfig-utils": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.33.0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.51.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.51.0", "@typescript-eslint/tsconfig-utils": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng=="],
|
||||
|
||||
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -301,19 +309,19 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bits-ui": ["bits-ui@2.11.0", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.31.1", "svelte-toolbelt": "^0.10.4", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-j/lOFHz6ZDWwj9sOUb6zYSJQdvPc7kr1IRyAdPjln4wOw9UVvKCosbRFEyP4JEzvNFX7HksMG4naDrDHta5bSA=="],
|
||||
"bits-ui": ["bits-ui@2.15.2", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-S8eDbFkZCN17kZ7J9fD3MRXziV9ozjdFt2D3vTr2bvXCl7BtrIqguYt2U/zrFgLdR2erwybvCKv0JXYn8uKLDQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
@@ -377,9 +385,11 @@
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"d3-tricontour": ["d3-tricontour@1.0.2", "", { "dependencies": { "d3-delaunay": "6", "d3-scale": "4" } }, "sha512-HIRxHzHagPtUPNabjOlfcyismJYIsc+Xlq4mlsts4e8eAcwyq9Tgk/sYdyhlBpQ0MHwVquc/8j+e29YjXnmxeA=="],
|
||||
"d3-tricontour": ["d3-tricontour@1.1.0", "", { "dependencies": { "d3-delaunay": "6", "d3-scale": "4" } }, "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
@@ -387,33 +397,39 @@
|
||||
|
||||
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devalue": ["devalue@5.1.1", "", {}, "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="],
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
|
||||
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.6", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.6", "@esbuild/android-arm": "0.25.6", "@esbuild/android-arm64": "0.25.6", "@esbuild/android-x64": "0.25.6", "@esbuild/darwin-arm64": "0.25.6", "@esbuild/darwin-x64": "0.25.6", "@esbuild/freebsd-arm64": "0.25.6", "@esbuild/freebsd-x64": "0.25.6", "@esbuild/linux-arm": "0.25.6", "@esbuild/linux-arm64": "0.25.6", "@esbuild/linux-ia32": "0.25.6", "@esbuild/linux-loong64": "0.25.6", "@esbuild/linux-mips64el": "0.25.6", "@esbuild/linux-ppc64": "0.25.6", "@esbuild/linux-riscv64": "0.25.6", "@esbuild/linux-s390x": "0.25.6", "@esbuild/linux-x64": "0.25.6", "@esbuild/netbsd-arm64": "0.25.6", "@esbuild/netbsd-x64": "0.25.6", "@esbuild/openbsd-arm64": "0.25.6", "@esbuild/openbsd-x64": "0.25.6", "@esbuild/openharmony-arm64": "0.25.6", "@esbuild/sunos-x64": "0.25.6", "@esbuild/win32-arm64": "0.25.6", "@esbuild/win32-ia32": "0.25.6", "@esbuild/win32-x64": "0.25.6" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg=="],
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.27.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.27.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q=="],
|
||||
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
|
||||
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.5", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw=="],
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.36.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.2.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-nvIUNyyPGbr5922Kd1p/jXe+FfNdVPXsxLyrrXpwfSbZZEFdAYva9O/gm2lObC/wXkQo/AUmQkAihfmNJYeCjA=="],
|
||||
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.13.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-Ng+kV/qGS8P/isbNYVE3sJORtubB+yLEcYICMkUWNaDTb0SwZni/JhAYXh/Dz/q2eThUwWY0VMPZ//KYD1n3eQ=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.3.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrap": ["esrap@1.4.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw=="],
|
||||
"esrap": ["esrap@2.2.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
@@ -423,20 +439,14 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
@@ -445,14 +455,14 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@16.2.0", "", {}, "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg=="],
|
||||
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
@@ -463,23 +473,23 @@
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
@@ -491,33 +501,35 @@
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"known-css-properties": ["known-css-properties@0.36.0", "", {}, "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA=="],
|
||||
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
|
||||
|
||||
"layerchart": ["layerchart@2.0.0-next.27", "", { "dependencies": { "@dagrejs/dagre": "^1.1.4", "@layerstack/svelte-actions": "1.0.1-next.12", "@layerstack/svelte-state": "0.1.0-next.17", "@layerstack/tailwind": "2.0.0-next.15", "@layerstack/utils": "2.0.0-next.12", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-delaunay": "^6.0.4", "d3-dsv": "^3.0.1", "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "d3-geo-voronoi": "^2.1.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-path": "^3.1.0", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", "lodash-es": "^4.17.21", "memoize": "^10.1.0", "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-yt28xU8WzXq0AliX7eiC0JKZGQtO8M9FmHvt8sESNitSc/yC+fYeTghaO9lMRwcYCmi6D1NjbFyD9mWFeazNIQ=="],
|
||||
"layerchart": ["layerchart@2.0.0-next.43", "", { "dependencies": { "@dagrejs/dagre": "^1.1.5", "@layerstack/svelte-actions": "1.0.1-next.14", "@layerstack/svelte-state": "0.1.0-next.19", "@layerstack/tailwind": "2.0.0-next.17", "@layerstack/utils": "2.0.0-next.14", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-delaunay": "^6.0.4", "d3-dsv": "^3.0.1", "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "d3-geo-voronoi": "^2.1.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-path": "^3.1.0", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", "lodash-es": "^4.17.21", "memoize": "^10.1.0", "runed": "^0.31.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1Ywm38NdzHWKwgaAHq3EcqshIgsq+pylntSnVWAVazXUk/NsxPcxdpR3tMt3ySjWV0ZPBBgLs78sdVf7FTgd+g=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||
|
||||
@@ -525,21 +537,15 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
||||
|
||||
"lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
"lodash-es": ["lodash-es@4.17.22", "", {}, "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"memoize": ["memoize@10.1.0", "", { "dependencies": { "mimic-function": "^5.0.1" } }, "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg=="],
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
"memoize": ["memoize@10.2.0", "", { "dependencies": { "mimic-function": "^5.0.1" } }, "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
@@ -547,13 +553,7 @@
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
|
||||
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||
|
||||
"mode-watcher": ["mode-watcher@1.0.7", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-ZGA7ZGdOvBJeTQkzdBOnXSgTkO6U6iIFWJoyGCTt6oHNg9XP9NBvS26De+V4W2aqI+B0yYXUskFG2VnEo3zyMQ=="],
|
||||
"mode-watcher": ["mode-watcher@1.1.0", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
@@ -571,6 +571,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
@@ -579,9 +581,11 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.4", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w=="],
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
||||
|
||||
@@ -593,29 +597,31 @@
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="],
|
||||
"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-tailwindcss": ["prettier-plugin-tailwindcss@0.6.12", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@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-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@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-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw=="],
|
||||
"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=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
||||
|
||||
"rollup": ["rollup@4.44.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.2", "@rollup/rollup-android-arm64": "4.44.2", "@rollup/rollup-darwin-arm64": "4.44.2", "@rollup/rollup-darwin-x64": "4.44.2", "@rollup/rollup-freebsd-arm64": "4.44.2", "@rollup/rollup-freebsd-x64": "4.44.2", "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", "@rollup/rollup-linux-arm-musleabihf": "4.44.2", "@rollup/rollup-linux-arm64-gnu": "4.44.2", "@rollup/rollup-linux-arm64-musl": "4.44.2", "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", "@rollup/rollup-linux-riscv64-gnu": "4.44.2", "@rollup/rollup-linux-riscv64-musl": "4.44.2", "@rollup/rollup-linux-s390x-gnu": "4.44.2", "@rollup/rollup-linux-x64-gnu": "4.44.2", "@rollup/rollup-linux-x64-musl": "4.44.2", "@rollup/rollup-win32-arm64-msvc": "4.44.2", "@rollup/rollup-win32-ia32-msvc": "4.44.2", "@rollup/rollup-win32-x64-msvc": "4.44.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg=="],
|
||||
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="],
|
||||
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
||||
|
||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
|
||||
@@ -623,105 +629,113 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="],
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="],
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"svelte": ["svelte@5.33.10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/yArPQIBoQS2p86LKnvJywOXkVHeEXnFgrDPSxkEfIAEkykopYuy2bF6UUqHG4IbZlJD6OurLxJT8Kn7kTk9WA=="],
|
||||
"svelte": ["svelte@5.46.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.2.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
|
||||
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
||||
|
||||
"svelte-eslint-parser": ["svelte-eslint-parser@1.2.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mbPtajIeuiyU80BEyGvwAktBeTX7KCr5/0l+uRGLq1dafwRNrjfM5kHGJScEBlPG3ipu6dJqfW/k0/fujvIEVw=="],
|
||||
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
|
||||
|
||||
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
|
||||
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
|
||||
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.5", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w=="],
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||
|
||||
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"tailwind-variants": ["tailwind-variants@1.0.0", "", { "dependencies": { "tailwind-merge": "3.0.2" }, "peerDependencies": { "tailwindcss": "*" } }, "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.8", "", {}, "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og=="],
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.2", "", {}, "sha512-khGYcg4sHWFWcjpiWvy0KN0Bd6yVy6Ecc4r9ZP2u7FV+n4/Fp8MQscCWJkM0KMIRvrpGyKpIQnIbEd1hrewdeg=="],
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.33.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.33.0", "@typescript-eslint/parser": "8.33.0", "@typescript-eslint/utils": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.51.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.0.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-y2L5oJZF7bj4c0jgGYgBNSdIu+5HF+m68rn2cQXFbGoShdhV1phX9rbnxy9YXj82aS8MMsCLAAFkRxZeWdldrQ=="],
|
||||
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="],
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte/vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
@@ -729,36 +743,32 @@
|
||||
|
||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"layerchart/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
"layerchart/runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="],
|
||||
|
||||
"mode-watcher/runed": ["runed@0.25.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg=="],
|
||||
|
||||
"mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
|
||||
|
||||
"rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||
|
||||
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
|
||||
|
||||
"svelte-sonner/runed": ["runed@0.26.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw=="],
|
||||
|
||||
"svelte-toolbelt/runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
||||
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
||||
|
||||
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
|
||||
|
||||
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
|
||||
|
||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 ts from 'typescript-eslint';
|
||||
@@ -9,7 +10,7 @@ import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
@@ -22,6 +23,7 @@ export default ts.config(
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'off',
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||
@@ -37,6 +39,9 @@ export default ts.config(
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-deprecated': 'error'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
5204
web/package-lock.json
generated
Normal file
5204
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,40 +1,41 @@
|
||||
{
|
||||
"name": "bili-sync-web",
|
||||
"version": "2.7.0",
|
||||
"version": "2.10.4",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"bits-ui": "^2.11.0",
|
||||
"bits-ui": "^2.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"layerchart": "2.0.0-next.27",
|
||||
"mode-watcher": "^1.0.6",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-sonner": "^1.0.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"globals": "^16.5.0",
|
||||
"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",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.2",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "7.0.3"
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.3.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -47,5 +48,9 @@
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,22 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<script>
|
||||
(function () {
|
||||
function getThemePreference() {
|
||||
const saved = localStorage.getItem('mode-watcher-mode');
|
||||
if (saved && (saved === 'light' || saved === 'dark')) {
|
||||
return saved;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const theme = getThemePreference();
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
VideoSourcesResponse,
|
||||
VideosRequest,
|
||||
VideosResponse,
|
||||
VideoResponse,
|
||||
ResetVideoResponse,
|
||||
ResetAllVideosResponse,
|
||||
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,
|
||||
ResetRequest,
|
||||
UpdateVideoSourceResponse
|
||||
UpdateFilteredVideoStatusRequest,
|
||||
UpdateFilteredVideoStatusResponse,
|
||||
UpdateVideoSourceRequest,
|
||||
UpdateVideoSourceResponse,
|
||||
UpdateVideoStatusRequest,
|
||||
UpdateVideoStatusResponse,
|
||||
UppersResponse,
|
||||
VideoResponse,
|
||||
VideoSourcesDetailsResponse,
|
||||
VideoSourcesResponse,
|
||||
VideosRequest,
|
||||
VideosResponse
|
||||
} from './types';
|
||||
import { wsManager } from './ws';
|
||||
|
||||
@@ -56,6 +63,10 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getAuthToken(): string | null {
|
||||
return this.defaultHeaders['Authorization'] || localStorage.getItem('authToken');
|
||||
}
|
||||
|
||||
// 清除认证 token
|
||||
clearAuthToken() {
|
||||
delete this.defaultHeaders['Authorization'];
|
||||
@@ -152,12 +163,21 @@ class ApiClient {
|
||||
return this.get<VideoResponse>(`/videos/${id}`);
|
||||
}
|
||||
|
||||
async resetVideo(id: number, request: ResetRequest): Promise<ApiResponse<ResetVideoResponse>> {
|
||||
return this.post<ResetVideoResponse>(`/videos/${id}/reset`, request);
|
||||
async resetVideoStatus(
|
||||
id: number,
|
||||
request: ResetVideoStatusRequest
|
||||
): Promise<ApiResponse<ResetVideoResponse>> {
|
||||
return this.post<ResetVideoResponse>(`/videos/${id}/reset-status`, request);
|
||||
}
|
||||
|
||||
async resetAllVideos(request: ResetRequest): Promise<ApiResponse<ResetAllVideosResponse>> {
|
||||
return this.post<ResetAllVideosResponse>('/videos/reset-all', request);
|
||||
async clearAndResetVideoStatus(id: number): Promise<ApiResponse<ClearAndResetVideoResponse>> {
|
||||
return this.post<ClearAndResetVideoResponse>(`/videos/${id}/clear-and-reset-status`);
|
||||
}
|
||||
|
||||
async resetFilteredVideoStatus(
|
||||
request: ResetFilteredVideoStatusRequest
|
||||
): Promise<ApiResponse<ResetFilteredVideosResponse>> {
|
||||
return this.post<ResetFilteredVideosResponse>('/videos/reset-status', request);
|
||||
}
|
||||
|
||||
async updateVideoStatus(
|
||||
@@ -167,6 +187,12 @@ class ApiClient {
|
||||
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
||||
}
|
||||
|
||||
async updateFilteredVideoStatus(
|
||||
request: UpdateFilteredVideoStatusRequest
|
||||
): Promise<ApiResponse<UpdateFilteredVideoStatusResponse>> {
|
||||
return this.post<UpdateFilteredVideoStatusResponse>('/videos/update-status', request);
|
||||
}
|
||||
|
||||
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
|
||||
return this.get<FavoritesResponse>('/me/favorites');
|
||||
}
|
||||
@@ -184,11 +210,13 @@ class ApiClient {
|
||||
|
||||
async getFollowedUppers(
|
||||
pageNum?: number,
|
||||
pageSize?: number
|
||||
pageSize?: number,
|
||||
name?: string
|
||||
): Promise<ApiResponse<UppersResponse>> {
|
||||
const params = {
|
||||
page_num: pageNum,
|
||||
page_size: pageSize
|
||||
page_size: pageSize,
|
||||
name: name
|
||||
};
|
||||
return this.get<UppersResponse>('/me/uppers', params as Record<string, unknown>);
|
||||
}
|
||||
@@ -217,10 +245,22 @@ class ApiClient {
|
||||
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
|
||||
}
|
||||
|
||||
async removeVideoSource(type: string, id: number): Promise<ApiResponse<boolean>> {
|
||||
return this.request<boolean>(`/video-sources/${type}/${id}`, 'DELETE');
|
||||
}
|
||||
|
||||
async evaluateVideoSourceRules(type: string, id: number): Promise<ApiResponse<boolean>> {
|
||||
return this.post<boolean>(`/video-sources/${type}/${id}/evaluate`, null);
|
||||
}
|
||||
|
||||
async getDefaultPath(type: string, name: string): Promise<ApiResponse<string>> {
|
||||
return this.get<string>(`/video-sources/${type}/default-path`, { name });
|
||||
}
|
||||
|
||||
async testNotifier(notifier: Notifier): Promise<ApiResponse<boolean>> {
|
||||
return this.post<boolean>('/config/notifiers/ping', notifier);
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ApiResponse<Config>> {
|
||||
return this.get<Config>('/config');
|
||||
}
|
||||
@@ -232,6 +272,19 @@ class ApiClient {
|
||||
async getDashboard(): Promise<ApiResponse<DashBoardResponse>> {
|
||||
return this.get<DashBoardResponse>('/dashboard');
|
||||
}
|
||||
|
||||
async triggerDownloadTask(): Promise<ApiResponse<boolean>> {
|
||||
return this.post<boolean>('/task/download');
|
||||
}
|
||||
|
||||
async generateQrcode(): Promise<ApiResponse<GenerateQrcodeResponse>> {
|
||||
return this.post<GenerateQrcodeResponse>('/login/qrcode/generate');
|
||||
}
|
||||
|
||||
async pollQrcode(qrcodeKey: string): Promise<ApiResponse<PollQrcodeResponse>> {
|
||||
return this.get<PollQrcodeResponse>('/login/qrcode/poll', { qrcode_key: qrcodeKey });
|
||||
}
|
||||
|
||||
subscribeToLogs(onMessage: (data: string) => void) {
|
||||
return wsManager.subscribeToLogs(onMessage);
|
||||
}
|
||||
@@ -251,26 +304,37 @@ const api = {
|
||||
getVideoSources: () => apiClient.getVideoSources(),
|
||||
getVideos: (params?: VideosRequest) => apiClient.getVideos(params),
|
||||
getVideo: (id: number) => apiClient.getVideo(id),
|
||||
resetVideo: (id: number, request: ResetRequest) => apiClient.resetVideo(id, request),
|
||||
resetAllVideos: (request: ResetRequest) => apiClient.resetAllVideos(request),
|
||||
resetVideoStatus: (id: number, request: ResetVideoStatusRequest) =>
|
||||
apiClient.resetVideoStatus(id, request),
|
||||
clearAndResetVideoStatus: (id: number) => apiClient.clearAndResetVideoStatus(id),
|
||||
resetFilteredVideoStatus: (request: ResetFilteredVideoStatusRequest) =>
|
||||
apiClient.resetFilteredVideoStatus(request),
|
||||
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
||||
apiClient.updateVideoStatus(id, request),
|
||||
updateFilteredVideoStatus: (request: UpdateFilteredVideoStatusRequest) =>
|
||||
apiClient.updateFilteredVideoStatus(request),
|
||||
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
|
||||
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
|
||||
apiClient.getFollowedCollections(pageNum, pageSize),
|
||||
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
|
||||
apiClient.getFollowedUppers(pageNum, pageSize),
|
||||
getFollowedUppers: (pageNum?: number, pageSize?: number, name?: string) =>
|
||||
apiClient.getFollowedUppers(pageNum, pageSize, name),
|
||||
insertFavorite: (request: InsertFavoriteRequest) => apiClient.insertFavorite(request),
|
||||
insertCollection: (request: InsertCollectionRequest) => apiClient.insertCollection(request),
|
||||
insertSubmission: (request: InsertSubmissionRequest) => apiClient.insertSubmission(request),
|
||||
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
||||
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
||||
apiClient.updateVideoSource(type, id, request),
|
||||
removeVideoSource: (type: string, id: number) => apiClient.removeVideoSource(type, id),
|
||||
evaluateVideoSourceRules: (type: string, id: number) =>
|
||||
apiClient.evaluateVideoSourceRules(type, id),
|
||||
getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name),
|
||||
testNotifier: (notifier: Notifier) => apiClient.testNotifier(notifier),
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||
getDashboard: () => apiClient.getDashboard(),
|
||||
triggerDownloadTask: () => apiClient.triggerDownloadTask(),
|
||||
generateQrcode: () => apiClient.generateQrcode(),
|
||||
pollQrcode: (qrcodeKey: string) => apiClient.pollQrcode(qrcodeKey),
|
||||
subscribeToSysInfo: (onMessage: (data: SysInfo) => void) =>
|
||||
apiClient.subscribeToSysInfo(onMessage),
|
||||
|
||||
@@ -280,6 +344,7 @@ const api = {
|
||||
apiClient.subscribeToTasks(onMessage),
|
||||
|
||||
setAuthToken: (token: string) => apiClient.setAuthToken(token),
|
||||
getAuthToken: () => apiClient.getAuthToken(),
|
||||
clearAuthToken: () => apiClient.clearAuthToken()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<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 FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import SquareTerminalIcon from '@lucide/svelte/icons/square-terminal';
|
||||
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';
|
||||
|
||||
let sidebar = Sidebar.useSidebar();
|
||||
@@ -43,7 +47,7 @@
|
||||
items: [
|
||||
{
|
||||
title: '视频',
|
||||
icon: FileVideoIcon,
|
||||
icon: FilePlayIcon,
|
||||
href: '/videos'
|
||||
},
|
||||
{
|
||||
@@ -62,12 +66,12 @@
|
||||
href: '/me/favorites'
|
||||
},
|
||||
{
|
||||
title: '我关注的合集',
|
||||
icon: FolderIcon,
|
||||
title: '我追的合集 / 收藏夹',
|
||||
icon: FoldersIcon,
|
||||
href: '/me/collections'
|
||||
},
|
||||
{
|
||||
title: '我关注的 up 主',
|
||||
title: '我关注的 UP 主',
|
||||
icon: UserIcon,
|
||||
href: '/me/uppers'
|
||||
}
|
||||
@@ -136,6 +140,22 @@
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.Separator />
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton class="h-8 cursor-pointer">
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
onclick={() => {
|
||||
toggleMode();
|
||||
closeMobileSidebar();
|
||||
}}
|
||||
>
|
||||
<PaletteIcon class="size-4" />
|
||||
<span class="text-sm">{mode.current === 'light' ? '亮色' : '暗色'}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{#each data.footer as item (item.title)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton class="h-8">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
hideIndicator?: boolean;
|
||||
labelClassName?: string;
|
||||
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
|
||||
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
valueFormatter?: ((value: any) => string | number | Snippet) | null;
|
||||
formatter?: Snippet<
|
||||
|
||||
268
web/src/lib/components/custom/qr-login.svelte
Normal file
268
web/src/lib/components/custom/qr-login.svelte
Normal file
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import api from '$lib/api';
|
||||
import type { Credential, ApiError } from '$lib/types';
|
||||
import { RefreshCw, LoaderCircle } from '@lucide/svelte/icons';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
/**
|
||||
* 扫码登录组件
|
||||
*
|
||||
* 状态流转:
|
||||
* loading -> showing -> (success | expired | error)
|
||||
* success 会调用 onSuccess 回调,由父组件关闭弹窗,不需要内部做处理
|
||||
*
|
||||
* @prop onSuccess - 登录成功回调,接收完整的凭证对象
|
||||
*/
|
||||
|
||||
// 常量配置
|
||||
const QR_EXPIRE_TIME = 180; // 二维码有效期(秒)
|
||||
const POLL_INTERVAL = 2000; // 轮询间隔(毫秒)
|
||||
const COUNTDOWN_INTERVAL = 1000; // 倒计时更新间隔(毫秒)
|
||||
const QR_SIZE = 256; // 二维码图片尺寸(像素)
|
||||
const QR_MARGIN = 2; // 二维码边距
|
||||
|
||||
export let onSuccess: (credential: Credential) => void;
|
||||
|
||||
export function init() {
|
||||
generateQrcode();
|
||||
}
|
||||
|
||||
type Status = 'loading' | 'showing' | 'expired' | 'error';
|
||||
|
||||
let status: Status = 'loading';
|
||||
let qrcodeUrl = ''; // B站返回的二维码 URL(需要转换为图片)
|
||||
let qrcodeKey = ''; // 用于轮询的认证 token
|
||||
let qrcodeDataUrl = ''; // 生成的二维码图片 Data URL
|
||||
let countdown = QR_EXPIRE_TIME; // 倒计时
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let scanned = false; // 是否已扫描
|
||||
let errorMessage = '';
|
||||
let isPolling = false; // 轮询标志,确保轮询排他性
|
||||
|
||||
/**
|
||||
* 生成二维码
|
||||
*
|
||||
* 1. 停止之前的轮询和倒计时(确保排他性)
|
||||
* 2. 调用后端 API 获取二维码信息
|
||||
* 3. 将 URL 转换为二维码图片
|
||||
* 4. 开始轮询登录状态
|
||||
*/
|
||||
async function generateQrcode() {
|
||||
// 先停止之前的轮询和倒计时(排他性)
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
status = 'loading';
|
||||
errorMessage = '';
|
||||
scanned = false;
|
||||
|
||||
try {
|
||||
const response = await api.generateQrcode();
|
||||
qrcodeUrl = response.data.url;
|
||||
qrcodeKey = response.data.qrcode_key;
|
||||
countdown = QR_EXPIRE_TIME;
|
||||
|
||||
// 将 URL 转换为二维码图片
|
||||
qrcodeDataUrl = await QRCode.toDataURL(qrcodeUrl, {
|
||||
width: QR_SIZE,
|
||||
margin: QR_MARGIN,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
});
|
||||
|
||||
status = 'showing';
|
||||
|
||||
// 开始轮询和倒计时
|
||||
startPolling();
|
||||
startCountdown();
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
status = 'error';
|
||||
errorMessage = (error as ApiError).message || '生成二维码失败';
|
||||
toast.error('生成二维码失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询登录状态
|
||||
*
|
||||
* 每次调用前检查 isPolling 标志,确保轮询排他性。
|
||||
* 异步请求后再次检查,防止在请求过程中状态已改变。
|
||||
*/
|
||||
async function pollStatus() {
|
||||
// 如果已经停止轮询,直接返回
|
||||
if (!qrcodeKey || !isPolling) return;
|
||||
|
||||
try {
|
||||
const response = await api.pollQrcode(qrcodeKey);
|
||||
const pollResult = response.data;
|
||||
|
||||
// 再次检查是否还在轮询(防止在请求过程中状态改变)
|
||||
if (!isPolling) return;
|
||||
|
||||
if (pollResult.status === 'success') {
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
onSuccess(pollResult.credential);
|
||||
} else if (pollResult.status === 'pending') {
|
||||
scanned = pollResult.scanned || false;
|
||||
} else if (pollResult.status === 'expired') {
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
status = 'expired';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询登录状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*
|
||||
* 设置轮询标志并启动定时器
|
||||
*/
|
||||
function startPolling() {
|
||||
isPolling = true;
|
||||
pollInterval = setInterval(pollStatus, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*
|
||||
* 立即设置轮询标志为 false,清除定时器
|
||||
*/
|
||||
function stopPolling() {
|
||||
isPolling = false; // 立即设置标志为 false
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动倒计时
|
||||
*
|
||||
* 每秒减少倒计时,到期后自动停止轮询并标记为过期
|
||||
*/
|
||||
function startCountdown() {
|
||||
countdownInterval = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
status = 'expired';
|
||||
}
|
||||
}, COUNTDOWN_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止倒计时
|
||||
*
|
||||
* 清除倒计时定时器
|
||||
*/
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="qr-login-container">
|
||||
<Card.Root class="border-0 shadow-none">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- 二维码容器 - 始终显示边框 -->
|
||||
<div class="border-border relative rounded-lg border-2 bg-white p-3">
|
||||
{#if status === 'loading'}
|
||||
<!-- 加载状态 -->
|
||||
<div class="flex h-48 w-48 items-center justify-center">
|
||||
<LoaderCircle class="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
{:else if status === 'showing'}
|
||||
<!-- 显示二维码 -->
|
||||
<img src={qrcodeDataUrl} alt="登录二维码" class="h-48 w-48" />
|
||||
{:else}
|
||||
<!-- 过期或错误状态 - 显示占位图标 -->
|
||||
<div class="flex h-48 w-48 items-center justify-center">
|
||||
<RefreshCw class="text-muted-foreground h-12 w-12" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 状态提示文本 -->
|
||||
<div class="text-muted-foreground space-y-2 text-center text-sm">
|
||||
{#if status === 'loading'}
|
||||
<p>正在生成二维码...</p>
|
||||
{:else if status === 'showing'}
|
||||
{#if scanned}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||
<p>已扫描,请在手机上确认登录</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>请使用哔哩哔哩 APP 扫描二维码</p>
|
||||
{/if}
|
||||
{:else if status === 'expired'}
|
||||
<p>二维码已过期</p>
|
||||
{:else if status === 'error'}
|
||||
<p class="text-destructive">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<!-- 倒计时 - 始终显示 -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="text-muted-foreground text-xs">有效时间:</span>
|
||||
<span
|
||||
class="font-mono text-sm font-bold"
|
||||
class:text-primary={countdown > 0}
|
||||
class:text-muted-foreground={countdown <= 0}
|
||||
>
|
||||
{#if status === 'showing'}
|
||||
{Math.floor(countdown / 60)}:{String(countdown % 60).padStart(2, '0')}
|
||||
{:else}
|
||||
-:--
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 - 根据状态变化 -->
|
||||
{#if status === 'loading'}
|
||||
<Button variant="outline" size="sm" class="w-full" disabled>
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</Button>
|
||||
{:else if status === 'showing'}
|
||||
<Button variant="outline" size="sm" onclick={generateQrcode} class="w-full">
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
刷新二维码
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" size="sm" onclick={generateQrcode} class="w-full">
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
重新获取二维码
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.qr-login-container {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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)}
|
||||
|
||||
331
web/src/lib/components/filtered-status-editor.svelte
Normal file
331
web/src/lib/components/filtered-status-editor.svelte
Normal file
@@ -0,0 +1,331 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '$lib/components/ui/sheet/index.js';
|
||||
import type { StatusUpdate, UpdateFilteredVideoStatusRequest } from '$lib/types';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
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 主信息', '分页下载'];
|
||||
|
||||
// 分页任务名称(与后端 PageStatus 对应)
|
||||
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];
|
||||
|
||||
// 状态选项:null 表示未选择,0 表示未开始,7 表示已完成
|
||||
type StatusValue = null | 0 | 7;
|
||||
|
||||
// 视频任务状态,默认都是 null(未选择)
|
||||
let videoStatuses = $state<StatusValue[]>(Array(5).fill(null));
|
||||
|
||||
// 分页任务状态,默认都是 null(未选择)
|
||||
let pageStatuses = $state<StatusValue[]>(Array(5).fill(null));
|
||||
|
||||
function setVideoStatus(taskIndex: number, value: StatusValue) {
|
||||
videoStatuses[taskIndex] = value;
|
||||
}
|
||||
|
||||
function setPageStatus(taskIndex: number, value: StatusValue) {
|
||||
pageStatuses[taskIndex] = value;
|
||||
}
|
||||
|
||||
function resetVideoStatus(taskIndex: number) {
|
||||
videoStatuses[taskIndex] = null;
|
||||
}
|
||||
|
||||
function resetPageStatus(taskIndex: number) {
|
||||
pageStatuses[taskIndex] = null;
|
||||
}
|
||||
|
||||
function resetAllStatuses() {
|
||||
videoStatuses = Array(5).fill(null);
|
||||
pageStatuses = Array(5).fill(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 = {};
|
||||
|
||||
// 添加视频更新
|
||||
const videoUpdates: StatusUpdate[] = [];
|
||||
videoStatuses.forEach((status, index) => {
|
||||
if (status !== null) {
|
||||
videoUpdates.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
});
|
||||
if (videoUpdates.length > 0) {
|
||||
request.video_updates = videoUpdates;
|
||||
}
|
||||
|
||||
// 添加分页更新
|
||||
const pageUpdates: StatusUpdate[] = [];
|
||||
pageStatuses.forEach((status, index) => {
|
||||
if (status !== null) {
|
||||
pageUpdates.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
});
|
||||
if (pageUpdates.length > 0) {
|
||||
request.page_updates = pageUpdates;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!hasAnyChanges) {
|
||||
toast.info('请至少选择一个状态进行修改');
|
||||
return;
|
||||
}
|
||||
const request = buildRequest();
|
||||
onsubmit(request);
|
||||
}
|
||||
|
||||
// 当 Sheet 关闭时重置状态
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
resetAllStatuses();
|
||||
}
|
||||
});
|
||||
|
||||
function getStatusInfo(status: StatusValue) {
|
||||
if (status === 0) {
|
||||
return { label: '未开始', class: 'text-yellow-600', dotClass: 'bg-yellow-600' };
|
||||
}
|
||||
if (status === 7) {
|
||||
return { label: '已完成', class: 'text-emerald-600', dotClass: 'bg-emerald-600' };
|
||||
}
|
||||
return { label: '无修改', class: 'text-muted-foreground', dotClass: 'bg-muted-foreground' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sheet bind:open>
|
||||
<SheetContent side="right" class="flex w-full flex-col sm:max-w-3xl">
|
||||
<SheetHeader class="px-6 pb-2">
|
||||
<SheetTitle class="text-lg">{hasFilters ? '编辑筛选视频' : '编辑全部视频'}</SheetTitle>
|
||||
<SheetDescription class="text-muted-foreground space-y-2 text-sm"
|
||||
>批量编辑视频和分页的下载状态。可将任意子任务状态修改为“未开始”或“已完成”。<br />
|
||||
{#if hasFilters}
|
||||
正在编辑<strong>符合以下筛选条件</strong>的视频的下载状态:
|
||||
<div class="bg-muted my-2 rounded-md p-2 text-left">
|
||||
{#each filterDescriptionParts as part, index (index)}
|
||||
<div><strong>{part}</strong></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
正在编辑<strong>全部视频</strong>的下载状态。 <br />
|
||||
{/if}
|
||||
<div class="leading-relaxed text-orange-600">
|
||||
⚠️ 仅当分页下载状态不是"已完成"时,程序才会尝试执行分页下载。
|
||||
</div>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
<div class="space-y-6 py-2">
|
||||
<!-- 视频状态编辑 -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-base font-medium">视频状态</h3>
|
||||
<div class="bg-card rounded-lg border p-4">
|
||||
<div class="space-y-3">
|
||||
{#each videoTaskNames as taskName, index (index)}
|
||||
{@const statusInfo = getStatusInfo(videoStatuses[index])}
|
||||
{@const isModified = videoStatuses[index] !== null}
|
||||
<div
|
||||
class="bg-background hover:bg-muted/30 flex items-center justify-between rounded-md border p-3 transition-colors {isModified
|
||||
? 'border-blue-200 ring-2 ring-blue-500/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{taskName}</span>
|
||||
{#if isModified}
|
||||
<span class="hidden text-xs font-medium text-blue-600 sm:inline"
|
||||
>已修改</span
|
||||
>
|
||||
<div
|
||||
class="h-2 w-2 rounded-full bg-blue-500 sm:hidden"
|
||||
title="已修改"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full {statusInfo.dotClass}"></div>
|
||||
<span class="text-xs {statusInfo.class}">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
{#if isModified}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => resetVideoStatus(index)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
|
||||
title="恢复到原始状态"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant={videoStatuses[index] === 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setVideoStatus(index, 0)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {videoStatuses[index] ===
|
||||
0
|
||||
? 'border-yellow-600 bg-yellow-600 font-medium text-white hover:bg-yellow-700'
|
||||
: 'hover:border-yellow-400 hover:bg-yellow-50 hover:text-yellow-700'}"
|
||||
>
|
||||
未开始
|
||||
</Button>
|
||||
<Button
|
||||
variant={videoStatuses[index] === 7 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setVideoStatus(index, 7)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {videoStatuses[index] ===
|
||||
7
|
||||
? 'border-emerald-600 bg-emerald-600 font-medium text-white hover:bg-emerald-700'
|
||||
: 'hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-700'}"
|
||||
>
|
||||
已完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页状态编辑 -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-base font-medium">分页状态</h3>
|
||||
<div class="bg-card rounded-lg border p-4">
|
||||
<div class="space-y-3">
|
||||
{#each pageTaskNames as taskName, index (index)}
|
||||
{@const statusInfo = getStatusInfo(pageStatuses[index])}
|
||||
{@const isModified = pageStatuses[index] !== null}
|
||||
<div
|
||||
class="bg-background hover:bg-muted/30 flex items-center justify-between rounded-md border p-3 transition-colors {isModified
|
||||
? 'border-blue-200 ring-2 ring-blue-500/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{taskName}</span>
|
||||
{#if isModified}
|
||||
<span class="hidden text-xs font-medium text-blue-600 sm:inline"
|
||||
>已修改</span
|
||||
>
|
||||
<div
|
||||
class="h-2 w-2 rounded-full bg-blue-500 sm:hidden"
|
||||
title="已修改"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full {statusInfo.dotClass}"></div>
|
||||
<span class="text-xs {statusInfo.class}">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
{#if isModified}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => resetPageStatus(index)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
|
||||
title="恢复到原始状态"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant={pageStatuses[index] === 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setPageStatus(index, 0)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {pageStatuses[index] === 0
|
||||
? 'border-yellow-600 bg-yellow-600 font-medium text-white hover:bg-yellow-700'
|
||||
: 'hover:border-yellow-400 hover:bg-yellow-50 hover:text-yellow-700'}"
|
||||
>
|
||||
未开始
|
||||
</Button>
|
||||
<Button
|
||||
variant={pageStatuses[index] === 7 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setPageStatus(index, 7)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {pageStatuses[index] === 7
|
||||
? 'border-emerald-600 bg-emerald-600 font-medium text-white hover:bg-emerald-700'
|
||||
: 'hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-700'}"
|
||||
>
|
||||
已完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter class="bg-background flex gap-2 border-t px-6 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={resetAllStatuses}
|
||||
disabled={!hasAnyChanges || loading}
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
重置所有状态
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
disabled={loading || !hasAnyChanges}
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
{loading ? '提交中...' : '提交更改'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -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';
|
||||
|
||||
@@ -33,6 +31,7 @@
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'icontains', label: '包含(不区分大小写)' },
|
||||
{ value: 'prefix', label: '以...开头' },
|
||||
{ value: 'suffix', label: '以...结尾' },
|
||||
{ value: 'matchesRegex', label: '匹配正则' }
|
||||
|
||||
@@ -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,30 +3,24 @@
|
||||
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 type {
|
||||
FavoriteWithSubscriptionStatus,
|
||||
CollectionWithSubscriptionStatus,
|
||||
UpperWithSubscriptionStatus
|
||||
} from '$lib/types';
|
||||
import {
|
||||
UserIcon,
|
||||
VideoIcon,
|
||||
FolderIcon,
|
||||
HeartIcon,
|
||||
CheckIcon,
|
||||
PlusIcon,
|
||||
XIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import type { Followed } from '$lib/types';
|
||||
|
||||
export let item:
|
||||
| FavoriteWithSubscriptionStatus
|
||||
| CollectionWithSubscriptionStatus
|
||||
| UpperWithSubscriptionStatus;
|
||||
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
|
||||
export let item: Followed;
|
||||
export let onSubscriptionSuccess: (() => void) | null = null;
|
||||
|
||||
let dialogOpen = false;
|
||||
|
||||
function getIcon() {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return HeartIcon;
|
||||
case 'collection':
|
||||
@@ -39,7 +33,7 @@
|
||||
}
|
||||
|
||||
function getTypeLabel() {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return '收藏夹';
|
||||
case 'collection':
|
||||
@@ -52,54 +46,49 @@
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return (item as FavoriteWithSubscriptionStatus).title;
|
||||
case 'collection':
|
||||
return (item as CollectionWithSubscriptionStatus).title;
|
||||
return item.title;
|
||||
case 'upper':
|
||||
return (item as UpperWithSubscriptionStatus).uname;
|
||||
return item.uname;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getSubtitle(): string {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return `uid: ${(item as FavoriteWithSubscriptionStatus).mid}`;
|
||||
case 'collection':
|
||||
return `uid: ${(item as CollectionWithSubscriptionStatus).mid}`;
|
||||
case 'upper':
|
||||
return '';
|
||||
return `UID:${item.mid}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getDescription(): string {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'upper':
|
||||
return (item as UpperWithSubscriptionStatus).sign || '';
|
||||
return item.sign || '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabled(): boolean {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'collection':
|
||||
return (item as CollectionWithSubscriptionStatus).invalid;
|
||||
case 'upper': {
|
||||
return (item as UpperWithSubscriptionStatus).invalid;
|
||||
}
|
||||
case 'upper':
|
||||
case 'favorite':
|
||||
return item.invalid;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisabledReason(): string {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'collection':
|
||||
return '已失效';
|
||||
case 'upper':
|
||||
@@ -110,22 +99,19 @@
|
||||
}
|
||||
|
||||
function getCount(): number | null {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return (item as FavoriteWithSubscriptionStatus).media_count;
|
||||
case 'collection':
|
||||
return item.media_count;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCountLabel(): string {
|
||||
return '个视频';
|
||||
}
|
||||
|
||||
function getAvatarUrl(): string {
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'upper':
|
||||
return (item as UpperWithSubscriptionStatus).face;
|
||||
return item.face;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -151,7 +137,6 @@
|
||||
const subtitle = getSubtitle();
|
||||
const description = getDescription();
|
||||
const count = getCount();
|
||||
const countLabel = getCountLabel();
|
||||
const avatarUrl = getAvatarUrl();
|
||||
const subscribed = item.subscribed;
|
||||
const disabled = isDisabled();
|
||||
@@ -163,7 +148,7 @@
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader class="flex-shrink-0 pb-4">
|
||||
<CardHeader class="shrink-0">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- 头像或图标 - 简化设计 -->
|
||||
<div
|
||||
@@ -171,7 +156,7 @@
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if avatarUrl && type === 'upper'}
|
||||
{#if avatarUrl && item.type === 'upper'}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={title}
|
||||
@@ -199,7 +184,7 @@
|
||||
{#if disabled}
|
||||
<Badge variant="destructive" class="shrink-0 text-xs">不可用</Badge>
|
||||
{:else}
|
||||
<Badge variant={subscribed ? 'outline' : 'secondary'} class="shrink-0 text-xs">
|
||||
<Badge variant="secondary" class="shrink-0 text-xs">
|
||||
{subscribed ? '已订阅' : typeLabel}
|
||||
</Badge>
|
||||
{/if}
|
||||
@@ -213,25 +198,26 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 计数信息 -->
|
||||
{#if count !== null && !disabled}
|
||||
<div class="text-muted-foreground flex items-center gap-1 text-sm">
|
||||
<VideoIcon class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">视频数:{count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 描述信息 -->
|
||||
{#if description && !disabled}
|
||||
<p class="text-muted-foreground line-clamp-1 text-sm" title={description}>
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- 计数信息 -->
|
||||
{#if count !== null && !disabled}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
{count}
|
||||
{countLabel}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<!-- 底部按钮区域 -->
|
||||
<CardContent class="flex min-w-0 flex-1 flex-col justify-end pt-0 pb-4">
|
||||
<CardContent class="flex min-w-0 flex-1 flex-col justify-end">
|
||||
<div class="flex justify-end">
|
||||
{#if disabled}
|
||||
<Button
|
||||
@@ -264,6 +250,4 @@
|
||||
</Card>
|
||||
|
||||
<!-- 订阅对话框 -->
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||
<!-- 订阅对话框 -->
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} onSuccess={handleSubscriptionSuccess} />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -10,54 +11,43 @@
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '$lib/components/ui/sheet/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import api from '$lib/api';
|
||||
import type {
|
||||
FavoriteWithSubscriptionStatus,
|
||||
CollectionWithSubscriptionStatus,
|
||||
UpperWithSubscriptionStatus,
|
||||
Followed,
|
||||
InsertFavoriteRequest,
|
||||
InsertCollectionRequest,
|
||||
InsertSubmissionRequest,
|
||||
ApiError
|
||||
} from '$lib/types';
|
||||
|
||||
export let open = false;
|
||||
export let item:
|
||||
| FavoriteWithSubscriptionStatus
|
||||
| CollectionWithSubscriptionStatus
|
||||
| UpperWithSubscriptionStatus
|
||||
| null = null;
|
||||
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
|
||||
export let onSuccess: (() => void) | null = null;
|
||||
interface Props {
|
||||
open: boolean;
|
||||
item: Followed | null;
|
||||
onSuccess: (() => void) | null;
|
||||
}
|
||||
|
||||
let customPath = '';
|
||||
let loading = false;
|
||||
let { open = $bindable(false), item = null, onSuccess = null }: Props = $props();
|
||||
|
||||
let customPath = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// 根据类型和 item 生成默认路径
|
||||
function generateDefaultPath(): string {
|
||||
if (!item) return '';
|
||||
|
||||
switch (type) {
|
||||
case 'favorite': {
|
||||
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||
return `收藏夹/${favorite.title}`;
|
||||
}
|
||||
case 'collection': {
|
||||
const collection = item as CollectionWithSubscriptionStatus;
|
||||
return `合集/${collection.title}`;
|
||||
}
|
||||
case 'upper': {
|
||||
const upper = item as UpperWithSubscriptionStatus;
|
||||
return `UP 主/${upper.uname}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
async function generateDefaultPath(): Promise<string> {
|
||||
if (!item || !itemTitle) return '';
|
||||
// 根据 item.type 映射到对应的 API 类型
|
||||
const apiType =
|
||||
item.type === 'favorite'
|
||||
? 'favorites'
|
||||
: item.type === 'collection'
|
||||
? 'collections'
|
||||
: 'submissions';
|
||||
return (await api.getDefaultPath(apiType, itemTitle)).data;
|
||||
}
|
||||
|
||||
function getTypeLabel(): string {
|
||||
switch (type) {
|
||||
if (!item) return '';
|
||||
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return '收藏夹';
|
||||
case 'collection':
|
||||
@@ -72,13 +62,12 @@
|
||||
function getItemTitle(): string {
|
||||
if (!item) return '';
|
||||
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite':
|
||||
return (item as FavoriteWithSubscriptionStatus).title;
|
||||
case 'collection':
|
||||
return (item as CollectionWithSubscriptionStatus).title;
|
||||
return item.title;
|
||||
case 'upper':
|
||||
return (item as UpperWithSubscriptionStatus).uname;
|
||||
return item.uname;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -91,30 +80,27 @@
|
||||
try {
|
||||
let response;
|
||||
|
||||
switch (type) {
|
||||
switch (item.type) {
|
||||
case 'favorite': {
|
||||
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||
const request: InsertFavoriteRequest = {
|
||||
fid: favorite.fid,
|
||||
fid: item.fid,
|
||||
path: customPath.trim()
|
||||
};
|
||||
response = await api.insertFavorite(request);
|
||||
break;
|
||||
}
|
||||
case 'collection': {
|
||||
const collection = item as CollectionWithSubscriptionStatus;
|
||||
const request: InsertCollectionRequest = {
|
||||
sid: collection.sid,
|
||||
mid: collection.mid,
|
||||
sid: item.sid,
|
||||
mid: item.mid,
|
||||
path: customPath.trim()
|
||||
};
|
||||
response = await api.insertCollection(request);
|
||||
break;
|
||||
}
|
||||
case 'upper': {
|
||||
const upper = item as UpperWithSubscriptionStatus;
|
||||
const request: InsertSubmissionRequest = {
|
||||
upper_id: upper.mid,
|
||||
upper_id: item.mid,
|
||||
path: customPath.trim()
|
||||
};
|
||||
response = await api.insertSubmission(request);
|
||||
@@ -145,10 +131,20 @@
|
||||
open = false;
|
||||
}
|
||||
|
||||
// 当对话框打开时重置 path
|
||||
$: if (open && item) {
|
||||
customPath = generateDefaultPath();
|
||||
}
|
||||
$effect(() => {
|
||||
if (open && item) {
|
||||
generateDefaultPath()
|
||||
.then((path) => {
|
||||
customPath = path;
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('获取默认路径失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
customPath = '';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const typeLabel = getTypeLabel();
|
||||
const itemTitle = getItemTitle();
|
||||
@@ -173,21 +169,16 @@
|
||||
<span class="text-muted-foreground text-sm font-medium">{typeLabel}名称:</span>
|
||||
<span class="text-sm">{itemTitle}</span>
|
||||
</div>
|
||||
{#if type === 'favorite'}
|
||||
{@const favorite = item as FavoriteWithSubscriptionStatus}
|
||||
{#if item!.type !== 'upper'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm font-medium">视频数量:</span>
|
||||
<span class="text-sm">{favorite.media_count} 个</span>
|
||||
<span class="text-sm">{item!.media_count} 条</span>
|
||||
</div>
|
||||
{:else if item!.sign}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-muted-foreground text-sm font-medium">个人简介:</span>
|
||||
<span class="text-muted-foreground text-sm">{item!.sign}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if type === 'upper'}
|
||||
{@const upper = item as UpperWithSubscriptionStatus}
|
||||
{#if upper.sign}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-muted-foreground text-sm font-medium">个人简介:</span>
|
||||
<span class="text-muted-foreground text-sm">{upper.sign}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</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
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user