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