diff --git a/.github/workflows/build-binary.yaml b/.github/workflows/build-binary.yaml index ad8e20f..c9baeb0 100644 --- a/.github/workflows/build-binary.yaml +++ b/.github/workflows/build-binary.yaml @@ -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: | diff --git a/.github/workflows/build-doc.yaml b/.github/workflows/build-doc.yaml index 09dbf06..3ffb407 100644 --- a/.github/workflows/build-doc.yaml +++ b/.github/workflows/build-doc.yaml @@ -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 的最新文档变更: \ No newline at end of file + commit_message: 部署来自 main 的最新文档变更: diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index a554f13..b859988 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -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') }} diff --git a/.github/workflows/release-build.yaml b/.github/workflows/release-build.yaml index c4ae7fb..a1c48ba 100644 --- a/.github/workflows/release-build.yaml +++ b/.github/workflows/release-build.yaml @@ -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 }} diff --git a/Cargo.lock b/Cargo.lock index 0418da4..5aebf2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index a5bd5f4..a3ab309 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"] resolver = "2" [workspace.package] -version = "2.10.4" +version = "2.11.0" authors = ["amtoaer "] 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" diff --git a/Dockerfile b/Dockerfile index 0c2911b..30a5434 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" ] - diff --git a/crates/bili_sync/src/bilibili/analyzer.rs b/crates/bili_sync/src/bilibili/analyzer.rs index cbbb0e5..59f3f85 100644 --- a/crates/bili_sync/src/bilibili/analyzer.rs +++ b/crates/bili_sync/src/bilibili/analyzer.rs @@ -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 diff --git a/crates/bili_sync/src/bilibili/favorite_list.rs b/crates/bili_sync/src/bilibili/favorite_list.rs index 1e253cf..f8f43d8 100644 --- a/crates/bili_sync/src/bilibili/favorite_list.rs +++ b/crates/bili_sync/src/bilibili/favorite_list.rs @@ -16,12 +16,6 @@ pub struct FavoriteListInfo { pub title: String, } -#[derive(Debug, serde::Deserialize)] -pub struct Upper { - 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 { diff --git a/crates/bili_sync/src/bilibili/mod.rs b/crates/bili_sync/src/bilibili/mod.rs index 41eb7a4..e4028cc 100644 --- a/crates/bili_sync/src/bilibili/mod.rs +++ b/crates/bili_sync/src/bilibili/mod.rs @@ -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, + upper: Upper, + #[serde(default)] + staff: Option>>, #[serde(with = "ts_seconds")] ctime: DateTime, #[serde(rename = "pubdate", with = "ts_seconds")] @@ -152,7 +154,7 @@ pub enum VideoInfo { bvid: String, intro: String, cover: String, - upper: Upper, + upper: Upper, #[serde(with = "ts_seconds")] ctime: DateTime, #[serde(with = "ts_seconds")] @@ -170,7 +172,7 @@ pub enum VideoInfo { #[serde(rename = "pic")] cover: String, #[serde(rename = "owner")] - upper: Upper, + upper: Upper, #[serde(with = "ts_seconds")] ctime: DateTime, #[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!(); diff --git a/crates/bili_sync/src/bilibili/submission.rs b/crates/bili_sync/src/bilibili/submission.rs index 5740650..ea73d1f 100644 --- a/crates/bili_sync/src/bilibili/submission.rs +++ b/crates/bili_sync/src/bilibili/submission.rs @@ -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> { + pub async fn get_info(&self) -> Result> { let mut res = self .client .request( diff --git a/crates/bili_sync/src/bilibili/video.rs b/crates/bili_sync/src/bilibili/video.rs index b1a67c0..8617971 100644 --- a/crates/bili_sync/src/bilibili/video.rs +++ b/crates/bili_sync/src/bilibili/video.rs @@ -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> { - 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() diff --git a/crates/bili_sync/src/downloader.rs b/crates/bili_sync/src/downloader.rs index 2760a95..d59c75a 100644 --- a/crates/bili_sync/src/downloader.rs +++ b/crates/bili_sync/src/downloader.rs @@ -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 diff --git a/crates/bili_sync/src/notifier/mod.rs b/crates/bili_sync/src/notifier/mod.rs index da2759f..45b15ee 100644 --- a/crates/bili_sync/src/notifier/mod.rs +++ b/crates/bili_sync/src/notifier/mod.rs @@ -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, + #[serde(default)] + headers: Option>, #[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(()) diff --git a/crates/bili_sync/src/utils/convert.rs b/crates/bili_sync/src/utils/convert.rs index 9803faa..a4ea696 100644 --- a/crates/bili_sync/src/utils/convert.rs +++ b/crates/bili_sync/src/utils/convert.rs @@ -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!(), diff --git a/crates/bili_sync/src/utils/nfo.rs b/crates/bili_sync/src/utils/nfo.rs index 92053c8..3dd509a 100644 --- a/crates/bili_sync/src/utils/nfo.rs +++ b/crates/bili_sync/src/utils/nfo.rs @@ -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>, pub premiered: NaiveDateTime, pub tags: Option>, } @@ -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>, pub premiered: NaiveDateTime, pub tags: Option>, } @@ -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 { "#, ); 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) { 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, } } } diff --git a/crates/bili_sync/src/utils/rule.rs b/crates/bili_sync/src/utils/rule.rs index c0f4cc4..3bda70e 100644 --- a/crates/bili_sync/src/utils/rule.rs +++ b/crates/bili_sync/src/utils/rule.rs @@ -37,13 +37,22 @@ impl Evaluatable for Condition { } } -impl Evaluatable<&NaiveDateTime> for Condition { - fn evaluate(&self, value: &NaiveDateTime) -> bool { +impl Evaluatable for Condition { + 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 for Condition { + 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), } } diff --git a/crates/bili_sync/src/workflow.rs b/crates/bili_sync/src/workflow.rs index 672be9a..af3bef3 100644 --- a/crates/bili_sync/src/workflow.rs +++ b/crates/bili_sync/src/workflow.rs @@ -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::>(); - tasks.try_collect::>().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::>(); + download_video_pages(video_model, pages_model, &semaphore, task_uids, cx) }) .collect::>(); let mut risk_control_related_error = None; @@ -229,7 +235,7 @@ pub async fn download_video_pages( video_model: video::Model, page_models: Vec, semaphore: &Semaphore, - should_download_upper: bool, + upper_uids: Vec, cx: DownloadContext<'_>, ) -> Result { 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::>(); // 对于单页视频,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::>(); - tasks.try_collect::>().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, PathBuf)], cx: DownloadContext<'_>, ) -> Result { - 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::>(); + 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, PathBuf)], cx: DownloadContext<'_>, ) -> Result { 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::>(); + tasks.try_collect::<()>().await?; Ok(ExecutionStatus::Succeeded) } diff --git a/crates/bili_sync_entity/Cargo.toml b/crates/bili_sync_entity/Cargo.toml index 51edb4e..5f499ab 100644 --- a/crates/bili_sync_entity/Cargo.toml +++ b/crates/bili_sync_entity/Cargo.toml @@ -6,6 +6,7 @@ publish = { workspace = true } [dependencies] derivative = { workspace = true } +either = { workspace = true } regex = { workspace = true } sea-orm = { workspace = true } serde = { workspace = true } diff --git a/crates/bili_sync_entity/src/custom_type/mod.rs b/crates/bili_sync_entity/src/custom_type/mod.rs index d87b844..a441f76 100644 --- a/crates/bili_sync_entity/src/custom_type/mod.rs +++ b/crates/bili_sync_entity/src/custom_type/mod.rs @@ -1,2 +1,3 @@ pub mod rule; pub mod string_vec; +pub mod upper_vec; diff --git a/crates/bili_sync_entity/src/custom_type/rule.rs b/crates/bili_sync_entity/src/custom_type/rule.rs index eee745c..2d4cb16 100644 --- a/crates/bili_sync_entity/src/custom_type/rule.rs +++ b/crates/bili_sync_entity/src/custom_type/rule.rs @@ -30,6 +30,8 @@ pub enum RuleTarget { FavTime(Condition), PubTime(Condition), PageCount(Condition), + SumVideoLength(Condition), + MultiUpper(Condition), Not(Box), } @@ -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), } } } diff --git a/crates/bili_sync_entity/src/custom_type/upper_vec.rs b/crates/bili_sync_entity/src/custom_type/upper_vec.rs new file mode 100644 index 0000000..6970700 --- /dev/null +++ b/crates/bili_sync_entity/src/custom_type/upper_vec.rs @@ -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 { + pub mid: T, + pub name: S, + pub face: S, + pub title: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)] +pub struct UpperVec(pub Vec>); + +impl From>> for UpperVec { + fn from(value: Vec>) -> Self { + Self(value) + } +} + +impl From for Vec> { + fn from(value: UpperVec) -> Self { + value.0 + } +} + +impl Upper { + pub fn as_ref(&self) -> Upper { + Upper { + mid: self.mid, + name: self.name.as_str(), + face: self.face.as_str(), + title: self.title.as_deref(), + } + } +} + +impl> Upper { + 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()) + } + } +} diff --git a/crates/bili_sync_entity/src/entities/video.rs b/crates/bili_sync_entity/src/entities/video.rs index 9c51b79..a0f8b6d 100644 --- a/crates/bili_sync_entity/src/entities/video.rs +++ b/crates/bili_sync_entity/src/entities/video.rs @@ -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, 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>> { + 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:: { + 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")] diff --git a/crates/bili_sync_migration/src/lib.rs b/crates/bili_sync_migration/src/lib.rs index d3e34a5..44c3a9c 100644 --- a/crates/bili_sync_migration/src/lib.rs +++ b/crates/bili_sync_migration/src/lib.rs @@ -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), ] } } diff --git a/crates/bili_sync_migration/src/m20260324_055217_add_staff.rs b/crates/bili_sync_migration/src/m20260324_055217_add_staff.rs new file mode 100644 index 0000000..c4a638e --- /dev/null +++ b/crates/bili_sync_migration/src/m20260324_055217_add_staff.rs @@ -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, +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ff3c41e..878d3ce 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -21,7 +21,7 @@ export default defineConfig({ nav: [ { text: "主页", link: "/" }, { - text: "v2.10.4", + text: "v2.11.0", items: [ { text: "程序更新", diff --git a/docs/introduction.md b/docs/introduction.md index 9b67ecd..9346a3c 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,7 +1,7 @@ # bili-sync 是什么? > [!TIP] -> 当前最新程序版本为 v2.10.4,文档将始终与最新程序版本保持一致。 +> 当前最新程序版本为 v2.11.0,文档将始终与最新程序版本保持一致。 bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index deb561f..95e32ea 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.94.0" components = ["clippy"] diff --git a/web/package.json b/web/package.json index 86fe22b..2d41bf5 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/components/rule-editor.svelte b/web/src/lib/components/rule-editor.svelte index 17521b6..c5c477b 100644 --- a/web/src/lib/components/rule-editor.svelte +++ b/web/src/lib/components/rule-editor.svelte @@ -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): LocalCondition { + function convertRuleTargetToLocal( + target: RuleTarget + ): 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 = { + const conditionObj: Condition = { operator: condition.operator, value }; - let target: RuleTarget = { + let target: RuleTarget = { 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 @@
- + + {FIELD_OPTIONS.find((o) => o.value === condition.field)?.label ?? + condition.field} + + + {#each FIELD_OPTIONS as option (option.value)} + + {/each} + +
- + + {getOperatorOptions(condition.field).find( + (o) => o.value === condition.operator + )?.label ?? condition.operator} + + + {#each getOperatorOptions(condition.field) as option (option.value)} + + {/each} + +
@@ -328,10 +345,11 @@ {#if condition.operator === 'between'}
- {#if condition.field === 'pageCount'} + {#if condition.field === 'pageCount' || condition.field === 'sumVideoLength'} @@ -344,7 +362,8 @@ /> @@ -411,10 +430,11 @@ /> {/if}
- {:else if condition.field === 'pageCount'} + {:else if condition.field === 'pageCount' || condition.field === 'sumVideoLength'} @@ -434,6 +454,20 @@ e.currentTarget.value + ':00' )} /> + {:else if condition.field === 'multiUpper'} + updateCondition(groupIndex, conditionIndex, 'value', v)} + > + + {condition.value === 'true' ? 'true' : 'false'} + + + + + + {:else} { rule: Condition | RuleTarget; } -export type AndGroup = RuleTarget[]; +export type AndGroup = RuleTarget[]; export type Rule = AndGroup[]; export interface VideoSourceDetail { @@ -308,6 +308,7 @@ export interface WebhookNotifier { type: 'webhook'; url: string; template?: string | null; + headers?: Record | null; } export type Notifier = TelegramNotifier | WebhookNotifier; diff --git a/web/src/routes/settings/NotifierDialog.svelte b/web/src/routes/settings/NotifierDialog.svelte index 22ea5d9..a0cfb66 100644 --- a/web/src/routes/settings/NotifierDialog.svelte +++ b/web/src/routes/settings/NotifierDialog.svelte @@ -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 = {}; + 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'}
- -

- 接收通知的 Webhook 地址
- 格式示例:{jsonExample} -

+
@@ -127,7 +136,48 @@ >

用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。
- 可用变量:message(通知内容) + 可用变量:message(通知内容)、image_url(封面图片地址,无图时为 null) +

+
+ +
+
+ + +
+ {#each webhookHeaders as header, index (index)} +
+ + + +
+ {/each} +

+ 添加自定义请求头,例如:Authorization: Bearer your_token

{/if}