feat: unify default source paths and fix windows drive path rendering
Some checks failed
Build Main Binary / build-binary (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled

This commit is contained in:
2026-02-10 16:46:07 +08:00
parent 8c04dc6564
commit 628d0f9ce7
11 changed files with 153 additions and 97 deletions

View File

@@ -99,8 +99,6 @@ pub struct FollowedUppersRequest {
#[derive(Deserialize, Validate)]
pub struct InsertFavoriteRequest {
pub fid: i64,
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
}
#[derive(Deserialize, Validate)]
@@ -109,21 +107,16 @@ pub struct InsertCollectionRequest {
pub mid: i64,
#[serde(default)]
pub collection_type: CollectionType,
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
}
#[derive(Deserialize, Validate)]
pub struct InsertSubmissionRequest {
pub upper_id: i64,
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
}
#[derive(Deserialize, Validate)]
#[serde(rename_all = "camelCase")]
pub struct UpdateVideoSourceRequest {
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
pub enabled: bool,
pub rule: Option<Rule>,

View File

@@ -10,6 +10,7 @@ use bili_sync_migration::Expr;
use sea_orm::ActiveValue::Set;
use sea_orm::entity::prelude::*;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTrait, TransactionTrait};
use serde_json::json;
use crate::adapter::{_ActiveModel, VideoSource as _, VideoSourceEnum};
use crate::api::error::InnerApiError;
@@ -138,10 +139,13 @@ pub async fn get_video_sources_details(
.all(&db)
)?;
if watch_later.is_empty() {
let path = TEMPLATE
.read()
.path_safe_render("watch_later_default_path", &json!({ "name": "稍后再看" }))?;
watch_later.push(VideoSourceDetail {
id: 1,
name: "稍后再看".to_string(),
path: String::new(),
path,
rule: None,
rule_display: None,
use_dynamic_api: None,
@@ -171,6 +175,7 @@ pub async fn get_video_sources_default_path(
"favorites" => "favorite_default_path",
"collections" => "collection_default_path",
"submissions" => "submission_default_path",
"watch_later" => "watch_later_default_path",
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
};
let template = TEMPLATE.read();
@@ -185,7 +190,22 @@ pub async fn update_video_source(
Extension(db): Extension<DatabaseConnection>,
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
) -> Result<ApiResponse<UpdateVideoSourceResponse>, ApiError> {
if source_type != "watch_later" && crate::utils::validation::validate_path(&request.path).is_err() {
return Err(
InnerApiError::BadRequest("path: Validation error: path must be a non-empty absolute path".to_string())
.into(),
);
}
let rule_display = request.rule.as_ref().map(|rule| rule.to_string());
let watch_later_path = if source_type == "watch_later" {
Some(
TEMPLATE
.read()
.path_safe_render("watch_later_default_path", &json!({ "name": "稍后再看" }))?,
)
} else {
None
};
let active_model = match source_type.as_str() {
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
let mut active_model: collection::ActiveModel = model.into();
@@ -217,7 +237,11 @@ pub async fn update_video_source(
Some(model) => {
// 如果有记录,使用 id 对应的记录更新
let mut active_model: watch_later::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.path = Set(
watch_later_path
.clone()
.expect("watch_later path should always be initialized"),
);
active_model.enabled = Set(request.enabled);
active_model.rule = Set(request.rule);
Some(_ActiveModel::WatchLater(active_model))
@@ -228,7 +252,9 @@ pub async fn update_video_source(
} else {
// 如果没有记录且 id 为 1插入一个新的稍后再看记录
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
path: Set(request.path),
path: Set(
watch_later_path.expect("watch_later path should always be initialized"),
),
enabled: Set(request.enabled),
rule: Set(request.rule),
..Default::default()
@@ -368,10 +394,13 @@ pub async fn insert_favorite(
let credential = &VersionedConfig::get().read().credential;
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string(), credential);
let favorite_info = favorite.get_info().await?;
let path = TEMPLATE
.read()
.path_safe_render("favorite_default_path", &json!({ "name": favorite_info.title }))?;
favorite::Entity::insert(favorite::ActiveModel {
f_id: Set(favorite_info.id),
name: Set(favorite_info.title.clone()),
path: Set(request.path),
path: Set(path),
enabled: Set(false),
..Default::default()
})
@@ -397,12 +426,15 @@ pub async fn insert_collection(
credential,
);
let collection_info = collection.get_info().await?;
let path = TEMPLATE
.read()
.path_safe_render("collection_default_path", &json!({ "name": collection_info.name }))?;
collection::Entity::insert(collection::ActiveModel {
s_id: Set(collection_info.sid),
m_id: Set(collection_info.mid),
r#type: Set(collection_info.collection_type.into()),
name: Set(collection_info.name.clone()),
path: Set(request.path),
path: Set(path),
enabled: Set(false),
..Default::default()
})
@@ -418,13 +450,18 @@ pub async fn insert_submission(
Extension(bili_client): Extension<Arc<BiliClient>>,
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
let credential = &VersionedConfig::get().read().credential;
let config = VersionedConfig::get().snapshot();
let credential = &config.credential;
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string(), credential);
let upper = submission.get_info().await?;
let upper_name = upper.name;
let path = TEMPLATE
.read()
.path_safe_render("submission_default_path", &json!({ "name": upper_name }))?;
submission::Entity::insert(submission::ActiveModel {
upper_id: Set(upper.mid.parse()?),
upper_name: Set(upper.name),
path: Set(request.path),
upper_name: Set(upper_name),
path: Set(path),
enabled: Set(false),
..Default::default()
})

View File

@@ -11,7 +11,7 @@ use crate::bilibili::{Credential, DanmakuOption, FilterOption};
use crate::config::args::ARGS;
use crate::config::default::{
default_auth_token, default_bind_address, default_collection_path, default_favorite_path, default_submission_path,
default_time_format,
default_time_format, default_watch_later_path,
};
use crate::config::item::{ConcurrentLimit, NFOTimeType, SkipOption, Trigger};
use crate::notifier::Notifier;
@@ -43,6 +43,8 @@ pub struct Config {
pub collection_default_path: String,
#[serde(default = "default_submission_path")]
pub submission_default_path: String,
#[serde(default = "default_watch_later_path")]
pub watch_later_default_path: String,
pub interval: Trigger,
pub upper_path: PathBuf,
pub nfo_time_type: NFOTimeType,
@@ -130,6 +132,7 @@ impl Default for Config {
favorite_default_path: default_favorite_path(),
collection_default_path: default_collection_path(),
submission_default_path: default_submission_path(),
watch_later_default_path: default_watch_later_path(),
interval: Trigger::default(),
upper_path: CONFIG_DIR.join("upper_face"),
nfo_time_type: NFOTimeType::FavTime,

View File

@@ -28,3 +28,7 @@ pub fn default_collection_path() -> String {
pub fn default_submission_path() -> String {
"投稿/{{name}}".to_owned()
}
pub fn default_watch_later_path() -> String {
"稍后再看".to_owned()
}

View File

@@ -18,6 +18,7 @@ fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
handlebars.path_safe_register("favorite_default_path", config.favorite_default_path.clone())?;
handlebars.path_safe_register("collection_default_path", config.collection_default_path.clone())?;
handlebars.path_safe_register("submission_default_path", config.submission_default_path.clone())?;
handlebars.path_safe_register("watch_later_default_path", config.watch_later_default_path.clone())?;
if let Some(notifiers) = &config.notifiers {
for notifier in notifiers.iter() {
if let Notifier::Webhook { url, template, .. } = notifier {
@@ -79,6 +80,13 @@ mod tests {
.unwrap(),
r"关注_永雏塔菲\\test\\a"
);
let _ = template.path_safe_register("test_drive_prefix", r"W:\\投稿\\{{title}}");
assert_eq!(
template
.path_safe_render("test_drive_prefix", &json!({"title": "林:杏仁/Almond"}))
.unwrap(),
r"W:\\投稿\\林_杏仁_Almond"
);
}
assert_eq!(
template

View File

@@ -95,6 +95,19 @@ impl PathSafeTemplate for handlebars::Handlebars<'_> {
}
fn path_safe_render(&self, name: &'static str, data: &serde_json::Value) -> Result<String> {
Ok(filenamify(&self.render(name, data)?).replace("__SEP__", std::path::MAIN_SEPARATOR_STR))
let rendered = self.render(name, data)?;
#[cfg(windows)]
let path = {
let bytes = rendered.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
let (drive_prefix, rest) = rendered.split_at(2);
format!("{drive_prefix}{}", filenamify(rest))
} else {
filenamify(&rendered)
}
};
#[cfg(not(windows))]
let path = filenamify(&rendered);
Ok(path.replace("__SEP__", std::path::MAIN_SEPARATOR_STR))
}
}

23
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "bili-sync-web",
"version": "2.9.4",
"version": "2.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bili-sync-web",
"version": "2.9.4",
"version": "2.10.3",
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4"
@@ -33,6 +33,7 @@
"layerchart": "^2.0.0-next.43",
"mode-watcher": "^1.1.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.1",
@@ -4201,6 +4202,24 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-organize-imports": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz",
"integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"prettier": ">=2.0",
"typescript": ">=2.9",
"vue-tsc": "^2.1.0 || 3"
},
"peerDependenciesMeta": {
"vue-tsc": {
"optional": true
}
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz",

View File

@@ -74,7 +74,7 @@
}
async function handleSubscribe() {
if (!item || !customPath.trim()) return;
if (!item) return;
loading = true;
try {
@@ -83,8 +83,7 @@
switch (item.type) {
case 'favorite': {
const request: InsertFavoriteRequest = {
fid: item.fid,
path: customPath.trim()
fid: item.fid
};
response = await api.insertFavorite(request);
break;
@@ -92,16 +91,14 @@
case 'collection': {
const request: InsertCollectionRequest = {
sid: item.sid,
mid: item.mid,
path: customPath.trim()
mid: item.mid
};
response = await api.insertCollection(request);
break;
}
case 'upper': {
const request: InsertSubmissionRequest = {
upper_id: item.mid,
path: customPath.trim()
upper_id: item.mid
};
response = await api.insertSubmission(request);
break;
@@ -109,8 +106,9 @@
}
if (response && response.data) {
const successPath = customPath.trim() || '自动生成路径';
toast.success('订阅成功', {
description: `已订阅${getTypeLabel()}${getItemTitle()}」到路径「${customPath.trim()}」`
description: `已订阅${getTypeLabel()}${getItemTitle()}」到路径「${successPath}」`
});
open = false;
if (onSuccess) {
@@ -156,7 +154,13 @@
<SheetTitle class="text-lg">订阅{typeLabel}</SheetTitle>
<SheetDescription class="text-muted-foreground space-y-1 text-sm">
<div>即将订阅{typeLabel}{itemTitle}</div>
<div>请手动编辑本地保存路径:</div>
{#if item?.type === 'upper'}
<div>投稿下载路径将使用“设置-基本设置”中的默认地址。</div>
{:else if item?.type === 'favorite'}
<div>收藏夹下载路径将使用“设置-基本设置-收藏夹快捷订阅路径模板”自动生成。</div>
{:else}
<div>合集下载路径将使用“设置-基本设置-合集快捷订阅路径模板”自动生成。</div>
{/if}
</SheetDescription>
</SheetHeader>
@@ -183,29 +187,12 @@
</div>
</div>
<!-- 路径输入 -->
<div class="space-y-3">
<Label for="custom-path" class="text-sm font-medium">
本地保存路径 <span class="text-destructive">*</span>
</Label>
<Input
id="custom-path"
type="text"
placeholder="请输入保存路径,例如:/home/我的收藏"
bind:value={customPath}
disabled={loading}
class="w-full"
/>
<div class="text-muted-foreground space-y-3 text-xs">
<p>路径将作为文件夹名称,用于存放下载的视频文件。</p>
<div>
<p class="mb-2 font-medium">路径示例:</p>
<div class="space-y-1 pl-4">
<div class="font-mono text-xs">Mac/Linux: /home/downloads/我的收藏</div>
<div class="font-mono text-xs">Windows: C:\Downloads\我的收藏</div>
</div>
</div>
</div>
<Label class="text-sm font-medium">本地保存路径</Label>
<Input id="custom-path" type="text" bind:value={customPath} disabled class="w-full" />
<p class="text-muted-foreground text-xs">
该路径由“设置-基本设置”中的对应模板自动生成。
</p>
</div>
</div>
</div>
@@ -221,7 +208,7 @@
</Button>
<Button
onclick={handleSubscribe}
disabled={loading || !customPath.trim()}
disabled={loading}
class="flex-1 cursor-pointer"
>
{loading ? '订阅中...' : '确认订阅'}

View File

@@ -173,19 +173,16 @@ export interface UppersResponse {
export interface InsertFavoriteRequest {
fid: number;
path: string;
}
export interface InsertCollectionRequest {
sid: number;
mid: number;
collection_type?: number;
path: string;
}
export interface InsertSubmissionRequest {
upper_id: number;
path: string;
}
export interface Condition<T> {
@@ -315,6 +312,7 @@ export interface Config {
favorite_default_path: string;
collection_default_path: string;
submission_default_path: string;
watch_later_default_path: string;
interval: Trigger;
upper_path: string;
nfo_time_type: string;

View File

@@ -347,9 +347,16 @@
<Input id="collection-default-path" bind:value={formData.collection_default_path} />
</div>
<div class="space-y-2">
<Label for="submission-default-path">UP 主投稿快捷订阅路径模板</Label>
<Label for="submission-default-path">UP 主投稿默认下载路径</Label>
<Input id="submission-default-path" bind:value={formData.submission_default_path} />
</div>
<div class="space-y-2">
<Label for="watch-later-default-path">稍后再看默认下载路径</Label>
<Input
id="watch-later-default-path"
bind:value={formData.watch_later_default_path}
/>
</div>
</div>
<Separator />

View File

@@ -67,9 +67,9 @@
};
// 表单数据
let favoriteForm = { fid: '', path: '' };
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
let submissionForm = { upper_id: '', path: '' };
let favoriteForm = { fid: '' };
let collectionForm = { sid: '', mid: '', collection_type: '2' }; // 默认为合集
let submissionForm = { upper_id: '' };
const TAB_CONFIG = {
favorites: { label: '收藏夹', icon: HeartIcon },
@@ -218,9 +218,9 @@
function openAddDialog(type: 'favorites' | 'collections' | 'submissions') {
addDialogType = type;
// 重置表单
favoriteForm = { fid: '', path: '' };
collectionForm = { sid: '', mid: '', collection_type: '2', path: '' };
submissionForm = { upper_id: '', path: '' };
favoriteForm = { fid: '' };
collectionForm = { sid: '', mid: '', collection_type: '2' };
submissionForm = { upper_id: '' };
showAddDialog = true;
}
@@ -230,35 +230,32 @@
try {
switch (addDialogType) {
case 'favorites':
if (!favoriteForm.fid || !favoriteForm.path.trim()) {
if (!favoriteForm.fid) {
toast.error('请填写完整的收藏夹信息');
return;
}
await api.insertFavorite({
fid: parseInt(favoriteForm.fid),
path: favoriteForm.path
fid: parseInt(favoriteForm.fid)
});
break;
case 'collections':
if (!collectionForm.sid || !collectionForm.mid || !collectionForm.path.trim()) {
if (!collectionForm.sid || !collectionForm.mid) {
toast.error('请填写完整的合集信息');
return;
}
await api.insertCollection({
sid: parseInt(collectionForm.sid),
mid: parseInt(collectionForm.mid),
collection_type: parseInt(collectionForm.collection_type),
path: collectionForm.path
collection_type: parseInt(collectionForm.collection_type)
});
break;
case 'submissions':
if (!submissionForm.upper_id || !submissionForm.path.trim()) {
if (!submissionForm.upper_id) {
toast.error('请填写完整的用户投稿信息');
return;
}
await api.insertSubmission({
upper_id: parseInt(submissionForm.upper_id),
path: submissionForm.path
upper_id: parseInt(submissionForm.upper_id)
});
break;
}
@@ -484,8 +481,14 @@
type="text"
bind:value={editForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
disabled={editingType === 'watch_later'}
class="mt-2"
/>
{#if editingType === 'watch_later'}
<p class="text-muted-foreground mt-2 text-xs">
稍后再看固定使用“设置 - 基本设置 - 稍后再看默认下载路径”自动生成。
</p>
{/if}
</div>
<!-- 启用状态 -->
@@ -664,33 +667,17 @@
</div>
</div>
{/if}
<div class="mt-4">
<Label for="path" class="text-sm font-medium">下载路径</Label>
{#if addDialogType === 'favorites'}
<Input
id="path"
type="text"
bind:value={favoriteForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-1"
/>
{:else if addDialogType === 'collections'}
<Input
id="path"
type="text"
bind:value={collectionForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-1"
/>
{:else}
<Input
id="path"
type="text"
bind:value={submissionForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-1"
/>
{/if}
<div class="mt-4 space-y-1.5">
<Label class="text-sm font-medium">下载路径</Label>
<p class="text-muted-foreground text-xs">
{#if addDialogType === 'favorites'}
收藏夹会固定使用“设置 - 基本设置 - 收藏夹快捷订阅路径模板”自动生成。
{:else if addDialogType === 'collections'}
合集会固定使用“设置 - 基本设置 - 合集快捷订阅路径模板”自动生成。
{:else}
用户投稿会固定使用“设置 - 基本设置 - UP 主投稿默认下载路径”自动生成。
{/if}
</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">