Files
2026-04-30 14:34:28 +08:00

424 lines
11 KiB
Python

from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ScheduleConfig(BaseModel):
model_config = ConfigDict(extra='allow')
enabled: bool = True
type: str = 'daily'
dayOfWeek: str = '1'
time: str = '02:00'
cron: str = '0 2 * * *'
class AdvancedStrategyConfig(BaseModel):
metadataFallback: bool = True
downloadAssets: bool = True
replaceLowQualityDuplicates: bool = False
class NotificationConfig(BaseModel):
dingtalkWebhook: str = ''
dingtalkSecret: str = ''
telegramBotToken: str = ''
telegramChatId: str = ''
emailSmtp: str = ''
emailUser: str = ''
emailPass: str = ''
emailTo: str = ''
class MetadataConfig(BaseModel):
acoustidUrl: str = 'https://api.acoustid.org/v2'
acoustidClientKey: str = ''
musicbrainz: str = 'https://musicbrainz.org/ws/2/'
netease: str = 'http://localhost:3000'
qq: str = 'http://localhost:3300'
spotifyUrl: str = 'https://api.spotify.com/v1'
spotifyClientId: str = ''
spotifySecret: str = ''
discogsUrl: str = 'https://api.discogs.com'
discogsToken: str = ''
lastfmUrl: str = 'https://ws.audioscrobbler.com/2.0/'
lastfmKey: str = ''
geniusUrl: str = 'https://api.genius.com'
geniusToken: str = ''
class ConfigPayload(BaseModel):
input: str = Field(default='')
output: str = Field(default='')
trash: str = Field(default='')
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
advancedStrategy: AdvancedStrategyConfig = Field(default_factory=AdvancedStrategyConfig)
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
metadata: MetadataConfig = Field(default_factory=MetadataConfig)
@model_validator(mode='after')
def validate_config(self):
required_paths = {
'input': self.input,
'output': self.output,
'trash': self.trash
}
for field_name, value in required_paths.items():
if not isinstance(value, str):
raise ValueError(f'{field_name} must be a string')
if not isinstance(self.schedule.cron, str) or not self.schedule.cron.strip():
raise ValueError('schedule.cron must not be empty')
return self
class MetadataStatusPayload(BaseModel):
status: Literal['checking', 'online', 'warning', 'offline', 'none', 'idle']
latencyMs: int | None = None
message: str
class MetadataStatusResponse(BaseModel):
metadataStatus: dict[str, MetadataStatusPayload]
class ConfigSaveResponse(BaseModel):
config: ConfigPayload
metadataStatus: dict[str, MetadataStatusPayload]
class TaskSummaryPayload(BaseModel):
task_id: str
task_type: Literal['ingest', 'repair']
trigger_source: str
source_task_id: str | None = None
status: Literal['pending', 'running', 'completed', 'failed']
current_stage: str
stage_states: dict[str, Literal['pending', 'running', 'completed', 'skipped', 'failed']]
stats: dict[str, dict[str, int]]
repair_plan_json: dict[str, Any] | None = None
error_message: str | None = None
started_at: str
completed_at: str | None = None
updated_at: str
class TaskRunResponse(BaseModel):
task_id: str
status: Literal['pending', 'running', 'completed', 'failed']
current_stage: str
stage_states: dict[str, Literal['pending', 'running', 'completed', 'skipped', 'failed']]
started_at: str
class TaskCurrentResponse(BaseModel):
task: TaskSummaryPayload | None = None
class TaskDetailResponse(BaseModel):
task: TaskSummaryPayload
class TaskHistoryListItemPayload(BaseModel):
task_id: str
started_at: str
status: Literal['completed', 'failed']
total_items: int
success_items: int
exception_items: int
report_status: Literal['success', 'warning']
class TaskHistoryListResponse(BaseModel):
items: list[TaskHistoryListItemPayload]
page: int
page_size: int
total: int
class TaskItemPayload(BaseModel):
id: int
task_id: str
parent_item_id: int | None = None
is_active: bool
original_path: str
current_file_path: str
relative_path: str
filename: str
extension: str
size_bytes: int | None = None
modified_at: str | None = None
local_cover: str | None = None
local_lyric: str | None = None
scan_status: Literal['queued', 'skipped_locked', 'invalid']
scan_reason: Literal[
'recent_mtime',
'permission_denied',
'stat_failed',
'path_disappeared',
'unreadable'
] | None = None
scan_message: str | None = None
preprocess_status: str
preprocess_reason: str | None = None
preprocess_message: str | None = None
audio_props_json: dict[str, Any] | None = None
original_tags_json: dict[str, Any] | None = None
preprocess_artifacts_json: dict[str, Any] | None = None
acoustic_fingerprint: str | None = None
fingerprint_duration_seconds: float | None = None
match_status: Literal[
'pending',
'running',
'matched',
'matched_fallback',
'low_score',
'not_found',
'failed'
]
match_reason: str | None = None
match_message: str | None = None
match_source: str | None = None
match_confidence: float | None = None
match_is_authoritative: bool
matched_metadata_json: dict[str, Any] | None = None
match_candidates_json: list[dict[str, Any]] | None = None
match_enrichment_json: dict[str, Any] | None = None
dedupe_status: Literal[
'pending',
'running',
'unique',
'duplicate_trashed',
'duplicate_replaced',
'failed'
]
dedupe_reason: str | None = None
dedupe_message: str | None = None
dedupe_group_key: str | None = None
duplicate_of_path: str | None = None
duplicate_of_item_id: int | None = None
dedupe_decision_json: dict[str, Any] | None = None
organize_status: Literal['pending', 'running', 'organized', 'trashed', 'failed']
organize_reason: str | None = None
organize_message: str | None = None
library_relative_path: str | None = None
library_file_path: str | None = None
trash_file_path: str | None = None
organize_decision_json: dict[str, Any] | None = None
created_at: str
updated_at: str
class TaskItemsPageResponse(BaseModel):
items: list[TaskItemPayload]
page: int
page_size: int
total: int
class TaskLogPayload(BaseModel):
id: int
task_id: str
stage: str
level: Literal['info', 'warning', 'error', 'success']
event_type: str
message: str
payload: dict | None = None
created_at: str
class TaskLogsPageResponse(BaseModel):
logs: list[TaskLogPayload]
page: int
page_size: int
total: int
class ExceptionSummaryPayload(BaseModel):
total: int
counts_by_type: dict[str, int]
scanned_at: str
class ExceptionListItemPayload(BaseModel):
exception_id: int
task_id: str
task_started_at: str
exception_type: Literal[
'missing_tags',
'duplicates',
'match_failed',
'low_score',
'convert_failed',
'organize_failed'
]
exception_stage: Literal['preprocess', 'match', 'dedupe', 'organize']
exception_reason_code: str | None = None
exception_message: str | None = None
captured_at: str
filename: str
relative_path: str
original_path: str
current_file_path: str
trash_file_path: str | None = None
audio_props_json: dict[str, Any] | None = None
original_tags_json: dict[str, Any] | None = None
matched_metadata_json: dict[str, Any] | None = None
duplicate_of_path: str | None = None
dedupe_decision_json: dict[str, Any] | None = None
library_relative_path: str | None = None
library_file_path: str | None = None
match_source: str | None = None
match_confidence: float | None = None
preview_available: bool
available_actions: list[str]
exception_resolution_status: Literal['open', 'planned', 'resolved', 'ignored']
exception_resolution_json: dict[str, Any] | None = None
workflow_state: Literal[
'open',
'candidate_selected',
'ready_to_ingest',
'ingested',
'ignored',
'deleted'
]
raw_metadata: dict[str, Any]
metadata_draft: dict[str, Any]
effective_metadata: dict[str, Any]
normalization_strategy: str | None = None
album_artist_reason: str | None = None
compilation: int = 0
can_ingest: bool
pending_ingest: bool
display_title: str
display_reason: str
type_label: str
class ExceptionDetailPayload(ExceptionListItemPayload):
preprocess_artifacts_json: dict[str, Any] | None = None
match_candidates_json: list[dict[str, Any]] | None = None
match_enrichment_json: dict[str, Any] | None = None
organize_decision_json: dict[str, Any] | None = None
class ExceptionListResponse(BaseModel):
items: list[ExceptionListItemPayload]
page: int
page_size: int
total: int
class MetadataPatchPayload(BaseModel):
title: str | None = None
artist: str | None = None
album: str | None = None
album_artist: str | None = None
track_number: int | None = None
disc_number: int | None = None
year: int | None = None
lyrics: str | None = None
class RepairPreviewRequest(BaseModel):
exception_ids: list[int]
action: str
params: dict[str, Any] = Field(default_factory=dict)
class RepairExecuteRequest(RepairPreviewRequest):
pass
class PlannedOperationPayload(BaseModel):
type: Literal['move', 'replace', 'rename', 'metadata_write', 'trash', 'status_update']
source_path: str | None = None
target_path: str | None = None
description: str
class RepairPreviewItemPayload(BaseModel):
exception_id: int
filename: str
exception_type: str
planned_operations: list[PlannedOperationPayload]
warnings: list[str] = Field(default_factory=list)
class RepairPreviewResponse(BaseModel):
action: str
items: list[RepairPreviewItemPayload]
requires_confirmation: bool
planned_operations: list[PlannedOperationPayload]
conflict_summary: dict[str, Any]
risk_level: Literal['low', 'medium', 'high']
warnings: list[str]
class RepairTaskRunResponse(BaseModel):
repair_task_id: str
status: Literal['pending', 'running', 'completed', 'failed']
current_stage: str
stage_states: dict[str, Literal['pending', 'running', 'completed', 'skipped', 'failed']]
started_at: str
class RepairTaskCurrentResponse(BaseModel):
task: TaskSummaryPayload | None = None
class LibraryIngestProvenancePayload(BaseModel):
task_id: str
organized_at: str
match_source: str | None = None
match_confidence: float | None = None
dedupe_status: str | None = None
class LibrarySummaryPayload(BaseModel):
total_tracks: int
total_albums: int
total_artists: int
suspected_duplicates: int
scanned_at: str
class LibraryTrackPayload(BaseModel):
track_id: str
library_relative_path: str
library_file_path: str
filename: str
title: str | None = None
artist: str | None = None
album: str | None = None
album_artist: str | None = None
track_number: int | None = None
disc_number: int | None = None
year: int | None = None
duration_seconds: float | int | None = None
format: str | None = None
codec: str | None = None
bitrate: int | None = None
sample_rate: int | None = None
bit_depth: int | None = None
channels: int | None = None
size_bytes: int | None = None
modified_at: str | None = None
ingest_provenance: LibraryIngestProvenancePayload | None = None
class LibraryTracksPageResponse(BaseModel):
items: list[LibraryTrackPayload]
page: int
page_size: int
total: int
class LibraryMoveToExceptionResponse(BaseModel):
exception_id: int
library_relative_path: str
trash_file_path: str
message: str