Merge remote-tracking branch 'upstream/main'
Some checks failed
Build Main Docs / Build documentation (push) Has been cancelled
Build Main Binary / build-binary (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled

# Conflicts:
#	.github/workflows/build-binary.yaml
#	.github/workflows/build-doc.yaml
#	.github/workflows/pr-check.yaml
#	.github/workflows/release-build.yaml
#	rust-toolchain.toml
This commit is contained in:
2026-04-01 02:00:04 +08:00
32 changed files with 481 additions and 220 deletions

View File

@@ -12,15 +12,15 @@ jobs:
working-directory: web
steps:
- name: Checkout repo
uses: bf1942/checkout@v4
uses: actions/checkout@v6
- name: Setup bun
uses: bf1942/setup-bun@v2
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: bf1942/cache@v4
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
@@ -29,7 +29,7 @@ jobs:
- name: Build Frontend
run: bun run build
- name: Upload Web Build Artifact
uses: bf1942/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: web-build
path: web/build
@@ -40,6 +40,11 @@ jobs:
strategy:
matrix:
platform:
- release_for: Linux-armv7
os: ubuntu-24.04
target: armv7-unknown-linux-musleabihf
bin: bili-sync-rs
name: bili-sync-rs-Linux-armv7-musl.tar.gz
- release_for: Linux-x86_64
os: ubuntu-24.04
target: x86_64-unknown-linux-musl
@@ -67,22 +72,22 @@ jobs:
name: bili-sync-rs-Windows-x86_64.zip
steps:
- name: Checkout repo
uses: bf1942/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Web Build Artifact
uses: bf1942/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: web-build
path: web/build
- name: Read Toolchain Version
uses: bf1942/toml-action@v1.2.0
id: read_rust_toolchain
with:
file: rust-toolchain.toml
field: toolchain.channel
shell: bash
run: |
channel=$(grep '^channel' rust-toolchain.toml | sed 's/.*= *"\(.*\)"/\1/')
echo "value=$channel" >> $GITHUB_OUTPUT
- name: Build binary
uses: bf1942/actions-rust-cross@v1
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: ${{ matrix.platform.target }}
@@ -99,7 +104,7 @@ jobs:
tar czvf ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }}
fi
- name: Upload release artifact
uses: bf1942/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: bili-sync-rs-${{ matrix.platform.release_for }}
path: |

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'docs/**'
- "docs/**"
jobs:
doc:
@@ -16,15 +16,15 @@ jobs:
working-directory: docs
steps:
- name: Checkout repo
uses: bf1942/checkout@v4
uses: actions/checkout@v6
- name: Setup bun
uses: bf1942/setup-bun@v2
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache dependencies
uses: bf1942/cache@v4
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('docs/bun.lockb') }}
@@ -33,9 +33,9 @@ jobs:
- name: Build documentation
run: bun run docs:build
- name: Deploy Github Pages
uses: bf1942/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/.vitepress/dist
force_orphan: true
commit_message: 部署来自 main 的最新文档变更:
commit_message: 部署来自 main 的最新文档变更:

View File

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

View File

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

7
Cargo.lock generated
View File

