Add MusicWorkshop application
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
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
|
||||
Reference in New Issue
Block a user