Merge remote-tracking branch 'upstream/main'
# 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:
29
.github/workflows/build-binary.yaml
vendored
29
.github/workflows/build-binary.yaml
vendored
@@ -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: |
|
||||
|
||||
12
.github/workflows/build-doc.yaml
vendored
12
.github/workflows/build-doc.yaml
vendored
@@ -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 的最新文档变更:
|
||||
|
||||
10
.github/workflows/pr-check.yaml
vendored
10
.github/workflows/pr-check.yaml
vendored
@@ -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') }}
|
||||
|
||||
23
.github/workflows/release-build.yaml
vendored
23
.github/workflows/release-build.yaml
vendored
@@ -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
7
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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!(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
derivative = { workspace = true }
|
||||
either = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod rule;
|
||||
pub mod string_vec;
|
||||
pub mod upper_vec;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/bili_sync_entity/src/custom_type/upper_vec.rs
Normal file
48
crates/bili_sync_entity/src/custom_type/upper_vec.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
30
crates/bili_sync_migration/src/m20260324_055217_add_staff.rs
Normal file
30
crates/bili_sync_migration/src/m20260324_055217_add_staff.rs
Normal 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,
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "主页", link: "/" },
|
||||
{
|
||||
text: "v2.10.4",
|
||||
text: "v2.11.0",
|
||||
items: [
|
||||
{
|
||||
text: "程序更新",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.10.4,文档将始终与最新程序版本保持一致。
|
||||
> 当前最新程序版本为 v2.11.0,文档将始终与最新程序版本保持一致。
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
channel = "1.94.0"
|
||||
components = ["clippy"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user