@@ -353,7 +353,7 @@ dependencies = [
[[package]]
name = "bili_sync"
version = "2.10.4"
version = "2.11.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -413,9 +413,10 @@ dependencies = [
[[package]]
name = "bili_sync_entity"
version = "2.10.4"
version = "2.11.0"
dependencies = [
"derivative",
"either",
"regex",
"sea-orm",
"serde",
@@ -424,7 +425,7 @@ dependencies = [
[[package]]
name = "bili_sync_migration"
version = "2.10.4"
version = "2.11.0"
dependencies = [
"sea-orm-migration",
]

View File

@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
resolver = "2"
[workspace.package]
version = "2.10.4"
version = "2.11.0"
authors = ["amtoaer <amtoaer@gmail.com>"]
license = "MIT"
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"
@@ -31,6 +31,7 @@ dashmap = "6.1.0"
derivative = "2.2.0"
dirs = "6.0.0"
dunce = "1.0.5"
either = "1.15.0"
enum_dispatch = "0.3.13"
float-ord = "0.3.2"
futures = "0.3.31"

View File

@@ -13,6 +13,8 @@ COPY ./bili-sync-rs-Linux-*.tar.gz ./targets/
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
tar xzvf ./targets/bili-sync-rs-Linux-x86_64-musl.tar.gz -C ./; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
tar xzvf ./targets/bili-sync-rs-Linux-armv7-musl.tar.gz -C ./; \
else \
tar xzvf ./targets/bili-sync-rs-Linux-aarch64-musl.tar.gz -C ./; \
fi
@@ -34,4 +36,3 @@ COPY --from=base / /
ENTRYPOINT [ "/app/bili-sync-rs" ]
VOLUME [ "/app/.config/bili-sync" ]

View File

@@ -429,7 +429,7 @@ mod tests {
let config = VersionedConfig::get().read();
for (bvid, video_quality, video_codec, audio_quality) in testcases.into_iter() {
let client = BiliClient::new();
let video = Video::new(&client, bvid.to_owned(), &config.credential);
let video = Video::new(&client, bvid, &config.credential);
let pages = video.get_pages().await.expect("failed to get pages");
let first_page = pages.into_iter().next().expect("no page found");
let best_stream = video

View File

@@ -16,12 +16,6 @@ pub struct FavoriteListInfo {
pub title: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct Upper<T> {
pub mid: T,
pub name: String,
pub face: String,
}
impl<'a> FavoriteList<'a> {
pub fn new(client: &'a BiliClient, fid: String, credential: &'a Credential) -> Self {
Self {

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
pub use analyzer::{BestStream, FilterOption};
use anyhow::{Context, Result, bail, ensure};
use arc_swap::ArcSwapOption;
use bili_sync_entity::upper_vec::Upper;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
pub use client::{BiliClient, Client};
@@ -13,7 +14,6 @@ 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};
@@ -133,7 +133,9 @@ pub enum VideoInfo {
#[serde(rename = "pic")]
cover: String,
#[serde(rename = "owner")]
upper: Upper<i64>,
upper: Upper<i64, String>,
#[serde(default)]
staff: Option<Vec<Upper<i64, String>>>,
#[serde(with = "ts_seconds")]
ctime: DateTime<Utc>,
#[serde(rename = "pubdate", with = "ts_seconds")]
@@ -152,7 +154,7 @@ pub enum VideoInfo {
bvid: String,
intro: String,
cover: String,
upper: Upper<i64>,
upper: Upper<i64, String>,
#[serde(with = "ts_seconds")]
ctime: DateTime<Utc>,
#[serde(with = "ts_seconds")]
@@ -170,7 +172,7 @@ pub enum VideoInfo {
#[serde(rename = "pic")]
cover: String,
#[serde(rename = "owner")]
upper: Upper<i64>,
upper: Upper<i64, String>,
#[serde(with = "ts_seconds")]
ctime: DateTime<Utc>,
#[serde(rename = "add_at", with = "ts_seconds")]
@@ -311,7 +313,7 @@ mod tests {
.into_mixin_key()
.context("no mixin key")?;
set_global_mixin_key(mixin_key);
let video = Video::new(&bili_client, "BV1gLfnY8E6D".to_string(), &credential);
let video = Video::new(&bili_client, "BV1gLfnY8E6D", &credential);
let pages = video.get_pages().await?;
println!("pages: {:?}", pages);
let subtitles = video.get_subtitles(&pages[0]).await?;
@@ -342,7 +344,7 @@ mod tests {
("BV16w41187fx", (true, true)), // 充电专享但有权观看
("BV1n34jzPEYq", (false, false)), // 普通视频
] {
let video = Video::new(&bili_client, bvid.to_string(), credential);
let video = Video::new(&bili_client, bvid, credential);
let info = video.get_view_info().await?;
let VideoInfo::Detail {
is_upower_exclusive,
@@ -375,7 +377,7 @@ mod tests {
("BV13xtnzPEye", false), // 番剧
("BV1kT4NzTEZj", true), // 普通视频
] {
let video = Video::new(&bili_client, bvid.to_string(), credential);
let video = Video::new(&bili_client, bvid, credential);
let info = video.get_view_info().await?;
let VideoInfo::Detail { redirect_url, .. } = info else {
unreachable!();

View File

@@ -1,10 +1,10 @@
use anyhow::{Context, Result, anyhow};
use async_stream::try_stream;
use bili_sync_entity::upper_vec::Upper;
use futures::Stream;
use reqwest::Method;
use serde_json::Value;
use crate::bilibili::favorite_list::Upper;
use crate::bilibili::{BiliClient, Credential, Dynamic, ErrorForStatusExt, MIXIN_KEY, Validate, VideoInfo, WbiSign};
pub struct Submission<'a> {
client: &'a BiliClient,
@@ -27,7 +27,7 @@ impl<'a> Submission<'a> {
}
}
pub async fn get_info(&self) -> Result<Upper<String>> {
pub async fn get_info(&self) -> Result<Upper<String, String>> {
let mut res = self
.client
.request(

View File

@@ -3,6 +3,7 @@ use futures::TryStreamExt;
use futures::stream::FuturesUnordered;
use prost::Message;
use reqwest::Method;
use serde_json::Value;
use crate::bilibili::analyzer::PageAnalyzer;
use crate::bilibili::client::BiliClient;
@@ -12,7 +13,7 @@ use crate::bilibili::{Credential, ErrorForStatusExt, MIXIN_KEY, Validate, VideoI
pub struct Video<'a> {
client: &'a BiliClient,
pub bvid: String,
pub bvid: &'a str,
credential: &'a Credential,
}
@@ -35,7 +36,7 @@ pub struct Dimension {
}
impl<'a> Video<'a> {
pub fn new(client: &'a BiliClient, bvid: String, credential: &'a Credential) -> Self {
pub fn new(client: &'a BiliClient, bvid: &'a str, credential: &'a Credential) -> Self {
Self {
client,
bvid,
@@ -85,7 +86,7 @@ impl<'a> Video<'a> {
}
pub async fn get_tags(&self) -> Result<Vec<String>> {
let res = self
let mut res = self
.client
.request(
Method::GET,
@@ -101,10 +102,10 @@ impl<'a> Video<'a> {
.await?
.validate()?;
Ok(res["data"]
.as_array()
.as_array_mut()
.context("tags is not an array")?
.iter()
.filter_map(|v| v["tag_name"].as_str().map(String::from))
.iter_mut()
.filter_map(|v| if let Value::String(s) = v.take() { Some(s) } else { None })
.collect())
}
@@ -154,7 +155,7 @@ impl<'a> Video<'a> {
)
.await
.query(&[
("bvid", self.bvid.as_str()),
("bvid", self.bvid),
("qn", "127"),
("otype", "json"),
("fnval", "4048"),
@@ -176,7 +177,7 @@ impl<'a> Video<'a> {
.client
.request(Method::GET, "https://api.bilibili.com/x/player/wbi/v2", self.credential)
.await
.query(&[("bvid", self.bvid.as_str())])
.query(&[("bvid", self.bvid)])
.query(&[("cid", page.cid)])
.wbi_sign(MIXIN_KEY.load().as_deref())?
.send()

View File

@@ -308,7 +308,7 @@ mod tests {
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
let config = VersionedConfig::get().read();
let client = BiliClient::new();
let video = Video::new(&client, "BV1QJmaYKEv4".to_owned(), &config.credential);
let video = Video::new(&client, "BV1QJmaYKEv4", &config.credential);
let pages = video.get_pages().await.expect("failed to get pages");
let first_page = pages.into_iter().next().expect("no page found");
let mut page_analyzer = video

View File

@@ -1,6 +1,8 @@
mod info;
mod message;
use std::collections::HashMap;
use anyhow::Result;
use futures::future;
pub use info::DownloadNotifyInfo;
@@ -20,6 +22,8 @@ pub enum Notifier {
Webhook {
url: String,
template: Option<String>,
#[serde(default)]
headers: Option<HashMap<String, String>>,
#[serde(skip)]
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
ignore_cache: Option<()>,
@@ -74,6 +78,7 @@ impl Notifier {
Notifier::Webhook {
url,
template,
headers,
ignore_cache,
} => {
let key = webhook_template_key(url);
@@ -82,12 +87,20 @@ impl Notifier {
Some(_) => handlebar.render_template(webhook_template_content(template), &message)?,
None => handlebar.render(&key, &message)?,
};
client
.post(url)
.header(header::CONTENT_TYPE, "application/json")
.body(payload)
.send()
.await?;
let mut headers_map = header::HeaderMap::new();
headers_map.insert(header::CONTENT_TYPE, "application/json".try_into()?);
if let Some(custom_headers) = headers {
for (key, value) in custom_headers {
if let (Ok(key), Ok(value)) =
(header::HeaderName::try_from(key), header::HeaderValue::try_from(value))
{
headers_map.insert(key, value);
}
}
}
client.post(url).headers(headers_map).body(payload).send().await?;
}
}
Ok(())

View File

@@ -133,6 +133,7 @@ impl VideoInfo {
intro,
cover,
upper,
staff,
ctime,
pubtime,
state,
@@ -165,6 +166,7 @@ impl VideoInfo {
upper_id: Set(upper.mid),
upper_name: Set(upper.name),
upper_face: Set(upper.face),
staff: Set(staff.map(Into::into)),
..base_model.into_active_model()
},
_ => unreachable!(),

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use bili_sync_entity::upper_vec::Upper as EntityUpper;
use bili_sync_entity::*;
use chrono::NaiveDateTime;
use quick_xml::Error;
@@ -20,9 +21,7 @@ pub struct Movie<'a> {
pub name: &'a str,
pub intro: &'a str,
pub bvid: &'a str,
pub upper_id: i64,
pub upper_name: &'a str,
pub upper_thumb: &'a str,
pub uppers: Vec<EntityUpper<i64, &'a str>>,
pub premiered: NaiveDateTime,
pub tags: Option<Vec<String>>,
}
@@ -31,9 +30,7 @@ pub struct TVShow<'a> {
pub name: &'a str,
pub intro: &'a str,
pub bvid: &'a str,
pub upper_id: i64,
pub upper_name: &'a str,
pub upper_thumb: &'a str,
pub uppers: Vec<EntityUpper<i64, &'a str>>,
pub premiered: NaiveDateTime,
pub tags: Option<Vec<String>>,
}
@@ -87,24 +84,26 @@ impl NFO<'_> {
.create_element("title")
.write_text_content_async(BytesText::new(movie.name))
.await?;
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&movie.upper_id.to_string()))
.await?;
writer
.create_element("role")
.write_text_content_async(BytesText::new(movie.upper_name))
.await?;
writer
.create_element("thumb")
.write_text_content_async(BytesText::new(movie.upper_thumb))
.await?;
Ok(writer)
})
.await?;
for upper in movie.uppers {
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&upper.mid.to_string()))
.await?;
writer
.create_element("role")
.write_text_content_async(BytesText::new(upper.role().as_ref()))
.await?;
writer
.create_element("thumb")
.write_text_content_async(BytesText::new(upper.face))
.await?;
Ok(writer)
})
.await?;
}
writer
.create_element("year")
.write_text_content_async(BytesText::new(&movie.premiered.format("%Y").to_string()))
@@ -145,24 +144,26 @@ impl NFO<'_> {
.create_element("title")
.write_text_content_async(BytesText::new(tvshow.name))
.await?;
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&tvshow.upper_id.to_string()))
.await?;
writer
.create_element("role")
.write_text_content_async(BytesText::new(tvshow.upper_name))
.await?;
writer
.create_element("thumb")
.write_text_content_async(BytesText::new(tvshow.upper_thumb))
.await?;
Ok(writer)
})
.await?;
for upper in tvshow.uppers {
writer
.create_element("actor")
.write_inner_content_async::<_, _, Error>(|writer| async move {
writer
.create_element("name")
.write_text_content_async(BytesText::new(&upper.mid.to_string()))
.await?;
writer
.create_element("role")
.write_text_content_async(BytesText::new(upper.role().as_ref()))
.await?;
writer
.create_element("thumb")
.write_text_content_async(BytesText::new(upper.face))
.await?;
Ok(writer)
})
.await?;
}
writer
.create_element("year")
.write_text_content_async(BytesText::new(&tvshow.premiered.format("%Y").to_string()))
@@ -320,7 +321,7 @@ mod tests {
</tvshow>"#,
);
assert_eq!(
NFO::Upper((&video).to_nfo(NFOTimeType::FavTime))
NFO::Upper(((&video, &video.uppers().next().unwrap())).to_nfo(NFOTimeType::FavTime))
.generate_nfo()
.await
.unwrap(),
@@ -366,9 +367,7 @@ impl<'a> ToNFO<'a, Movie<'a>> for &'a video::Model {
name: &self.name,
intro: &self.intro,
bvid: &self.bvid,
upper_id: self.upper_id,
upper_name: &self.upper_name,
upper_thumb: &self.upper_face,
uppers: self.uppers().collect(),
premiered: match nfo_time_type {
NFOTimeType::FavTime => self.favtime,
NFOTimeType::PubTime => self.pubtime,
@@ -384,9 +383,7 @@ impl<'a> ToNFO<'a, TVShow<'a>> for &'a video::Model {
name: &self.name,
intro: &self.intro,
bvid: &self.bvid,
upper_id: self.upper_id,
upper_name: &self.upper_name,
upper_thumb: &self.upper_face,
uppers: self.uppers().collect(),
premiered: match nfo_time_type {
NFOTimeType::FavTime => self.favtime,
NFOTimeType::PubTime => self.pubtime,
@@ -396,11 +393,11 @@ impl<'a> ToNFO<'a, TVShow<'a>> for &'a video::Model {
}
}
impl<'a> ToNFO<'a, Upper> for &'a video::Model {
impl<'a> ToNFO<'a, Upper> for (&video::Model, &EntityUpper<i64, &str>) {
fn to_nfo(&'a self, _nfo_time_type: NFOTimeType) -> Upper {
Upper {
upper_id: self.upper_id.to_string(),
pubtime: self.pubtime,
upper_id: self.1.mid.to_string(),
pubtime: self.0.pubtime,
}
}
}

View File

@@ -37,13 +37,22 @@ impl Evaluatable<usize> for Condition<usize> {
}
}
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> {
fn evaluate(&self, value: &NaiveDateTime) -> bool {
impl Evaluatable<NaiveDateTime> for Condition<NaiveDateTime> {
fn evaluate(&self, value: NaiveDateTime) -> bool {
match self {
Condition::Equals(expected) => expected == value,
Condition::GreaterThan(threshold) => value > threshold,
Condition::LessThan(threshold) => value < threshold,
Condition::Between(start, end) => value > start && value < end,
Condition::Equals(expected) => *expected == value,
Condition::GreaterThan(threshold) => value > *threshold,
Condition::LessThan(threshold) => value < *threshold,
Condition::Between(start, end) => value > *start && value < *end,
_ => false,
}
}
}
impl Evaluatable<bool> for Condition<bool> {
fn evaluate(&self, value: bool) -> bool {
match self {
Condition::Equals(expected) => *expected == value,
_ => false,
}
}
@@ -65,13 +74,20 @@ impl FieldEvaluatable for RuleTarget {
.favtime
.try_as_ref()
.map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较
.is_some_and(|fav_time| cond.evaluate(&fav_time)),
.is_some_and(|fav_time| cond.evaluate(fav_time)),
RuleTarget::PubTime(cond) => video
.pubtime
.try_as_ref()
.map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local())
.is_some_and(|pub_time| cond.evaluate(&pub_time)),
.is_some_and(|pub_time| cond.evaluate(pub_time)),
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
RuleTarget::SumVideoLength(cond) => pages
.iter()
.try_fold(0usize, |acc, page| {
page.duration.try_as_ref().map(|d| acc + *d as usize).ok_or(())
})
.is_ok_and(|total_length| cond.evaluate(total_length)),
RuleTarget::MultiUpper(cond) => cond.evaluate(video.staff.as_ref().is_some()),
RuleTarget::Not(inner) => !inner.evaluate(video, pages),
}
}
@@ -86,9 +102,13 @@ impl FieldEvaluatable for RuleTarget {
.tags
.as_ref()
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
RuleTarget::FavTime(cond) => cond.evaluate(&video.favtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PubTime(cond) => cond.evaluate(&video.pubtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::FavTime(cond) => cond.evaluate(video.favtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PubTime(cond) => cond.evaluate(video.pubtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
RuleTarget::SumVideoLength(cond) => {
cond.evaluate(pages.iter().fold(0usize, |acc, page| acc + page.duration as usize))
}
RuleTarget::MultiUpper(cond) => cond.evaluate(video.staff.is_some()),
RuleTarget::Not(inner) => !inner.evaluate_model(video, pages),
}
}

View File

@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::pin::Pin;
use anyhow::{Context, Result, anyhow, bail};
use bili_sync_entity::upper_vec::Upper;
use bili_sync_entity::*;
use futures::stream::FuturesUnordered;
use futures::{Stream, StreamExt, TryStreamExt};
@@ -131,7 +132,7 @@ pub async fn fetch_video_details(
.into_iter()
.map(|video_model| async move {
let _permit = semaphore_ref.acquire().await.context("acquire semaphore failed")?;
let video = Video::new(bili_client, video_model.bvid.clone(), &config.credential);
let video = Video::new(bili_client, video_model.bvid.as_str(), &config.credential);
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
match info {
Err(e) => {
@@ -170,7 +171,7 @@ pub async fn fetch_video_details(
Ok::<_, anyhow::Error>(())
})
.collect::<FuturesUnordered<_>>();
tasks.try_collect::<Vec<_>>().await?;
tasks.try_collect::<()>().await?;
video_source.log_fetch_video_end();
Ok(())
}
@@ -188,13 +189,18 @@ pub async fn download_unprocessed_videos(
let downloader = Downloader::new(bili_client.client.clone());
let cx = DownloadContext::new(bili_client, video_source, template, connection, &downloader, config);
let unhandled_videos_pages = filter_unhandled_video_pages(video_source.filter_expr(), connection).await?;
let mut assigned_upper = HashSet::new();
let mut assigned_upper_ids = HashSet::new();
let tasks = unhandled_videos_pages
.into_iter()
.map(|(video_model, pages_model)| {
let should_download_upper = !assigned_upper.contains(&video_model.upper_id);
assigned_upper.insert(video_model.upper_id);
download_video_pages(video_model, pages_model, &semaphore, should_download_upper, cx)
// 这里按理说是可以直接拿到 assigned_uppers 的但rust 会错误地认为它引用了 local variable
// 导致编译出错,暂时先这样单独提取出一个 owned 的 upper id 列表,再在任务内部筛选
let task_uids = video_model
.uppers()
.map(|u| u.mid)
.filter(|uid| assigned_upper_ids.insert(*uid))
.collect::<Vec<_>>();
download_video_pages(video_model, pages_model, &semaphore, task_uids, cx)
})
.collect::<FuturesUnordered<_>>();
let mut risk_control_related_error = None;
@@ -229,7 +235,7 @@ pub async fn download_video_pages(
video_model: video::Model,
page_models: Vec<page::Model>,
semaphore: &Semaphore,
should_download_upper: bool,
upper_uids: Vec<i64>,
cx: DownloadContext<'_>,
) -> Result<video::ActiveModel> {
let _permit = semaphore.acquire().await.context("acquire semaphore failed")?;
@@ -245,14 +251,26 @@ pub async fn download_video_pages(
)
};
fs::create_dir_all(&base_path).await?;
let base_path = dunce::canonicalize(base_path).context("canonicalize video path failed")?;
let upper_id = video_model.upper_id.to_string();
let base_upper_path = cx
.config
.upper_path
.join(upper_id.chars().next().context("upper_id is empty")?.to_string())
.join(upper_id);
let is_single_page = video_model.single_page.context("single_page is null")?;
let uppers_with_path = video_model
.uppers()
.filter_map(|u| {
if !upper_uids.contains(&u.mid) {
None
} else {
let id_string = u.mid.to_string();
Some((
u,
cx.config
.upper_path
.join(id_string.chars().next()?.to_string())
.join(id_string),
))
}
})
.collect::<Vec<_>>();
// 对于单页视频page 的下载已经足够
// 对于多页视频page 下载仅包含了分集内容,需要额外补上视频的 poster 的 tvshow.nfo
let (res_1, res_2, res_3, res_4, res_5) = tokio::join!(
@@ -273,16 +291,15 @@ pub async fn download_video_pages(
),
// 下载 Up 主头像
fetch_upper_face(
separate_status[2] && should_download_upper && !cx.config.skip_option.no_upper,
&video_model,
base_upper_path.join("folder.jpg"),
separate_status[2] && !cx.config.skip_option.no_upper,
&uppers_with_path,
cx
),
// 生成 Up 主信息的 nfo
generate_upper_nfo(
separate_status[3] && should_download_upper && !cx.config.skip_option.no_upper,
separate_status[3] && !cx.config.skip_option.no_upper,
&video_model,
base_upper_path.join("person.nfo"),
&uppers_with_path,
cx,
),
// 分发并执行分页下载的任务
@@ -589,7 +606,7 @@ pub async fn fetch_page_video(
if !should_run {
return Ok(ExecutionStatus::Skipped);
}
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
let bili_video = Video::new(cx.bili_client, video_model.bvid.as_str(), &cx.config.credential);
let streams = bili_video
.get_page_analyzer(page_info)
.await?
@@ -643,7 +660,7 @@ pub async fn fetch_page_danmaku(
if !should_run {
return Ok(ExecutionStatus::Skipped);
}
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
let bili_video = Video::new(cx.bili_client, video_model.bvid.as_str(), &cx.config.credential);
bili_video
.get_danmaku_writer(page_info)
.await?
@@ -662,7 +679,7 @@ pub async fn fetch_page_subtitle(
if !should_run {
return Ok(ExecutionStatus::Skipped);
}
let bili_video = Video::new(cx.bili_client, video_model.bvid.clone(), &cx.config.credential);
let bili_video = Video::new(cx.bili_client, video_model.bvid.as_str(), &cx.config.credential);
let subtitles = bili_video.get_subtitles(page_info).await?;
let tasks = subtitles
.into_iter()
@@ -671,7 +688,7 @@ pub async fn fetch_page_subtitle(
tokio::fs::write(path, subtitle.body.to_string()).await
})
.collect::<FuturesUnordered<_>>();
tasks.try_collect::<Vec<()>>().await?;
tasks.try_collect::<()>().await?;
Ok(ExecutionStatus::Succeeded)
}
@@ -714,33 +731,48 @@ pub async fn fetch_video_poster(
pub async fn fetch_upper_face(
should_run: bool,
video_model: &video::Model,
upper_face_path: PathBuf,
uppers_with_path: &[(Upper<i64, &str>, PathBuf)],
cx: DownloadContext<'_>,
) -> Result<ExecutionStatus> {
if !should_run {
if !should_run || uppers_with_path.is_empty() {
return Ok(ExecutionStatus::Skipped);
}
cx.downloader
.fetch(
&video_model.upper_face,
&upper_face_path,
&cx.config.concurrent_limit.download,
)
.await?;
let tasks = uppers_with_path
.iter()
.map(|(upper, base_path)| async move {
cx.downloader
.fetch(
upper.face,
&base_path.join("folder.jpg"),
&cx.config.concurrent_limit.download,
)
.await?;
Ok::<(), anyhow::Error>(())
})
.collect::<FuturesUnordered<_>>();
tasks.try_collect::<()>().await?;
Ok(ExecutionStatus::Succeeded)
}
pub async fn generate_upper_nfo(
should_run: bool,
video_model: &video::Model,
nfo_path: PathBuf,
uppers_with_path: &[(Upper<i64, &str>, PathBuf)],
cx: DownloadContext<'_>,
) -> Result<ExecutionStatus> {
if !should_run {
return Ok(ExecutionStatus::Skipped);
}
generate_nfo(NFO::Upper(video_model.to_nfo(cx.config.nfo_time_type)), nfo_path).await?;
let tasks = uppers_with_path
.iter()
.map(|(upper, base_path)| {
generate_nfo(
NFO::Upper((video_model, upper).to_nfo(cx.config.nfo_time_type)),
base_path.join("person.nfo"),
)
})
.collect::<FuturesUnordered<_>>();
tasks.try_collect::<()>().await?;
Ok(ExecutionStatus::Succeeded)
}

View File

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

View File

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

View File

@@ -30,6 +30,8 @@ pub enum RuleTarget {
FavTime(Condition<DateTime>),
PubTime(Condition<DateTime>),
PageCount(Condition<usize>),
SumVideoLength(Condition<usize>),
MultiUpper(Condition<bool>),
Not(Box<RuleTarget>),
}
@@ -63,6 +65,8 @@ impl Display for RuleTarget {
RuleTarget::FavTime(_) => "收藏时间",
RuleTarget::PubTime(_) => "发布时间",
RuleTarget::PageCount(_) => "视频分页数量",
RuleTarget::SumVideoLength(_) => "视频总时长",
RuleTarget::MultiUpper(_) => "联合投稿",
RuleTarget::Not(inner) => {
if depth == 0 {
get_field_name(inner, depth + 1)
@@ -79,14 +83,16 @@ impl Display for RuleTarget {
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
write!(f, "{}不{}", field_name, cond)
}
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::PageCount(cond) | RuleTarget::SumVideoLength(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::MultiUpper(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::Not(_) => write!(f, "格式化失败"),
},
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond),
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
write!(f, "{}{}", field_name, cond)
}
RuleTarget::PageCount(cond) => write!(f, "{}{}", field_name, cond),
RuleTarget::PageCount(cond) | RuleTarget::SumVideoLength(cond) => write!(f, "{}{}", field_name, cond),
RuleTarget::MultiUpper(cond) => write!(f, "{}{}", field_name, cond),
}
}
}

View File

@@ -0,0 +1,48 @@
use std::borrow::Cow;
use sea_orm::FromJsonQueryResult;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Upper<T, S> {
pub mid: T,
pub name: S,
pub face: S,
pub title: Option<S>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
pub struct UpperVec(pub Vec<Upper<i64, String>>);
impl From<Vec<Upper<i64, String>>> for UpperVec {
fn from(value: Vec<Upper<i64, String>>) -> Self {
Self(value)
}
}
impl From<UpperVec> for Vec<Upper<i64, String>> {
fn from(value: UpperVec) -> Self {
value.0
}
}
impl<T: Copy> Upper<T, String> {
pub fn as_ref(&self) -> Upper<T, &str> {
Upper {
mid: self.mid,
name: self.name.as_str(),
face: self.face.as_str(),
title: self.title.as_deref(),
}
}
}
impl<T, S: AsRef<str>> Upper<T, S> {
pub fn role(&self) -> Cow<'_, str> {
if let Some(title) = &self.title {
Cow::Owned(format!("{}{}", self.name.as_ref(), title.as_ref()))
} else {
Cow::Borrowed(self.name.as_ref())
}
}
}

View File

@@ -1,8 +1,10 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
use either::Either;
use sea_orm::entity::prelude::*;
use crate::string_vec::StringVec;
use crate::upper_vec::{Upper, UpperVec};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
#[sea_orm(table_name = "video")]
@@ -16,6 +18,7 @@ pub struct Model {
pub upper_id: i64,
pub upper_name: String,
pub upper_face: String,
pub staff: Option<UpperVec>,
pub name: String,
pub path: String,
pub category: i32,
@@ -33,6 +36,21 @@ pub struct Model {
pub created_at: String,
}
impl Model {
pub fn uppers(&self) -> Either<impl Iterator<Item = Upper<i64, &str>>, impl Iterator<Item = Upper<i64, &str>>> {
if let Some(staff) = self.staff.as_ref() {
Either::Left(staff.0.iter().map(|u| u.as_ref()))
} else {
Either::Right(std::iter::once(Upper::<i64, &str> {
mid: self.upper_id,
name: self.upper_name.as_str(),
face: self.upper_face.as_str(),
title: None,
}))
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::page::Entity")]

View File

@@ -10,6 +10,7 @@ mod m20250613_043257_add_config;
mod m20250712_080013_add_video_created_at_index;
mod m20250903_094454_add_rule_and_should_download;
mod m20251009_123713_add_use_dynamic_api;
mod m20260324_055217_add_staff;
pub struct Migrator;
@@ -27,6 +28,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250712_080013_add_video_created_at_index::Migration),
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
Box::new(m20251009_123713_add_use_dynamic_api::Migration),
Box::new(m20260324_055217_add_staff::Migration),
]
}
}

View File

@@ -0,0 +1,30 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::schema::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Video::Table)
.add_column(text_null(Video::Staff))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(Table::alter().table(Video::Table).drop_column(Video::Staff).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Video {
Table,
Staff,
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "bili-sync-web",
"version": "2.10.4",
"version": "2.11.0",
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.2",

View File

@@ -5,6 +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 * as Select from '$lib/components/ui/select/index.js';
import { PlusIcon, MinusIcon, XIcon } from '@lucide/svelte/icons';
import type { Rule, RuleTarget, Condition } from '$lib/types';
import { onMount } from 'svelte';
@@ -21,7 +22,9 @@
{ value: 'tags', label: '标签' },
{ value: 'favTime', label: '收藏时间' },
{ value: 'pubTime', label: '发布时间' },
{ value: 'pageCount', label: '视频分页数量' }
{ value: 'pageCount', label: '视频分页数量' },
{ value: 'sumVideoLength', label: '视频总时长' },
{ value: 'multiUpper', label: '联合投稿' }
];
const getOperatorOptions = (field: string) => {
@@ -37,6 +40,7 @@
{ value: 'matchesRegex', label: '匹配正则' }
];
case 'pageCount':
case 'sumVideoLength':
return [
{ value: 'equals', label: '等于' },
{ value: 'greaterThan', label: '大于' },
@@ -51,6 +55,8 @@
{ value: 'lessThan', label: '早于' },
{ value: 'between', label: '时间范围' }
];
case 'multiUpper':
return [{ value: 'equals', label: '等于' }];
default:
return [];
}
@@ -80,7 +86,9 @@
}
});
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition {
function convertRuleTargetToLocal(
target: RuleTarget<string | number | boolean | Date>
): LocalCondition {
if (typeof target.rule === 'object' && 'field' in target.rule) {
// 嵌套的 not
const innerCondition = convertRuleTargetToLocal(target.rule);
@@ -93,10 +101,10 @@
let value = '';
let value2 = '';
if (Array.isArray(condition.value)) {
value = String(condition.value[0] || '');
value2 = String(condition.value[1] || '');
value = String(condition.value[0] ?? '');
value2 = String(condition.value[1] ?? '');
} else {
value = String(condition.value || '');
value = String(condition.value ?? '');
}
return {
field: target.field,
@@ -111,8 +119,8 @@
if (localRule.length === 0) return null;
return localRule.map((andGroup) =>
andGroup.conditions.map((condition) => {
let value: string | number | Date | (string | number | Date)[];
if (condition.field === 'pageCount') {
let value: string | number | boolean | Date | (string | number | boolean | Date)[];
if (condition.field === 'pageCount' || condition.field === 'sumVideoLength') {
if (condition.operator === 'between') {
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
} else {
@@ -124,6 +132,8 @@
} else {
value = condition.value;
}
} else if (condition.field === 'multiUpper') {
value = condition.value === 'true';
} else {
if (condition.operator === 'between') {
value = [condition.value, condition.value2 || ''];
@@ -131,12 +141,12 @@
value = condition.value;
}
}
const conditionObj: Condition<string | number | Date> = {
const conditionObj: Condition<string | number | boolean | Date> = {
operator: condition.operator,
value
};
let target: RuleTarget<string | number | Date> = {
let target: RuleTarget<string | number | boolean | Date> = {
field: condition.field,
rule: conditionObj
};
@@ -187,7 +197,7 @@
condition.field = value;
const operators = getOperatorOptions(value);
condition.operator = operators[0]?.value || 'equals';
condition.value = '';
condition.value = value === 'multiUpper' ? 'false' : '';
condition.value2 = '';
} else if (field === 'operator') {
condition.operator = value;
@@ -290,36 +300,43 @@
<!-- 字段选择 -->
<div>
<Label class="text-muted-foreground text-xs">字段</Label>
<select
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
<Select.Root
type="single"
value={condition.field}
onchange={(e) =>
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'field', v)}
>
{#each FIELD_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<Select.Trigger class="w-full">
{FIELD_OPTIONS.find((o) => o.value === condition.field)?.label ??
condition.field}
</Select.Trigger>
<Select.Content>
{#each FIELD_OPTIONS as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- 操作符选择 -->
<div>
<Label class="text-muted-foreground text-xs">操作符</Label>
<select
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
<Select.Root
type="single"
value={condition.operator}
onchange={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'operator',
e.currentTarget.value
)}
onValueChange={(v) =>
updateCondition(groupIndex, conditionIndex, 'operator', v)}
>
{#each getOperatorOptions(condition.field) as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<Select.Trigger class="w-full">
{getOperatorOptions(condition.field).find(
(o) => o.value === condition.operator
)?.label ?? condition.operator}
</Select.Trigger>
<Select.Content>
{#each getOperatorOptions(condition.field) as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
@@ -328,10 +345,11 @@
<Label class="text-muted-foreground text-xs"></Label>
{#if condition.operator === 'between'}
<div class="grid grid-cols-2 gap-2">
{#if condition.field === 'pageCount'}
{#if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
<Input
type="number"
placeholder="最小值"
placeholder={'最小值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value}
oninput={(e) =>
@@ -344,7 +362,8 @@
/>
<Input
type="number"
placeholder="最大值"
placeholder={'最大值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value2 || ''}
oninput={(e) =>
@@ -411,10 +430,11 @@
/>
{/if}
</div>
{:else if condition.field === 'pageCount'}
{:else if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
<Input
type="number"
placeholder="输入数值"
placeholder={'输入数值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value}
oninput={(e) =>
@@ -434,6 +454,20 @@
e.currentTarget.value + ':00'
)}
/>
{:else if condition.field === 'multiUpper'}
<Select.Root
type="single"
value={condition.value}
onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'value', v)}
>
<Select.Trigger class="w-full">
{condition.value === 'true' ? 'true' : 'false'}
</Select.Trigger>
<Select.Content>
<Select.Item value="true" label="true" />
<Select.Item value="false" label="false" />
</Select.Content>
</Select.Root>
{:else}
<Input
type="text"

View File

@@ -210,7 +210,7 @@ export interface RuleTarget<T> {
rule: Condition<T> | RuleTarget<T>;
}
export type AndGroup = RuleTarget<string | number | Date>[];
export type AndGroup = RuleTarget<string | number | boolean | Date>[];
export type Rule = AndGroup[];
export interface VideoSourceDetail {
@@ -308,6 +308,7 @@ export interface WebhookNotifier {
type: 'webhook';
url: string;
template?: string | null;
headers?: Record<string, string> | null;
}
export type Notifier = TelegramNotifier | WebhookNotifier;

View File

@@ -5,8 +5,6 @@
import { toast } from 'svelte-sonner';
import type { Notifier } from '$lib/types';
const jsonExample = '{"text": "您的消息内容"}';
export let notifier: Notifier | null = null;
export let onSave: (notifier: Notifier) => void;
export let onCancel: () => void;
@@ -16,6 +14,7 @@
let chatId = '';
let webhookUrl = '';
let webhookTemplate = '';
let webhookHeaders: { key: string; value: string }[] = [];
// 初始化表单
$: {
@@ -28,6 +27,11 @@
type = 'webhook';
webhookUrl = notifier.url;
webhookTemplate = notifier.template || '';
if (notifier.headers) {
webhookHeaders = Object.entries(notifier.headers).map(([key, value]) => ({ key, value }));
} else {
webhookHeaders = [];
}
}
} else {
type = 'telegram';
@@ -35,11 +39,11 @@
chatId = '';
webhookUrl = '';
webhookTemplate = '';
webhookHeaders = [];
}
}
function handleSave() {
// 验证表单
if (type === 'telegram') {
if (!botToken.trim()) {
toast.error('请输入 Bot Token');
@@ -62,7 +66,6 @@
return;
}
// 简单的 URL 验证
try {
new URL(webhookUrl.trim());
} catch {
@@ -70,10 +73,20 @@
return;
}
const headers: Record<string, string> = {};
for (const { key, value } of webhookHeaders) {
const trimmedKey = key.trim();
const trimmedValue = value.trim();
if (trimmedKey && trimmedValue) {
headers[trimmedKey] = trimmedValue;
}
}
const newNotifier: Notifier = {
type: 'webhook',
url: webhookUrl.trim(),
template: webhookTemplate.trim() || null
template: webhookTemplate.trim() || null,
headers: Object.keys(headers).length > 0 ? headers : null
};
onSave(newNotifier);
}
@@ -111,11 +124,7 @@
{:else if type === 'webhook'}
<div class="space-y-2">
<Label for="webhook-url">Webhook URL</Label>
<Input id="webhook-url" placeholder="https://example.com/webhook" bind:value={webhookUrl} />
<p class="text-muted-foreground text-xs">
接收通知的 Webhook 地址<br />
格式示例:{jsonExample}
</p>
<Input id="webhook-url" placeholder="请输入 Webhook 地址" bind:value={webhookUrl} />
</div>
<div class="space-y-2">
<Label for="webhook-template">模板(可选)</Label>
@@ -127,7 +136,48 @@
></textarea>
<p class="text-muted-foreground text-xs">
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
可用变量:<code class="text-xs">message</code>(通知内容)
可用变量:<code class="text-xs">message</code>(通知内容)<code class="text-xs"
>image_url</code
>(封面图片地址,无图时为 null
</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>自定义请求头(可选)</Label>
<Button
variant="ghost"
size="sm"
onclick={() => (webhookHeaders = [...webhookHeaders, { key: '', value: '' }])}
>
+ 添加请求头
</Button>
</div>
{#each webhookHeaders as header, index (index)}
<div class="flex items-center gap-2">
<Input
placeholder="Header 名称(例如 Authorization"
bind:value={header.key}
class="flex-1"
/>
<Input
placeholder="Header 值"
bind:value={header.value}
class="flex-1"
type={header.key.toLowerCase() === 'authorization' ? 'password' : 'text'}
/>
<Button
variant="ghost"
size="sm"
onclick={() => (webhookHeaders = webhookHeaders.filter((_, i) => i !== index))}
class="h-10 px-2"
>
×
</Button>
</div>
{/each}
<p class="text-muted-foreground text-xs">
添加自定义请求头例如Authorization: Bearer your_token
</p>
</div>
{/if}