424 lines
11 KiB
Python
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
|