feat: 支持设置快捷订阅的路径默认值 (#502)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::bilibili::CollectionType;
|
||||
@@ -91,3 +91,8 @@ pub struct UpdateVideoSourceRequest {
|
||||
pub rule: Option<Rule>,
|
||||
pub use_dynamic_api: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct DefaultPathRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::routing::{get, post, put};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
@@ -14,19 +14,25 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, Transac
|
||||
use crate::adapter::_ActiveModel;
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::request::{
|
||||
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
|
||||
DefaultPathRequest, InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest,
|
||||
UpdateVideoSourceRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||
use crate::config::{PathSafeTemplate, TEMPLATE};
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
|
||||
pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/video-sources", get(get_video_sources))
|
||||
.route("/video-sources/details", get(get_video_sources_details))
|
||||
.route(
|
||||
"/video-sources/{type}/default-path",
|
||||
get(get_video_sources_default_path),
|
||||
) // 仅用于前端获取默认路径
|
||||
.route("/video-sources/{type}/{id}", put(update_video_source))
|
||||
.route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source))
|
||||
.route("/video-sources/favorites", post(insert_favorite))
|
||||
@@ -154,6 +160,20 @@ pub async fn get_video_sources_details(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_video_sources_default_path(
|
||||
Path(source_type): Path<String>,
|
||||
Query(params): Query<DefaultPathRequest>,
|
||||
) -> Result<ApiResponse<String>, ApiError> {
|
||||
let template_name = match source_type.as_str() {
|
||||
"favorites" => "favorite_default_path",
|
||||
"collections" => "collection_default_path",
|
||||
"submissions" => "submission_default_path",
|
||||
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
|
||||
};
|
||||
let (template, params) = (TEMPLATE.load(), serde_json::to_value(params)?);
|
||||
Ok(ApiResponse::ok(template.path_safe_render(template_name, ¶ms)?))
|
||||
}
|
||||
|
||||
/// 更新视频来源
|
||||
pub async fn update_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
|
||||
@@ -8,7 +8,9 @@ use validator::Validate;
|
||||
|
||||
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
||||
use crate::config::default::{default_auth_token, default_bind_address, default_time_format};
|
||||
use crate::config::item::{ConcurrentLimit, NFOTimeType, SkipOption};
|
||||
use crate::config::item::{
|
||||
ConcurrentLimit, NFOTimeType, SkipOption, default_collection_path, default_favorite_path, default_submission_path,
|
||||
};
|
||||
use crate::utils::model::{load_db_config, save_db_config};
|
||||
|
||||
pub static CONFIG_DIR: LazyLock<PathBuf> =
|
||||
@@ -25,6 +27,12 @@ pub struct Config {
|
||||
pub skip_option: SkipOption,
|
||||
pub video_name: String,
|
||||
pub page_name: String,
|
||||
#[serde(default = "default_favorite_path")]
|
||||
pub favorite_default_path: String,
|
||||
#[serde(default = "default_collection_path")]
|
||||
pub collection_default_path: String,
|
||||
#[serde(default = "default_submission_path")]
|
||||
pub submission_default_path: String,
|
||||
pub interval: u64,
|
||||
pub upper_path: PathBuf,
|
||||
pub nfo_time_type: NFOTimeType,
|
||||
@@ -98,6 +106,9 @@ impl Default for Config {
|
||||
skip_option: SkipOption::default(),
|
||||
video_name: "{{title}}".to_owned(),
|
||||
page_name: "{{bvid}}".to_owned(),
|
||||
favorite_default_path: default_favorite_path(),
|
||||
collection_default_path: default_collection_path(),
|
||||
submission_default_path: default_submission_path(),
|
||||
interval: 1200,
|
||||
upper_path: CONFIG_DIR.join("upper_face"),
|
||||
nfo_time_type: NFOTimeType::FavTime,
|
||||
|
||||
@@ -12,8 +12,11 @@ pub static TEMPLATE: LazyLock<VersionedCache<handlebars::Handlebars<'static>>> =
|
||||
fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars.register_helper("truncate", Box::new(truncate));
|
||||
handlebars.path_safe_register("video", config.video_name.to_owned())?;
|
||||
handlebars.path_safe_register("page", config.page_name.to_owned())?;
|
||||
handlebars.path_safe_register("video", config.video_name.clone())?;
|
||||
handlebars.path_safe_register("page", config.page_name.clone())?;
|
||||
handlebars.path_safe_register("favorite_default_path", config.favorite_default_path.clone())?;
|
||||
handlebars.path_safe_register("collection_default_path", config.collection_default_path.clone())?;
|
||||
handlebars.path_safe_register("submission_default_path", config.submission_default_path.clone())?;
|
||||
Ok(handlebars)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,3 +85,15 @@ impl PathSafeTemplate for handlebars::Handlebars<'_> {
|
||||
Ok(filenamify(&self.render(name, data)?).replace("__SEP__", std::path::MAIN_SEPARATOR_STR))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_favorite_path() -> String {
|
||||
"收藏夹/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
pub fn default_collection_path() -> String {
|
||||
"合集/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
pub fn default_submission_path() -> String {
|
||||
"投稿/{{name}}".to_owned()
|
||||
}
|
||||
|
||||
@@ -221,6 +221,10 @@ class ApiClient {
|
||||
return this.post<boolean>(`/video-sources/${type}/${id}/evaluate`, null);
|
||||
}
|
||||
|
||||
async getDefaultPath(type: string, name: string): Promise<ApiResponse<string>> {
|
||||
return this.get<string>(`/video-sources/${type}/default-path`, { name });
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ApiResponse<Config>> {
|
||||
return this.get<Config>('/config');
|
||||
}
|
||||
@@ -268,6 +272,7 @@ const api = {
|
||||
apiClient.updateVideoSource(type, id, request),
|
||||
evaluateVideoSourceRules: (type: string, id: number) =>
|
||||
apiClient.evaluateVideoSourceRules(type, id),
|
||||
getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name),
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||
getDashboard: () => apiClient.getDashboard(),
|
||||
|
||||
@@ -20,18 +20,18 @@
|
||||
| FavoriteWithSubscriptionStatus
|
||||
| CollectionWithSubscriptionStatus
|
||||
| UpperWithSubscriptionStatus;
|
||||
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
|
||||
export let type: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||||
export let onSubscriptionSuccess: (() => void) | null = null;
|
||||
|
||||
let dialogOpen = false;
|
||||
|
||||
function getIcon() {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return HeartIcon;
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return FolderIcon;
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return UserIcon;
|
||||
default:
|
||||
return VideoIcon;
|
||||
@@ -40,11 +40,11 @@
|
||||
|
||||
function getTypeLabel() {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return '收藏夹';
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return '合集';
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return 'UP 主';
|
||||
default:
|
||||
return '';
|
||||
@@ -53,11 +53,11 @@
|
||||
|
||||
function getTitle(): string {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return (item as FavoriteWithSubscriptionStatus).title;
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return (item as CollectionWithSubscriptionStatus).title;
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return (item as UpperWithSubscriptionStatus).uname;
|
||||
default:
|
||||
return '';
|
||||
@@ -66,12 +66,10 @@
|
||||
|
||||
function getSubtitle(): string {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return `uid: ${(item as FavoriteWithSubscriptionStatus).mid}`;
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return `uid: ${(item as CollectionWithSubscriptionStatus).mid}`;
|
||||
case 'upper':
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -79,7 +77,7 @@
|
||||
|
||||
function getDescription(): string {
|
||||
switch (type) {
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return (item as UpperWithSubscriptionStatus).sign || '';
|
||||
default:
|
||||
return '';
|
||||
@@ -88,9 +86,9 @@
|
||||
|
||||
function isDisabled(): boolean {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return (item as CollectionWithSubscriptionStatus).invalid;
|
||||
case 'upper': {
|
||||
case 'submissions': {
|
||||
return (item as UpperWithSubscriptionStatus).invalid;
|
||||
}
|
||||
default:
|
||||
@@ -100,9 +98,9 @@
|
||||
|
||||
function getDisabledReason(): string {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return '已失效';
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return '账号已注销';
|
||||
default:
|
||||
return '';
|
||||
@@ -111,7 +109,7 @@
|
||||
|
||||
function getCount(): number | null {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return (item as FavoriteWithSubscriptionStatus).media_count;
|
||||
default:
|
||||
return null;
|
||||
@@ -124,7 +122,7 @@
|
||||
|
||||
function getAvatarUrl(): string {
|
||||
switch (type) {
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return (item as UpperWithSubscriptionStatus).face;
|
||||
default:
|
||||
return '';
|
||||
@@ -171,7 +169,7 @@
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if avatarUrl && type === 'upper'}
|
||||
{#if avatarUrl && type === 'submissions'}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={title}
|
||||
@@ -265,5 +263,3 @@
|
||||
|
||||
<!-- 订阅对话框 -->
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||
<!-- 订阅对话框 -->
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -10,7 +11,6 @@
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '$lib/components/ui/sheet/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import api from '$lib/api';
|
||||
import type {
|
||||
FavoriteWithSubscriptionStatus,
|
||||
@@ -22,47 +22,40 @@
|
||||
ApiError
|
||||
} from '$lib/types';
|
||||
|
||||
export let open = false;
|
||||
export let item:
|
||||
| FavoriteWithSubscriptionStatus
|
||||
| CollectionWithSubscriptionStatus
|
||||
| UpperWithSubscriptionStatus
|
||||
| null = null;
|
||||
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
|
||||
export let onSuccess: (() => void) | null = null;
|
||||
interface Props {
|
||||
open: boolean;
|
||||
item:
|
||||
| FavoriteWithSubscriptionStatus
|
||||
| CollectionWithSubscriptionStatus
|
||||
| UpperWithSubscriptionStatus
|
||||
| null;
|
||||
type: 'favorites' | 'collections' | 'submissions';
|
||||
onSuccess: (() => void) | null;
|
||||
}
|
||||
|
||||
let customPath = '';
|
||||
let loading = false;
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
type = 'favorites',
|
||||
onSuccess = null
|
||||
}: Props = $props();
|
||||
|
||||
let customPath = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// 根据类型和 item 生成默认路径
|
||||
function generateDefaultPath(): string {
|
||||
if (!item) return '';
|
||||
|
||||
switch (type) {
|
||||
case 'favorite': {
|
||||
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||
return `收藏夹/${favorite.title}`;
|
||||
}
|
||||
case 'collection': {
|
||||
const collection = item as CollectionWithSubscriptionStatus;
|
||||
return `合集/${collection.title}`;
|
||||
}
|
||||
case 'upper': {
|
||||
const upper = item as UpperWithSubscriptionStatus;
|
||||
return `UP 主/${upper.uname}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
async function generateDefaultPath(): Promise<string> {
|
||||
if (!itemTitle) return '';
|
||||
return (await api.getDefaultPath(type, itemTitle)).data;
|
||||
}
|
||||
|
||||
function getTypeLabel(): string {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return '收藏夹';
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return '合集';
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return 'UP 主';
|
||||
default:
|
||||
return '';
|
||||
@@ -73,11 +66,11 @@
|
||||
if (!item) return '';
|
||||
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
case 'favorites':
|
||||
return (item as FavoriteWithSubscriptionStatus).title;
|
||||
case 'collection':
|
||||
case 'collections':
|
||||
return (item as CollectionWithSubscriptionStatus).title;
|
||||
case 'upper':
|
||||
case 'submissions':
|
||||
return (item as UpperWithSubscriptionStatus).uname;
|
||||
default:
|
||||
return '';
|
||||
@@ -92,7 +85,7 @@
|
||||
let response;
|
||||
|
||||
switch (type) {
|
||||
case 'favorite': {
|
||||
case 'favorites': {
|
||||
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||
const request: InsertFavoriteRequest = {
|
||||
fid: favorite.fid,
|
||||
@@ -101,7 +94,7 @@
|
||||
response = await api.insertFavorite(request);
|
||||
break;
|
||||
}
|
||||
case 'collection': {
|
||||
case 'collections': {
|
||||
const collection = item as CollectionWithSubscriptionStatus;
|
||||
const request: InsertCollectionRequest = {
|
||||
sid: collection.sid,
|
||||
@@ -111,7 +104,7 @@
|
||||
response = await api.insertCollection(request);
|
||||
break;
|
||||
}
|
||||
case 'upper': {
|
||||
case 'submissions': {
|
||||
const upper = item as UpperWithSubscriptionStatus;
|
||||
const request: InsertSubmissionRequest = {
|
||||
upper_id: upper.mid,
|
||||
@@ -145,10 +138,20 @@
|
||||
open = false;
|
||||
}
|
||||
|
||||
// 当对话框打开时重置 path
|
||||
$: if (open && item) {
|
||||
customPath = generateDefaultPath();
|
||||
}
|
||||
$effect(() => {
|
||||
if (open && item) {
|
||||
generateDefaultPath()
|
||||
.then((path) => {
|
||||
customPath = path;
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('获取默认路径失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
customPath = '';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const typeLabel = getTypeLabel();
|
||||
const itemTitle = getItemTitle();
|
||||
@@ -173,14 +176,14 @@
|
||||
<span class="text-muted-foreground text-sm font-medium">{typeLabel}名称:</span>
|
||||
<span class="text-sm">{itemTitle}</span>
|
||||
</div>
|
||||
{#if type === 'favorite'}
|
||||
{#if type === 'favorites'}
|
||||
{@const favorite = item as FavoriteWithSubscriptionStatus}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm font-medium">视频数量:</span>
|
||||
<span class="text-sm">{favorite.media_count} 个</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if type === 'upper'}
|
||||
{#if type === 'submissions'}
|
||||
{@const upper = item as UpperWithSubscriptionStatus}
|
||||
{#if upper.sign}
|
||||
<div class="flex items-start gap-2">
|
||||
|
||||
@@ -281,6 +281,9 @@ export interface Config {
|
||||
skip_option: SkipOption;
|
||||
video_name: string;
|
||||
page_name: string;
|
||||
favorite_default_path: string;
|
||||
collection_default_path: string;
|
||||
submission_default_path: string;
|
||||
interval: number;
|
||||
upper_path: string;
|
||||
nfo_time_type: string;
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<div style="max-width: 450px; width: 100%;">
|
||||
<SubscriptionCard
|
||||
item={collection}
|
||||
type="collection"
|
||||
type="collections"
|
||||
onSubscriptionSuccess={handleSubscriptionSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div style="max-width: 450px; width: 100%;">
|
||||
<SubscriptionCard
|
||||
item={favorite}
|
||||
type="favorite"
|
||||
type="favorites"
|
||||
onSubscriptionSuccess={handleSubscriptionSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<div style="max-width: 450px; width: 100%;">
|
||||
<SubscriptionCard
|
||||
item={upper}
|
||||
type="upper"
|
||||
type="submissions"
|
||||
onSubscriptionSuccess={handleSubscriptionSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -191,8 +191,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="backend-auth-token">后端 API 认证Token</Label>
|
||||
@@ -209,6 +207,23 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="favorite-default-path">收藏夹快捷订阅路径模板</Label>
|
||||
<Input id="favorite-default-path" bind:value={formData.favorite_default_path} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="collection-default-path">合集快捷订阅路径模板</Label>
|
||||
<Input id="collection-default-path" bind:value={formData.collection_default_path} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="submission-default-path">UP 主投稿快捷订阅路径模板</Label>
|
||||
<Input id="submission-default-path" bind:value={formData.submission_default_path} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="cdn-sorting" bind:checked={formData.cdn_sorting} />
|
||||
|
||||
Reference in New Issue
Block a user