karaoke-gen 0.75.54__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of karaoke-gen might be problematic. Click here for more details.
- karaoke_gen/__init__.py +38 -0
- karaoke_gen/audio_fetcher.py +1614 -0
- karaoke_gen/audio_processor.py +790 -0
- karaoke_gen/config.py +83 -0
- karaoke_gen/file_handler.py +387 -0
- karaoke_gen/instrumental_review/__init__.py +45 -0
- karaoke_gen/instrumental_review/analyzer.py +408 -0
- karaoke_gen/instrumental_review/editor.py +322 -0
- karaoke_gen/instrumental_review/models.py +171 -0
- karaoke_gen/instrumental_review/server.py +475 -0
- karaoke_gen/instrumental_review/static/index.html +1529 -0
- karaoke_gen/instrumental_review/waveform.py +409 -0
- karaoke_gen/karaoke_finalise/__init__.py +1 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
- karaoke_gen/karaoke_gen.py +1026 -0
- karaoke_gen/lyrics_processor.py +474 -0
- karaoke_gen/metadata.py +160 -0
- karaoke_gen/pipeline/__init__.py +87 -0
- karaoke_gen/pipeline/base.py +215 -0
- karaoke_gen/pipeline/context.py +230 -0
- karaoke_gen/pipeline/executors/__init__.py +21 -0
- karaoke_gen/pipeline/executors/local.py +159 -0
- karaoke_gen/pipeline/executors/remote.py +257 -0
- karaoke_gen/pipeline/stages/__init__.py +27 -0
- karaoke_gen/pipeline/stages/finalize.py +202 -0
- karaoke_gen/pipeline/stages/render.py +165 -0
- karaoke_gen/pipeline/stages/screens.py +139 -0
- karaoke_gen/pipeline/stages/separation.py +191 -0
- karaoke_gen/pipeline/stages/transcription.py +191 -0
- karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_gen/style_loader.py +531 -0
- karaoke_gen/utils/__init__.py +18 -0
- karaoke_gen/utils/bulk_cli.py +492 -0
- karaoke_gen/utils/cli_args.py +432 -0
- karaoke_gen/utils/gen_cli.py +978 -0
- karaoke_gen/utils/remote_cli.py +3268 -0
- karaoke_gen/video_background_processor.py +351 -0
- karaoke_gen/video_generator.py +424 -0
- karaoke_gen-0.75.54.dist-info/METADATA +718 -0
- karaoke_gen-0.75.54.dist-info/RECORD +287 -0
- karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
- karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
- karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
- lyrics_transcriber/__init__.py +10 -0
- lyrics_transcriber/cli/__init__.py +0 -0
- lyrics_transcriber/cli/cli_main.py +285 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/config.py +50 -0
- lyrics_transcriber/core/controller.py +594 -0
- lyrics_transcriber/correction/__init__.py +0 -0
- lyrics_transcriber/correction/agentic/__init__.py +9 -0
- lyrics_transcriber/correction/agentic/adapter.py +71 -0
- lyrics_transcriber/correction/agentic/agent.py +313 -0
- lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
- lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
- lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
- lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
- lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
- lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
- lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
- lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
- lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
- lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
- lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
- lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
- lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
- lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
- lyrics_transcriber/correction/agentic/models/enums.py +38 -0
- lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
- lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
- lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
- lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
- lyrics_transcriber/correction/agentic/models/utils.py +19 -0
- lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
- lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
- lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
- lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
- lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
- lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
- lyrics_transcriber/correction/agentic/providers/base.py +36 -0
- lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
- lyrics_transcriber/correction/agentic/providers/config.py +73 -0
- lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
- lyrics_transcriber/correction/agentic/providers/health.py +28 -0
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
- lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
- lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
- lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
- lyrics_transcriber/correction/agentic/router.py +35 -0
- lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
- lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
- lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
- lyrics_transcriber/correction/anchor_sequence.py +919 -0
- lyrics_transcriber/correction/corrector.py +760 -0
- lyrics_transcriber/correction/feedback/__init__.py +2 -0
- lyrics_transcriber/correction/feedback/schemas.py +107 -0
- lyrics_transcriber/correction/feedback/store.py +236 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +52 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
- lyrics_transcriber/correction/handlers/llm.py +293 -0
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
- lyrics_transcriber/correction/handlers/repeat.py +88 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
- lyrics_transcriber/correction/handlers/word_operations.py +187 -0
- lyrics_transcriber/correction/operations.py +352 -0
- lyrics_transcriber/correction/phrase_analyzer.py +435 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/frontend/.gitignore +23 -0
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
- lyrics_transcriber/frontend/.yarnrc.yml +3 -0
- lyrics_transcriber/frontend/README.md +50 -0
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/__init__.py +25 -0
- lyrics_transcriber/frontend/eslint.config.js +28 -0
- lyrics_transcriber/frontend/index.html +18 -0
- lyrics_transcriber/frontend/package.json +42 -0
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/public/favicon.ico +0 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +214 -0
- lyrics_transcriber/frontend/src/api.ts +254 -0
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
- lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
- lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
- lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
- lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
- lyrics_transcriber/frontend/src/main.tsx +17 -0
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
- lyrics_transcriber/frontend/src/types.js +2 -0
- lyrics_transcriber/frontend/src/types.ts +199 -0
- lyrics_transcriber/frontend/src/validation.ts +132 -0
- lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
- lyrics_transcriber/frontend/tsconfig.app.json +26 -0
- lyrics_transcriber/frontend/tsconfig.json +25 -0
- lyrics_transcriber/frontend/tsconfig.node.json +23 -0
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
- lyrics_transcriber/frontend/update_version.js +11 -0
- lyrics_transcriber/frontend/vite.config.d.ts +2 -0
- lyrics_transcriber/frontend/vite.config.js +10 -0
- lyrics_transcriber/frontend/vite.config.ts +11 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +18 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/yarn.lock +3752 -0
- lyrics_transcriber/lyrics/__init__.py +0 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
- lyrics_transcriber/lyrics/file_provider.py +95 -0
- lyrics_transcriber/lyrics/genius.py +384 -0
- lyrics_transcriber/lyrics/lrclib.py +231 -0
- lyrics_transcriber/lyrics/musixmatch.py +156 -0
- lyrics_transcriber/lyrics/spotify.py +290 -0
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/ass/ass.py +2088 -0
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +180 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +265 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +619 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/countdown_processor.py +306 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +257 -0
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +96 -0
- lyrics_transcriber/output/segment_resizer.py +431 -0
- lyrics_transcriber/output/subtitles.py +397 -0
- lyrics_transcriber/output/video.py +544 -0
- lyrics_transcriber/review/__init__.py +0 -0
- lyrics_transcriber/review/server.py +676 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +225 -0
- lyrics_transcriber/transcribers/__init__.py +0 -0
- lyrics_transcriber/transcribers/audioshake.py +379 -0
- lyrics_transcriber/transcribers/base_transcriber.py +157 -0
- lyrics_transcriber/transcribers/whisper.py +330 -0
- lyrics_transcriber/types.py +650 -0
- lyrics_transcriber/utils/__init__.py +0 -0
- lyrics_transcriber/utils/word_utils.py +27 -0
|
@@ -0,0 +1,1614 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audio Fetcher module - abstraction layer for fetching audio files.
|
|
3
|
+
|
|
4
|
+
This module provides a clean interface for searching and downloading audio files
|
|
5
|
+
using flacfetch, replacing the previous direct yt-dlp usage.
|
|
6
|
+
|
|
7
|
+
Supports two modes:
|
|
8
|
+
1. Local mode: Uses flacfetch library directly (requires torrent client, etc.)
|
|
9
|
+
2. Remote mode: Uses a remote flacfetch HTTP API server when FLACFETCH_API_URL
|
|
10
|
+
and FLACFETCH_API_KEY environment variables are set.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
22
|
+
from dataclasses import dataclass, asdict, field
|
|
23
|
+
from typing import List, Optional, Dict, Any
|
|
24
|
+
|
|
25
|
+
# Optional import for remote fetcher
|
|
26
|
+
try:
|
|
27
|
+
import httpx
|
|
28
|
+
HTTPX_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
HTTPX_AVAILABLE = False
|
|
31
|
+
|
|
32
|
+
# Global flag to track if user requested cancellation via Ctrl+C
|
|
33
|
+
_interrupt_requested = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AudioSearchResult:
|
|
38
|
+
"""Represents a single search result for audio.
|
|
39
|
+
|
|
40
|
+
Used by both local CLI and cloud backend. Supports serialization
|
|
41
|
+
for Firestore storage via to_dict()/from_dict().
|
|
42
|
+
|
|
43
|
+
For rich display, this class can serialize the full flacfetch Release
|
|
44
|
+
data so remote CLIs can use flacfetch's shared display functions.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
title: str
|
|
48
|
+
artist: str
|
|
49
|
+
url: str
|
|
50
|
+
provider: str
|
|
51
|
+
duration: Optional[int] = None # Duration in seconds
|
|
52
|
+
quality: Optional[str] = None # e.g., "FLAC", "320kbps", etc.
|
|
53
|
+
source_id: Optional[str] = None # Unique ID from the source
|
|
54
|
+
index: int = 0 # Index in the results list (for API selection)
|
|
55
|
+
seeders: Optional[int] = None # Number of seeders (for torrent sources)
|
|
56
|
+
target_file: Optional[str] = None # Target filename in the release
|
|
57
|
+
# Raw result object from the provider (for download) - not serialized
|
|
58
|
+
raw_result: Optional[object] = field(default=None, repr=False)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
61
|
+
"""Convert to dict for JSON/Firestore serialization.
|
|
62
|
+
|
|
63
|
+
Includes full flacfetch Release data if available, enabling
|
|
64
|
+
remote CLIs to use flacfetch's shared display functions.
|
|
65
|
+
"""
|
|
66
|
+
result = {
|
|
67
|
+
"title": self.title,
|
|
68
|
+
"artist": self.artist,
|
|
69
|
+
"url": self.url,
|
|
70
|
+
"provider": self.provider,
|
|
71
|
+
"duration": self.duration,
|
|
72
|
+
"quality": self.quality,
|
|
73
|
+
"source_id": self.source_id,
|
|
74
|
+
"index": self.index,
|
|
75
|
+
"seeders": self.seeders,
|
|
76
|
+
"target_file": self.target_file,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# If we have a raw_result (flacfetch Release or dict), include its full data
|
|
80
|
+
# This enables rich display on the remote CLI
|
|
81
|
+
# raw_result can be either:
|
|
82
|
+
# - A dict (from remote flacfetch API)
|
|
83
|
+
# - A Release object (from local flacfetch)
|
|
84
|
+
if self.raw_result:
|
|
85
|
+
if isinstance(self.raw_result, dict):
|
|
86
|
+
# Remote flacfetch API returns dicts directly
|
|
87
|
+
release_dict = self.raw_result
|
|
88
|
+
else:
|
|
89
|
+
# Local flacfetch returns Release objects
|
|
90
|
+
try:
|
|
91
|
+
release_dict = self.raw_result.to_dict()
|
|
92
|
+
except AttributeError:
|
|
93
|
+
release_dict = {} # raw_result doesn't have to_dict() method
|
|
94
|
+
|
|
95
|
+
# Merge Release fields into result (they may override basic fields)
|
|
96
|
+
for key in ['year', 'label', 'edition_info', 'release_type', 'channel',
|
|
97
|
+
'view_count', 'size_bytes', 'target_file_size', 'track_pattern',
|
|
98
|
+
'match_score', 'formatted_size', 'formatted_duration',
|
|
99
|
+
'formatted_views', 'is_lossless', 'quality_str']:
|
|
100
|
+
if key in release_dict:
|
|
101
|
+
result[key] = release_dict[key]
|
|
102
|
+
|
|
103
|
+
# Handle quality dict - remote API uses 'quality_data', local uses 'quality'
|
|
104
|
+
if 'quality_data' in release_dict:
|
|
105
|
+
result['quality_data'] = release_dict['quality_data']
|
|
106
|
+
elif 'quality' in release_dict and isinstance(release_dict['quality'], dict):
|
|
107
|
+
result['quality_data'] = release_dict['quality']
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AudioSearchResult":
|
|
113
|
+
"""Create from dict (e.g., from Firestore)."""
|
|
114
|
+
return cls(
|
|
115
|
+
title=data.get("title", ""),
|
|
116
|
+
artist=data.get("artist", ""),
|
|
117
|
+
url=data.get("url", ""),
|
|
118
|
+
provider=data.get("provider", "Unknown"),
|
|
119
|
+
duration=data.get("duration"),
|
|
120
|
+
quality=data.get("quality"),
|
|
121
|
+
source_id=data.get("source_id"),
|
|
122
|
+
index=data.get("index", 0),
|
|
123
|
+
seeders=data.get("seeders"),
|
|
124
|
+
target_file=data.get("target_file"),
|
|
125
|
+
raw_result=None, # Not stored in serialized form
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class AudioFetchResult:
|
|
131
|
+
"""Result of an audio fetch operation.
|
|
132
|
+
|
|
133
|
+
Used by both local CLI and cloud backend. Supports serialization
|
|
134
|
+
for Firestore storage via to_dict()/from_dict().
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
filepath: str
|
|
138
|
+
artist: str
|
|
139
|
+
title: str
|
|
140
|
+
provider: str
|
|
141
|
+
duration: Optional[int] = None
|
|
142
|
+
quality: Optional[str] = None
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
145
|
+
"""Convert to dict for JSON/Firestore serialization."""
|
|
146
|
+
return asdict(self)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AudioFetchResult":
|
|
150
|
+
"""Create from dict (e.g., from Firestore)."""
|
|
151
|
+
return cls(
|
|
152
|
+
filepath=data.get("filepath", ""),
|
|
153
|
+
artist=data.get("artist", ""),
|
|
154
|
+
title=data.get("title", ""),
|
|
155
|
+
provider=data.get("provider", "Unknown"),
|
|
156
|
+
duration=data.get("duration"),
|
|
157
|
+
quality=data.get("quality"),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AudioFetcherError(Exception):
|
|
162
|
+
"""Base exception for audio fetcher errors."""
|
|
163
|
+
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NoResultsError(AudioFetcherError):
|
|
168
|
+
"""Raised when no search results are found."""
|
|
169
|
+
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class DownloadError(AudioFetcherError):
|
|
174
|
+
"""Raised when download fails."""
|
|
175
|
+
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class UserCancelledError(AudioFetcherError):
|
|
180
|
+
"""Raised when user explicitly cancels the operation (e.g., enters 0 or Ctrl+C)."""
|
|
181
|
+
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _check_interrupt():
|
|
186
|
+
"""Check if interrupt was requested and raise UserCancelledError if so."""
|
|
187
|
+
global _interrupt_requested
|
|
188
|
+
if _interrupt_requested:
|
|
189
|
+
raise UserCancelledError("Operation cancelled by user")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class AudioFetcher(ABC):
|
|
193
|
+
"""Abstract base class for audio fetching implementations."""
|
|
194
|
+
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def search(self, artist: str, title: str) -> List[AudioSearchResult]:
|
|
197
|
+
"""
|
|
198
|
+
Search for audio matching the given artist and title.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
artist: The artist name to search for
|
|
202
|
+
title: The track title to search for
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of AudioSearchResult objects
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
NoResultsError: If no results are found
|
|
209
|
+
AudioFetcherError: For other errors
|
|
210
|
+
"""
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
@abstractmethod
|
|
214
|
+
def download(
|
|
215
|
+
self,
|
|
216
|
+
result: AudioSearchResult,
|
|
217
|
+
output_dir: str,
|
|
218
|
+
output_filename: Optional[str] = None,
|
|
219
|
+
) -> AudioFetchResult:
|
|
220
|
+
"""
|
|
221
|
+
Download audio from a search result.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
result: The search result to download
|
|
225
|
+
output_dir: Directory to save the downloaded file
|
|
226
|
+
output_filename: Optional filename (without extension)
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
AudioFetchResult with the downloaded file path
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
DownloadError: If download fails
|
|
233
|
+
"""
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
@abstractmethod
|
|
237
|
+
def search_and_download(
|
|
238
|
+
self,
|
|
239
|
+
artist: str,
|
|
240
|
+
title: str,
|
|
241
|
+
output_dir: str,
|
|
242
|
+
output_filename: Optional[str] = None,
|
|
243
|
+
auto_select: bool = False,
|
|
244
|
+
) -> AudioFetchResult:
|
|
245
|
+
"""
|
|
246
|
+
Search for audio and download it in one operation.
|
|
247
|
+
|
|
248
|
+
In interactive mode (auto_select=False), this will present options to the user.
|
|
249
|
+
In auto mode (auto_select=True), this will automatically select the best result.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
artist: The artist name to search for
|
|
253
|
+
title: The track title to search for
|
|
254
|
+
output_dir: Directory to save the downloaded file
|
|
255
|
+
output_filename: Optional filename (without extension)
|
|
256
|
+
auto_select: If True, automatically select the best result
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
AudioFetchResult with the downloaded file path
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
NoResultsError: If no results are found
|
|
263
|
+
DownloadError: If download fails
|
|
264
|
+
"""
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class FlacFetchAudioFetcher(AudioFetcher):
|
|
269
|
+
"""
|
|
270
|
+
Audio fetcher implementation using flacfetch library.
|
|
271
|
+
|
|
272
|
+
This provides access to multiple audio sources including private music trackers
|
|
273
|
+
and YouTube, with intelligent prioritization of high-quality sources.
|
|
274
|
+
|
|
275
|
+
Also exported as FlacFetcher for shorter name.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
def __init__(
|
|
279
|
+
self,
|
|
280
|
+
logger: Optional[logging.Logger] = None,
|
|
281
|
+
red_api_key: Optional[str] = None,
|
|
282
|
+
red_api_url: Optional[str] = None,
|
|
283
|
+
ops_api_key: Optional[str] = None,
|
|
284
|
+
ops_api_url: Optional[str] = None,
|
|
285
|
+
provider_priority: Optional[List[str]] = None,
|
|
286
|
+
):
|
|
287
|
+
"""
|
|
288
|
+
Initialize the FlacFetch audio fetcher.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
logger: Logger instance for output
|
|
292
|
+
red_api_key: API key for RED tracker (optional)
|
|
293
|
+
red_api_url: Base URL for RED tracker API (optional, required if using RED)
|
|
294
|
+
ops_api_key: API key for OPS tracker (optional)
|
|
295
|
+
ops_api_url: Base URL for OPS tracker API (optional, required if using OPS)
|
|
296
|
+
provider_priority: Custom provider priority order (optional)
|
|
297
|
+
"""
|
|
298
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
299
|
+
self._red_api_key = red_api_key or os.environ.get("RED_API_KEY")
|
|
300
|
+
self._red_api_url = red_api_url or os.environ.get("RED_API_URL")
|
|
301
|
+
self._ops_api_key = ops_api_key or os.environ.get("OPS_API_KEY")
|
|
302
|
+
self._ops_api_url = ops_api_url or os.environ.get("OPS_API_URL")
|
|
303
|
+
self._provider_priority = provider_priority
|
|
304
|
+
self._manager = None
|
|
305
|
+
self._transmission_available = None # Cached result of Transmission check
|
|
306
|
+
|
|
307
|
+
def _check_transmission_available(self) -> bool:
|
|
308
|
+
"""
|
|
309
|
+
Check if Transmission daemon is available for torrent downloads.
|
|
310
|
+
|
|
311
|
+
This prevents adding tracker providers (RED/OPS) when Transmission
|
|
312
|
+
isn't running, which would result in search results that can't be downloaded.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
True if Transmission is available and responsive, False otherwise.
|
|
316
|
+
"""
|
|
317
|
+
if self._transmission_available is not None:
|
|
318
|
+
self.logger.info(f"[Transmission] Using cached status: available={self._transmission_available}")
|
|
319
|
+
return self._transmission_available
|
|
320
|
+
|
|
321
|
+
host = os.environ.get("TRANSMISSION_HOST", "localhost")
|
|
322
|
+
port = int(os.environ.get("TRANSMISSION_PORT", "9091"))
|
|
323
|
+
self.logger.info(f"[Transmission] Checking availability at {host}:{port}")
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
import transmission_rpc
|
|
327
|
+
self.logger.info(f"[Transmission] transmission_rpc imported successfully")
|
|
328
|
+
|
|
329
|
+
client = transmission_rpc.Client(host=host, port=port, timeout=5)
|
|
330
|
+
self.logger.info(f"[Transmission] Client created, calling session_stats()...")
|
|
331
|
+
|
|
332
|
+
# Simple test to verify connection works
|
|
333
|
+
stats = client.session_stats()
|
|
334
|
+
self.logger.info(f"[Transmission] Connected! Download dir: {getattr(stats, 'download_dir', 'unknown')}")
|
|
335
|
+
|
|
336
|
+
self._transmission_available = True
|
|
337
|
+
except ImportError as e:
|
|
338
|
+
self._transmission_available = False
|
|
339
|
+
self.logger.warning(f"[Transmission] transmission_rpc not installed: {e}")
|
|
340
|
+
except Exception as e:
|
|
341
|
+
self._transmission_available = False
|
|
342
|
+
self.logger.warning(f"[Transmission] Connection failed to {host}:{port}: {type(e).__name__}: {e}")
|
|
343
|
+
|
|
344
|
+
self.logger.info(f"[Transmission] Final status: available={self._transmission_available}")
|
|
345
|
+
return self._transmission_available
|
|
346
|
+
|
|
347
|
+
def _get_manager(self):
|
|
348
|
+
"""Lazily initialize and return the FetchManager."""
|
|
349
|
+
if self._manager is None:
|
|
350
|
+
# Import flacfetch here to avoid import errors if not installed
|
|
351
|
+
from flacfetch.core.manager import FetchManager
|
|
352
|
+
from flacfetch.providers.youtube import YoutubeProvider
|
|
353
|
+
from flacfetch.downloaders.youtube import YoutubeDownloader
|
|
354
|
+
|
|
355
|
+
# Try to import TorrentDownloader (has optional dependencies)
|
|
356
|
+
TorrentDownloader = None
|
|
357
|
+
try:
|
|
358
|
+
from flacfetch.downloaders.torrent import TorrentDownloader
|
|
359
|
+
except ImportError:
|
|
360
|
+
self.logger.debug("TorrentDownloader not available (missing dependencies)")
|
|
361
|
+
|
|
362
|
+
self._manager = FetchManager()
|
|
363
|
+
|
|
364
|
+
# Only add tracker providers if we can actually download from them
|
|
365
|
+
# This requires both TorrentDownloader and a running Transmission daemon
|
|
366
|
+
has_torrent_downloader = TorrentDownloader is not None
|
|
367
|
+
transmission_available = self._check_transmission_available()
|
|
368
|
+
can_use_trackers = has_torrent_downloader and transmission_available
|
|
369
|
+
|
|
370
|
+
self.logger.info(
|
|
371
|
+
f"[FlacFetcher] Provider setup: TorrentDownloader={has_torrent_downloader}, "
|
|
372
|
+
f"Transmission={transmission_available}, can_use_trackers={can_use_trackers}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if not can_use_trackers and (self._red_api_key or self._ops_api_key):
|
|
376
|
+
self.logger.warning(
|
|
377
|
+
"[FlacFetcher] Tracker providers (RED/OPS) DISABLED: "
|
|
378
|
+
f"TorrentDownloader={has_torrent_downloader}, Transmission={transmission_available}. "
|
|
379
|
+
"Only YouTube sources will be used."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Add providers and downloaders based on available API keys and URLs
|
|
383
|
+
if self._red_api_key and self._red_api_url and can_use_trackers:
|
|
384
|
+
from flacfetch.providers.red import REDProvider
|
|
385
|
+
|
|
386
|
+
self._manager.add_provider(REDProvider(api_key=self._red_api_key, base_url=self._red_api_url))
|
|
387
|
+
self._manager.register_downloader("RED", TorrentDownloader())
|
|
388
|
+
self.logger.info("[FlacFetcher] Added RED provider with TorrentDownloader")
|
|
389
|
+
elif self._red_api_key and not self._red_api_url:
|
|
390
|
+
self.logger.warning("[FlacFetcher] RED_API_KEY set but RED_API_URL not set - RED provider disabled")
|
|
391
|
+
|
|
392
|
+
if self._ops_api_key and self._ops_api_url and can_use_trackers:
|
|
393
|
+
from flacfetch.providers.ops import OPSProvider
|
|
394
|
+
|
|
395
|
+
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key, base_url=self._ops_api_url))
|
|
396
|
+
self._manager.register_downloader("OPS", TorrentDownloader())
|
|
397
|
+
self.logger.info("[FlacFetcher] Added OPS provider with TorrentDownloader")
|
|
398
|
+
elif self._ops_api_key and not self._ops_api_url:
|
|
399
|
+
self.logger.warning("[FlacFetcher] OPS_API_KEY set but OPS_API_URL not set - OPS provider disabled")
|
|
400
|
+
|
|
401
|
+
# Always add YouTube as a fallback provider with its downloader
|
|
402
|
+
self._manager.add_provider(YoutubeProvider())
|
|
403
|
+
self._manager.register_downloader("YouTube", YoutubeDownloader())
|
|
404
|
+
self.logger.debug("Added YouTube provider")
|
|
405
|
+
|
|
406
|
+
return self._manager
|
|
407
|
+
|
|
408
|
+
def search(self, artist: str, title: str) -> List[AudioSearchResult]:
|
|
409
|
+
"""
|
|
410
|
+
Search for audio matching the given artist and title.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
artist: The artist name to search for
|
|
414
|
+
title: The track title to search for
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
List of AudioSearchResult objects
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
NoResultsError: If no results are found
|
|
421
|
+
"""
|
|
422
|
+
from flacfetch.core.models import TrackQuery
|
|
423
|
+
|
|
424
|
+
manager = self._get_manager()
|
|
425
|
+
query = TrackQuery(artist=artist, title=title)
|
|
426
|
+
|
|
427
|
+
self.logger.info(f"Searching for: {artist} - {title}")
|
|
428
|
+
results = manager.search(query)
|
|
429
|
+
|
|
430
|
+
if not results:
|
|
431
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
432
|
+
|
|
433
|
+
# Convert to our AudioSearchResult format
|
|
434
|
+
search_results = []
|
|
435
|
+
for i, result in enumerate(results):
|
|
436
|
+
# Get quality as string if it's a Quality object
|
|
437
|
+
quality = getattr(result, "quality", None)
|
|
438
|
+
quality_str = str(quality) if quality else None
|
|
439
|
+
|
|
440
|
+
search_results.append(
|
|
441
|
+
AudioSearchResult(
|
|
442
|
+
title=getattr(result, "title", title),
|
|
443
|
+
artist=getattr(result, "artist", artist),
|
|
444
|
+
url=getattr(result, "download_url", "") or "",
|
|
445
|
+
provider=getattr(result, "source_name", "Unknown"),
|
|
446
|
+
duration=getattr(result, "duration_seconds", None),
|
|
447
|
+
quality=quality_str,
|
|
448
|
+
source_id=getattr(result, "info_hash", None),
|
|
449
|
+
index=i, # Set index for API selection
|
|
450
|
+
seeders=getattr(result, "seeders", None),
|
|
451
|
+
target_file=getattr(result, "target_file", None),
|
|
452
|
+
raw_result=result,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
self.logger.info(f"Found {len(search_results)} results")
|
|
457
|
+
return search_results
|
|
458
|
+
|
|
459
|
+
def download(
|
|
460
|
+
self,
|
|
461
|
+
result: AudioSearchResult,
|
|
462
|
+
output_dir: str,
|
|
463
|
+
output_filename: Optional[str] = None,
|
|
464
|
+
) -> AudioFetchResult:
|
|
465
|
+
"""
|
|
466
|
+
Download audio from a search result.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
result: The search result to download
|
|
470
|
+
output_dir: Directory to save the downloaded file
|
|
471
|
+
output_filename: Optional filename (without extension)
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
AudioFetchResult with the downloaded file path
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
DownloadError: If download fails
|
|
478
|
+
"""
|
|
479
|
+
manager = self._get_manager()
|
|
480
|
+
|
|
481
|
+
# Ensure output directory exists
|
|
482
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
483
|
+
|
|
484
|
+
# Generate filename if not provided
|
|
485
|
+
if output_filename is None:
|
|
486
|
+
output_filename = f"{result.artist} - {result.title}"
|
|
487
|
+
|
|
488
|
+
self.logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider or 'Unknown'}")
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
# Use flacfetch to download
|
|
492
|
+
filepath = manager.download(
|
|
493
|
+
result.raw_result,
|
|
494
|
+
output_path=output_dir,
|
|
495
|
+
output_filename=output_filename,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
if filepath is None:
|
|
499
|
+
raise DownloadError(f"Download returned no file path for: {result.artist} - {result.title}")
|
|
500
|
+
|
|
501
|
+
self.logger.info(f"Downloaded to: {filepath}")
|
|
502
|
+
|
|
503
|
+
return AudioFetchResult(
|
|
504
|
+
filepath=filepath,
|
|
505
|
+
artist=result.artist,
|
|
506
|
+
title=result.title,
|
|
507
|
+
provider=result.provider,
|
|
508
|
+
duration=result.duration,
|
|
509
|
+
quality=result.quality,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
except Exception as e:
|
|
513
|
+
raise DownloadError(f"Failed to download {result.artist} - {result.title}: {e}") from e
|
|
514
|
+
|
|
515
|
+
def select_best(self, results: List[AudioSearchResult]) -> int:
|
|
516
|
+
"""
|
|
517
|
+
Select the best result from a list of search results.
|
|
518
|
+
|
|
519
|
+
Uses flacfetch's built-in quality ranking to determine the best source.
|
|
520
|
+
This is useful for automated/non-interactive usage.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
results: List of AudioSearchResult objects from search()
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Index of the best result in the list
|
|
527
|
+
"""
|
|
528
|
+
if not results:
|
|
529
|
+
return 0
|
|
530
|
+
|
|
531
|
+
manager = self._get_manager()
|
|
532
|
+
|
|
533
|
+
# Get raw results that have raw_result set
|
|
534
|
+
raw_results = [r.raw_result for r in results if r.raw_result is not None]
|
|
535
|
+
|
|
536
|
+
if raw_results:
|
|
537
|
+
try:
|
|
538
|
+
best = manager.select_best(raw_results)
|
|
539
|
+
# Find index of best result
|
|
540
|
+
for i, r in enumerate(results):
|
|
541
|
+
if r.raw_result == best:
|
|
542
|
+
return i
|
|
543
|
+
except Exception as e:
|
|
544
|
+
self.logger.warning(f"select_best failed, using first result: {e}")
|
|
545
|
+
|
|
546
|
+
# Fallback: return first result
|
|
547
|
+
return 0
|
|
548
|
+
|
|
549
|
+
def search_and_download(
|
|
550
|
+
self,
|
|
551
|
+
artist: str,
|
|
552
|
+
title: str,
|
|
553
|
+
output_dir: str,
|
|
554
|
+
output_filename: Optional[str] = None,
|
|
555
|
+
auto_select: bool = False,
|
|
556
|
+
) -> AudioFetchResult:
|
|
557
|
+
"""
|
|
558
|
+
Search for audio and download it in one operation.
|
|
559
|
+
|
|
560
|
+
In interactive mode (auto_select=False), this will present options to the user.
|
|
561
|
+
In auto mode (auto_select=True), this will automatically select the best result.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
artist: The artist name to search for
|
|
565
|
+
title: The track title to search for
|
|
566
|
+
output_dir: Directory to save the downloaded file
|
|
567
|
+
output_filename: Optional filename (without extension)
|
|
568
|
+
auto_select: If True, automatically select the best result
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
AudioFetchResult with the downloaded file path
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
NoResultsError: If no results are found
|
|
575
|
+
DownloadError: If download fails
|
|
576
|
+
UserCancelledError: If user cancels (Ctrl+C or enters 0)
|
|
577
|
+
"""
|
|
578
|
+
from flacfetch.core.models import TrackQuery
|
|
579
|
+
|
|
580
|
+
manager = self._get_manager()
|
|
581
|
+
query = TrackQuery(artist=artist, title=title)
|
|
582
|
+
|
|
583
|
+
self.logger.info(f"Searching for: {artist} - {title}")
|
|
584
|
+
|
|
585
|
+
# Run search in a thread so we can handle Ctrl+C
|
|
586
|
+
results = self._interruptible_search(manager, query)
|
|
587
|
+
|
|
588
|
+
if not results:
|
|
589
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
590
|
+
|
|
591
|
+
self.logger.info(f"Found {len(results)} results")
|
|
592
|
+
|
|
593
|
+
if auto_select:
|
|
594
|
+
# Auto mode: select best result based on flacfetch's ranking
|
|
595
|
+
selected = manager.select_best(results)
|
|
596
|
+
self.logger.info(f"Auto-selected: {getattr(selected, 'title', title)} from {getattr(selected, 'source_name', 'Unknown')}")
|
|
597
|
+
else:
|
|
598
|
+
# Interactive mode: present options to user
|
|
599
|
+
selected = self._interactive_select(results, artist, title)
|
|
600
|
+
|
|
601
|
+
# Note: _interactive_select now raises UserCancelledError instead of returning None
|
|
602
|
+
# This check is kept as a safety net
|
|
603
|
+
if selected is None:
|
|
604
|
+
raise NoResultsError(f"No result selected for: {artist} - {title}")
|
|
605
|
+
|
|
606
|
+
# Ensure output directory exists
|
|
607
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
608
|
+
|
|
609
|
+
# Generate filename if not provided
|
|
610
|
+
if output_filename is None:
|
|
611
|
+
output_filename = f"{artist} - {title}"
|
|
612
|
+
|
|
613
|
+
self.logger.info(f"Downloading from {getattr(selected, 'source_name', 'Unknown')}...")
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
# Use interruptible download so Ctrl+C works during torrent downloads
|
|
617
|
+
filepath = self._interruptible_download(
|
|
618
|
+
manager,
|
|
619
|
+
selected,
|
|
620
|
+
output_path=output_dir,
|
|
621
|
+
output_filename=output_filename,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
if not filepath:
|
|
625
|
+
raise DownloadError(f"Download returned no file path for: {artist} - {title}")
|
|
626
|
+
|
|
627
|
+
self.logger.info(f"Downloaded to: {filepath}")
|
|
628
|
+
|
|
629
|
+
# Get quality as string if it's a Quality object
|
|
630
|
+
quality = getattr(selected, "quality", None)
|
|
631
|
+
quality_str = str(quality) if quality else None
|
|
632
|
+
|
|
633
|
+
return AudioFetchResult(
|
|
634
|
+
filepath=filepath,
|
|
635
|
+
artist=artist,
|
|
636
|
+
title=title,
|
|
637
|
+
provider=getattr(selected, "source_name", "Unknown"),
|
|
638
|
+
duration=getattr(selected, "duration_seconds", None),
|
|
639
|
+
quality=quality_str,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
except (UserCancelledError, KeyboardInterrupt):
|
|
643
|
+
# Let cancellation exceptions propagate without wrapping
|
|
644
|
+
raise
|
|
645
|
+
except Exception as e:
|
|
646
|
+
raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
|
|
647
|
+
|
|
648
|
+
def _interruptible_search(self, manager, query) -> list:
|
|
649
|
+
"""
|
|
650
|
+
Run search in a way that can be interrupted by Ctrl+C.
|
|
651
|
+
|
|
652
|
+
The flacfetch search is a blocking network operation that doesn't
|
|
653
|
+
respond to SIGINT while running. This method runs it in a background
|
|
654
|
+
thread and periodically checks for interrupts.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
manager: The FetchManager instance
|
|
658
|
+
query: The TrackQuery to search for
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
List of search results
|
|
662
|
+
|
|
663
|
+
Raises:
|
|
664
|
+
UserCancelledError: If user presses Ctrl+C during search
|
|
665
|
+
"""
|
|
666
|
+
global _interrupt_requested
|
|
667
|
+
_interrupt_requested = False
|
|
668
|
+
result_container = {"results": None, "error": None}
|
|
669
|
+
|
|
670
|
+
def do_search():
|
|
671
|
+
try:
|
|
672
|
+
result_container["results"] = manager.search(query)
|
|
673
|
+
except Exception as e:
|
|
674
|
+
result_container["error"] = e
|
|
675
|
+
|
|
676
|
+
# Set up signal handler for immediate response to Ctrl+C
|
|
677
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
678
|
+
|
|
679
|
+
def interrupt_handler(signum, frame):
|
|
680
|
+
global _interrupt_requested
|
|
681
|
+
_interrupt_requested = True
|
|
682
|
+
# Print immediately so user knows it was received
|
|
683
|
+
print("\nCancelling... please wait", file=sys.stderr)
|
|
684
|
+
|
|
685
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
# Start search in background thread
|
|
689
|
+
search_thread = threading.Thread(target=do_search, daemon=True)
|
|
690
|
+
search_thread.start()
|
|
691
|
+
|
|
692
|
+
# Wait for completion with periodic interrupt checks
|
|
693
|
+
while search_thread.is_alive():
|
|
694
|
+
search_thread.join(timeout=0.1) # Check every 100ms
|
|
695
|
+
if _interrupt_requested:
|
|
696
|
+
# Don't wait for thread - it's a daemon and will be killed
|
|
697
|
+
raise UserCancelledError("Search cancelled by user (Ctrl+C)")
|
|
698
|
+
|
|
699
|
+
# Check for errors from the search
|
|
700
|
+
if result_container["error"] is not None:
|
|
701
|
+
raise result_container["error"]
|
|
702
|
+
|
|
703
|
+
return result_container["results"]
|
|
704
|
+
|
|
705
|
+
finally:
|
|
706
|
+
# Restore original signal handler
|
|
707
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
708
|
+
_interrupt_requested = False
|
|
709
|
+
|
|
710
|
+
def _interruptible_download(self, manager, selected, output_path: str, output_filename: str) -> str:
|
|
711
|
+
"""
|
|
712
|
+
Run download in a way that can be interrupted by Ctrl+C.
|
|
713
|
+
|
|
714
|
+
The flacfetch/transmission download is a blocking operation that doesn't
|
|
715
|
+
respond to SIGINT while running (especially for torrent downloads).
|
|
716
|
+
This method runs it in a background thread and periodically checks for interrupts.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
manager: The FetchManager instance
|
|
720
|
+
selected: The selected result to download
|
|
721
|
+
output_path: Directory to save the file
|
|
722
|
+
output_filename: Filename to save as
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
Path to the downloaded file
|
|
726
|
+
|
|
727
|
+
Raises:
|
|
728
|
+
UserCancelledError: If user presses Ctrl+C during download
|
|
729
|
+
DownloadError: If download fails
|
|
730
|
+
"""
|
|
731
|
+
global _interrupt_requested
|
|
732
|
+
_interrupt_requested = False
|
|
733
|
+
result_container = {"filepath": None, "error": None}
|
|
734
|
+
was_cancelled = False
|
|
735
|
+
|
|
736
|
+
def do_download():
|
|
737
|
+
try:
|
|
738
|
+
result_container["filepath"] = manager.download(
|
|
739
|
+
selected,
|
|
740
|
+
output_path=output_path,
|
|
741
|
+
output_filename=output_filename,
|
|
742
|
+
)
|
|
743
|
+
except Exception as e:
|
|
744
|
+
result_container["error"] = e
|
|
745
|
+
|
|
746
|
+
# Set up signal handler for immediate response to Ctrl+C
|
|
747
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
748
|
+
|
|
749
|
+
def interrupt_handler(signum, frame):
|
|
750
|
+
global _interrupt_requested
|
|
751
|
+
_interrupt_requested = True
|
|
752
|
+
# Print immediately so user knows it was received
|
|
753
|
+
print("\nCancelling download... please wait (may take a few seconds)", file=sys.stderr)
|
|
754
|
+
|
|
755
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
# Start download in background thread
|
|
759
|
+
download_thread = threading.Thread(target=do_download, daemon=True)
|
|
760
|
+
download_thread.start()
|
|
761
|
+
|
|
762
|
+
# Wait for completion with periodic interrupt checks
|
|
763
|
+
while download_thread.is_alive():
|
|
764
|
+
download_thread.join(timeout=0.2) # Check every 200ms
|
|
765
|
+
if _interrupt_requested:
|
|
766
|
+
was_cancelled = True
|
|
767
|
+
# Clean up any pending torrents before raising
|
|
768
|
+
self._cleanup_transmission_torrents(selected)
|
|
769
|
+
raise UserCancelledError("Download cancelled by user (Ctrl+C)")
|
|
770
|
+
|
|
771
|
+
# Check for errors from the download
|
|
772
|
+
if result_container["error"] is not None:
|
|
773
|
+
raise result_container["error"]
|
|
774
|
+
|
|
775
|
+
return result_container["filepath"]
|
|
776
|
+
|
|
777
|
+
finally:
|
|
778
|
+
# Restore original signal handler
|
|
779
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
780
|
+
_interrupt_requested = False
|
|
781
|
+
|
|
782
|
+
def _cleanup_transmission_torrents(self, selected) -> None:
|
|
783
|
+
"""
|
|
784
|
+
Clean up any torrents in Transmission that were started for this download.
|
|
785
|
+
|
|
786
|
+
Called when a download is cancelled to remove incomplete torrents and their data.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
selected: The selected result that was being downloaded
|
|
790
|
+
"""
|
|
791
|
+
try:
|
|
792
|
+
import transmission_rpc
|
|
793
|
+
host = os.environ.get("TRANSMISSION_HOST", "localhost")
|
|
794
|
+
port = int(os.environ.get("TRANSMISSION_PORT", "9091"))
|
|
795
|
+
client = transmission_rpc.Client(host=host, port=port, timeout=5)
|
|
796
|
+
|
|
797
|
+
# Get the release name to match against torrents
|
|
798
|
+
release_name = getattr(selected, 'name', None) or getattr(selected, 'title', None)
|
|
799
|
+
if not release_name:
|
|
800
|
+
self.logger.debug("[Transmission] No release name to match for cleanup")
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
# Find and remove matching incomplete torrents
|
|
804
|
+
torrents = client.get_torrents()
|
|
805
|
+
for torrent in torrents:
|
|
806
|
+
# Match by name similarity and incomplete status
|
|
807
|
+
if torrent.progress < 100 and release_name.lower() in torrent.name.lower():
|
|
808
|
+
self.logger.info(f"[Transmission] Removing cancelled torrent: {torrent.name}")
|
|
809
|
+
client.remove_torrent(torrent.id, delete_data=True)
|
|
810
|
+
|
|
811
|
+
except Exception as e:
|
|
812
|
+
# Don't fail the cancellation if cleanup fails
|
|
813
|
+
self.logger.debug(f"[Transmission] Cleanup failed (non-fatal): {e}")
|
|
814
|
+
|
|
815
|
+
def _interactive_select(self, results: list, artist: str, title: str) -> object:
|
|
816
|
+
"""
|
|
817
|
+
Present search results to the user for interactive selection.
|
|
818
|
+
|
|
819
|
+
Uses flacfetch's built-in CLIHandler for rich, colorized output.
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
results: List of Release objects from flacfetch
|
|
823
|
+
artist: The artist name being searched
|
|
824
|
+
title: The track title being searched
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
The selected Release object
|
|
828
|
+
|
|
829
|
+
Raises:
|
|
830
|
+
UserCancelledError: If user cancels selection
|
|
831
|
+
"""
|
|
832
|
+
try:
|
|
833
|
+
# Use flacfetch's built-in CLIHandler for rich display
|
|
834
|
+
from flacfetch.interface.cli import CLIHandler
|
|
835
|
+
|
|
836
|
+
handler = CLIHandler(target_artist=artist)
|
|
837
|
+
result = handler.select_release(results)
|
|
838
|
+
if result is None:
|
|
839
|
+
# User selected 0 to cancel
|
|
840
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
841
|
+
return result
|
|
842
|
+
except ImportError:
|
|
843
|
+
# Fallback to basic display if CLIHandler not available
|
|
844
|
+
return self._basic_interactive_select(results, artist, title)
|
|
845
|
+
except (KeyboardInterrupt, EOFError):
|
|
846
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
847
|
+
except (AttributeError, TypeError):
|
|
848
|
+
# Fallback if results aren't proper Release objects (e.g., in tests)
|
|
849
|
+
return self._basic_interactive_select(results, artist, title)
|
|
850
|
+
|
|
851
|
+
def _basic_interactive_select(self, results: list, artist: str, title: str) -> object:
|
|
852
|
+
"""
|
|
853
|
+
Basic fallback for interactive selection without rich formatting.
|
|
854
|
+
|
|
855
|
+
Args:
|
|
856
|
+
results: List of Release objects from flacfetch
|
|
857
|
+
artist: The artist name being searched
|
|
858
|
+
title: The track title being searched
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
The selected Release object
|
|
862
|
+
|
|
863
|
+
Raises:
|
|
864
|
+
UserCancelledError: If user cancels selection
|
|
865
|
+
"""
|
|
866
|
+
# Use flacfetch's shared display function
|
|
867
|
+
from flacfetch import print_releases
|
|
868
|
+
print_releases(results, target_artist=artist, use_colors=True)
|
|
869
|
+
|
|
870
|
+
while True:
|
|
871
|
+
try:
|
|
872
|
+
choice = input("Enter your choice (1-{}, or 0 to cancel): ".format(len(results))).strip()
|
|
873
|
+
|
|
874
|
+
if choice == "0":
|
|
875
|
+
self.logger.info("User cancelled selection")
|
|
876
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
877
|
+
|
|
878
|
+
choice_num = int(choice)
|
|
879
|
+
if 1 <= choice_num <= len(results):
|
|
880
|
+
selected = results[choice_num - 1]
|
|
881
|
+
self.logger.info(f"User selected option {choice_num}")
|
|
882
|
+
return selected
|
|
883
|
+
else:
|
|
884
|
+
print(f"Please enter a number between 0 and {len(results)}")
|
|
885
|
+
|
|
886
|
+
except ValueError:
|
|
887
|
+
print("Please enter a valid number")
|
|
888
|
+
except KeyboardInterrupt:
|
|
889
|
+
print("\nCancelled")
|
|
890
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# Alias for shorter name - used by backend and other consumers
|
|
894
|
+
FlacFetcher = FlacFetchAudioFetcher
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class RemoteFlacFetchAudioFetcher(AudioFetcher):
|
|
898
|
+
"""
|
|
899
|
+
Audio fetcher implementation using remote flacfetch HTTP API.
|
|
900
|
+
|
|
901
|
+
This fetcher communicates with a dedicated flacfetch server that handles:
|
|
902
|
+
- BitTorrent downloads from private trackers (RED, OPS)
|
|
903
|
+
- YouTube downloads
|
|
904
|
+
- File streaming back to the client
|
|
905
|
+
|
|
906
|
+
Used when FLACFETCH_API_URL and FLACFETCH_API_KEY environment variables are set.
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
def __init__(
|
|
910
|
+
self,
|
|
911
|
+
api_url: str,
|
|
912
|
+
api_key: str,
|
|
913
|
+
logger: Optional[logging.Logger] = None,
|
|
914
|
+
timeout: int = 60,
|
|
915
|
+
download_timeout: int = 600,
|
|
916
|
+
):
|
|
917
|
+
"""
|
|
918
|
+
Initialize the remote FlacFetch audio fetcher.
|
|
919
|
+
|
|
920
|
+
Args:
|
|
921
|
+
api_url: Base URL of flacfetch API server (e.g., http://10.0.0.5:8080)
|
|
922
|
+
api_key: API key for authentication
|
|
923
|
+
logger: Logger instance for output
|
|
924
|
+
timeout: Request timeout in seconds for search/status calls
|
|
925
|
+
download_timeout: Maximum wait time for downloads to complete
|
|
926
|
+
"""
|
|
927
|
+
if not HTTPX_AVAILABLE:
|
|
928
|
+
raise ImportError("httpx is required for remote flacfetch. Install with: pip install httpx")
|
|
929
|
+
|
|
930
|
+
self.api_url = api_url.rstrip('/')
|
|
931
|
+
self.api_key = api_key
|
|
932
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
933
|
+
self.timeout = timeout
|
|
934
|
+
self.download_timeout = download_timeout
|
|
935
|
+
self._last_search_id: Optional[str] = None
|
|
936
|
+
self._last_search_results: List[Dict[str, Any]] = []
|
|
937
|
+
|
|
938
|
+
self.logger.info(f"[RemoteFlacFetcher] Initialized with API URL: {self.api_url}")
|
|
939
|
+
|
|
940
|
+
def _headers(self) -> Dict[str, str]:
|
|
941
|
+
"""Get request headers with authentication."""
|
|
942
|
+
return {
|
|
943
|
+
"X-API-Key": self.api_key,
|
|
944
|
+
"Content-Type": "application/json",
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
def _check_health(self) -> bool:
|
|
948
|
+
"""Check if the remote flacfetch service is healthy."""
|
|
949
|
+
try:
|
|
950
|
+
with httpx.Client() as client:
|
|
951
|
+
resp = client.get(
|
|
952
|
+
f"{self.api_url}/health",
|
|
953
|
+
headers=self._headers(),
|
|
954
|
+
timeout=10,
|
|
955
|
+
)
|
|
956
|
+
if resp.status_code == 200:
|
|
957
|
+
data = resp.json()
|
|
958
|
+
status = data.get("status", "unknown")
|
|
959
|
+
self.logger.debug(f"[RemoteFlacFetcher] Health check: {status}")
|
|
960
|
+
return status in ["healthy", "degraded"]
|
|
961
|
+
return False
|
|
962
|
+
except Exception as e:
|
|
963
|
+
self.logger.warning(f"[RemoteFlacFetcher] Health check failed: {e}")
|
|
964
|
+
return False
|
|
965
|
+
|
|
966
|
+
def search(self, artist: str, title: str) -> List[AudioSearchResult]:
|
|
967
|
+
"""
|
|
968
|
+
Search for audio matching the given artist and title via remote API.
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
artist: The artist name to search for
|
|
972
|
+
title: The track title to search for
|
|
973
|
+
|
|
974
|
+
Returns:
|
|
975
|
+
List of AudioSearchResult objects
|
|
976
|
+
|
|
977
|
+
Raises:
|
|
978
|
+
NoResultsError: If no results are found
|
|
979
|
+
AudioFetcherError: For other errors
|
|
980
|
+
"""
|
|
981
|
+
self.logger.info(f"[RemoteFlacFetcher] Searching for: {artist} - {title}")
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
with httpx.Client() as client:
|
|
985
|
+
resp = client.post(
|
|
986
|
+
f"{self.api_url}/search",
|
|
987
|
+
headers=self._headers(),
|
|
988
|
+
json={"artist": artist, "title": title},
|
|
989
|
+
timeout=self.timeout,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
if resp.status_code == 404:
|
|
993
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
994
|
+
|
|
995
|
+
resp.raise_for_status()
|
|
996
|
+
data = resp.json()
|
|
997
|
+
|
|
998
|
+
self._last_search_id = data.get("search_id")
|
|
999
|
+
self._last_search_results = data.get("results", [])
|
|
1000
|
+
|
|
1001
|
+
if not self._last_search_results:
|
|
1002
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
1003
|
+
|
|
1004
|
+
# Convert API results to AudioSearchResult objects
|
|
1005
|
+
search_results = []
|
|
1006
|
+
for i, result in enumerate(self._last_search_results):
|
|
1007
|
+
search_results.append(
|
|
1008
|
+
AudioSearchResult(
|
|
1009
|
+
title=result.get("title", title),
|
|
1010
|
+
artist=result.get("artist", artist),
|
|
1011
|
+
url=result.get("download_url", "") or result.get("url", ""),
|
|
1012
|
+
provider=result.get("provider", result.get("source_name", "Unknown")),
|
|
1013
|
+
duration=result.get("duration_seconds", result.get("duration")),
|
|
1014
|
+
quality=result.get("quality_str", result.get("quality")),
|
|
1015
|
+
source_id=result.get("info_hash"),
|
|
1016
|
+
index=i,
|
|
1017
|
+
seeders=result.get("seeders"),
|
|
1018
|
+
target_file=result.get("target_file"),
|
|
1019
|
+
raw_result=result, # Store the full API result
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
self.logger.info(f"[RemoteFlacFetcher] Found {len(search_results)} results")
|
|
1024
|
+
return search_results
|
|
1025
|
+
|
|
1026
|
+
except httpx.RequestError as e:
|
|
1027
|
+
raise AudioFetcherError(f"Search request failed: {e}") from e
|
|
1028
|
+
except httpx.HTTPStatusError as e:
|
|
1029
|
+
if e.response.status_code == 404:
|
|
1030
|
+
raise NoResultsError(f"No results found for: {artist} - {title}") from e
|
|
1031
|
+
raise AudioFetcherError(f"Search failed: {e.response.status_code} - {e.response.text}") from e
|
|
1032
|
+
|
|
1033
|
+
def download(
|
|
1034
|
+
self,
|
|
1035
|
+
result: AudioSearchResult,
|
|
1036
|
+
output_dir: str,
|
|
1037
|
+
output_filename: Optional[str] = None,
|
|
1038
|
+
) -> AudioFetchResult:
|
|
1039
|
+
"""
|
|
1040
|
+
Download audio from a search result via remote API.
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
result: The search result to download
|
|
1044
|
+
output_dir: Directory to save the downloaded file
|
|
1045
|
+
output_filename: Optional filename (without extension)
|
|
1046
|
+
|
|
1047
|
+
Returns:
|
|
1048
|
+
AudioFetchResult with the downloaded file path
|
|
1049
|
+
|
|
1050
|
+
Raises:
|
|
1051
|
+
DownloadError: If download fails
|
|
1052
|
+
"""
|
|
1053
|
+
if not self._last_search_id:
|
|
1054
|
+
raise DownloadError("No search performed - call search() first")
|
|
1055
|
+
|
|
1056
|
+
# Ensure output directory exists
|
|
1057
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1058
|
+
|
|
1059
|
+
# Generate filename if not provided
|
|
1060
|
+
if output_filename is None:
|
|
1061
|
+
output_filename = f"{result.artist} - {result.title}"
|
|
1062
|
+
|
|
1063
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloading: {result.artist} - {result.title} from {result.provider}")
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
# Start the download
|
|
1067
|
+
with httpx.Client() as client:
|
|
1068
|
+
resp = client.post(
|
|
1069
|
+
f"{self.api_url}/download",
|
|
1070
|
+
headers=self._headers(),
|
|
1071
|
+
json={
|
|
1072
|
+
"search_id": self._last_search_id,
|
|
1073
|
+
"result_index": result.index,
|
|
1074
|
+
"output_filename": output_filename,
|
|
1075
|
+
# Don't set upload_to_gcs - we want local download
|
|
1076
|
+
},
|
|
1077
|
+
timeout=self.timeout,
|
|
1078
|
+
)
|
|
1079
|
+
resp.raise_for_status()
|
|
1080
|
+
data = resp.json()
|
|
1081
|
+
download_id = data.get("download_id")
|
|
1082
|
+
|
|
1083
|
+
if not download_id:
|
|
1084
|
+
raise DownloadError("No download_id returned from API")
|
|
1085
|
+
|
|
1086
|
+
self.logger.info(f"[RemoteFlacFetcher] Download started: {download_id}")
|
|
1087
|
+
|
|
1088
|
+
# Wait for download to complete
|
|
1089
|
+
filepath = self._wait_and_stream_download(
|
|
1090
|
+
download_id=download_id,
|
|
1091
|
+
output_dir=output_dir,
|
|
1092
|
+
output_filename=output_filename,
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloaded to: {filepath}")
|
|
1096
|
+
|
|
1097
|
+
return AudioFetchResult(
|
|
1098
|
+
filepath=filepath,
|
|
1099
|
+
artist=result.artist,
|
|
1100
|
+
title=result.title,
|
|
1101
|
+
provider=result.provider,
|
|
1102
|
+
duration=result.duration,
|
|
1103
|
+
quality=result.quality,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
except httpx.RequestError as e:
|
|
1107
|
+
raise DownloadError(f"Download request failed: {e}") from e
|
|
1108
|
+
except httpx.HTTPStatusError as e:
|
|
1109
|
+
raise DownloadError(f"Download failed: {e.response.status_code} - {e.response.text}") from e
|
|
1110
|
+
|
|
1111
|
+
def _wait_and_stream_download(
|
|
1112
|
+
self,
|
|
1113
|
+
download_id: str,
|
|
1114
|
+
output_dir: str,
|
|
1115
|
+
output_filename: str,
|
|
1116
|
+
poll_interval: float = 2.0,
|
|
1117
|
+
) -> str:
|
|
1118
|
+
"""
|
|
1119
|
+
Wait for a remote download to complete, then stream the file locally.
|
|
1120
|
+
|
|
1121
|
+
Args:
|
|
1122
|
+
download_id: Download ID from /download endpoint
|
|
1123
|
+
output_dir: Local directory to save file
|
|
1124
|
+
output_filename: Local filename (without extension)
|
|
1125
|
+
poll_interval: Seconds between status checks
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
Path to the downloaded local file
|
|
1129
|
+
|
|
1130
|
+
Raises:
|
|
1131
|
+
DownloadError: On download failure or timeout
|
|
1132
|
+
UserCancelledError: If user presses Ctrl+C
|
|
1133
|
+
"""
|
|
1134
|
+
global _interrupt_requested
|
|
1135
|
+
_interrupt_requested = False
|
|
1136
|
+
|
|
1137
|
+
# Set up signal handler for Ctrl+C
|
|
1138
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
1139
|
+
|
|
1140
|
+
def interrupt_handler(signum, frame):
|
|
1141
|
+
global _interrupt_requested
|
|
1142
|
+
_interrupt_requested = True
|
|
1143
|
+
print("\nCancelling download... please wait", file=sys.stderr)
|
|
1144
|
+
|
|
1145
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
1146
|
+
|
|
1147
|
+
try:
|
|
1148
|
+
elapsed = 0.0
|
|
1149
|
+
last_progress = -1
|
|
1150
|
+
|
|
1151
|
+
while elapsed < self.download_timeout:
|
|
1152
|
+
# Check for interrupt
|
|
1153
|
+
if _interrupt_requested:
|
|
1154
|
+
raise UserCancelledError("Download cancelled by user (Ctrl+C)")
|
|
1155
|
+
|
|
1156
|
+
# Check status
|
|
1157
|
+
with httpx.Client() as client:
|
|
1158
|
+
resp = client.get(
|
|
1159
|
+
f"{self.api_url}/download/{download_id}/status",
|
|
1160
|
+
headers=self._headers(),
|
|
1161
|
+
timeout=10,
|
|
1162
|
+
)
|
|
1163
|
+
resp.raise_for_status()
|
|
1164
|
+
status = resp.json()
|
|
1165
|
+
|
|
1166
|
+
download_status = status.get("status")
|
|
1167
|
+
progress = status.get("progress", 0)
|
|
1168
|
+
speed = status.get("download_speed_kbps", 0)
|
|
1169
|
+
|
|
1170
|
+
# Log progress updates
|
|
1171
|
+
if int(progress) != last_progress:
|
|
1172
|
+
if download_status == "downloading":
|
|
1173
|
+
self.logger.info(f"[RemoteFlacFetcher] Progress: {progress:.1f}% ({speed:.1f} KB/s)")
|
|
1174
|
+
elif download_status in ["uploading", "processing"]:
|
|
1175
|
+
self.logger.info(f"[RemoteFlacFetcher] {download_status.capitalize()}...")
|
|
1176
|
+
last_progress = int(progress)
|
|
1177
|
+
|
|
1178
|
+
if download_status in ["complete", "seeding"]:
|
|
1179
|
+
# Download complete - now stream the file locally
|
|
1180
|
+
self.logger.info(f"[RemoteFlacFetcher] Remote download complete, streaming to local...")
|
|
1181
|
+
return self._stream_file_locally(download_id, output_dir, output_filename)
|
|
1182
|
+
|
|
1183
|
+
elif download_status == "failed":
|
|
1184
|
+
error = status.get("error", "Unknown error")
|
|
1185
|
+
raise DownloadError(f"Remote download failed: {error}")
|
|
1186
|
+
|
|
1187
|
+
elif download_status == "cancelled":
|
|
1188
|
+
raise DownloadError("Download was cancelled on server")
|
|
1189
|
+
|
|
1190
|
+
time.sleep(poll_interval)
|
|
1191
|
+
elapsed += poll_interval
|
|
1192
|
+
|
|
1193
|
+
raise DownloadError(f"Download timed out after {self.download_timeout}s")
|
|
1194
|
+
|
|
1195
|
+
finally:
|
|
1196
|
+
# Restore original signal handler
|
|
1197
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
1198
|
+
_interrupt_requested = False
|
|
1199
|
+
|
|
1200
|
+
def _stream_file_locally(
|
|
1201
|
+
self,
|
|
1202
|
+
download_id: str,
|
|
1203
|
+
output_dir: str,
|
|
1204
|
+
output_filename: str,
|
|
1205
|
+
) -> str:
|
|
1206
|
+
"""
|
|
1207
|
+
Stream a completed download from the remote server to local disk.
|
|
1208
|
+
|
|
1209
|
+
Args:
|
|
1210
|
+
download_id: Download ID
|
|
1211
|
+
output_dir: Local directory to save file
|
|
1212
|
+
output_filename: Local filename (without extension)
|
|
1213
|
+
|
|
1214
|
+
Returns:
|
|
1215
|
+
Path to the downloaded local file
|
|
1216
|
+
|
|
1217
|
+
Raises:
|
|
1218
|
+
DownloadError: On streaming failure
|
|
1219
|
+
"""
|
|
1220
|
+
try:
|
|
1221
|
+
# Stream the file from the remote server
|
|
1222
|
+
with httpx.Client() as client:
|
|
1223
|
+
with client.stream(
|
|
1224
|
+
"GET",
|
|
1225
|
+
f"{self.api_url}/download/{download_id}/file",
|
|
1226
|
+
headers=self._headers(),
|
|
1227
|
+
timeout=300, # 5 minute timeout for file streaming
|
|
1228
|
+
) as resp:
|
|
1229
|
+
resp.raise_for_status()
|
|
1230
|
+
|
|
1231
|
+
# Get content-disposition header for filename/extension
|
|
1232
|
+
content_disp = resp.headers.get("content-disposition", "")
|
|
1233
|
+
|
|
1234
|
+
# Try to extract extension from the server's filename
|
|
1235
|
+
extension = ".flac" # Default
|
|
1236
|
+
if "filename=" in content_disp:
|
|
1237
|
+
import re
|
|
1238
|
+
match = re.search(r'filename="?([^";\s]+)"?', content_disp)
|
|
1239
|
+
if match:
|
|
1240
|
+
server_filename = match.group(1)
|
|
1241
|
+
_, ext = os.path.splitext(server_filename)
|
|
1242
|
+
if ext:
|
|
1243
|
+
extension = ext
|
|
1244
|
+
|
|
1245
|
+
# Also try content-type
|
|
1246
|
+
content_type = resp.headers.get("content-type", "")
|
|
1247
|
+
if "audio/mpeg" in content_type or "audio/mp3" in content_type:
|
|
1248
|
+
extension = ".mp3"
|
|
1249
|
+
elif "audio/wav" in content_type:
|
|
1250
|
+
extension = ".wav"
|
|
1251
|
+
elif "audio/x-flac" in content_type or "audio/flac" in content_type:
|
|
1252
|
+
extension = ".flac"
|
|
1253
|
+
elif "audio/mp4" in content_type or "audio/m4a" in content_type:
|
|
1254
|
+
extension = ".m4a"
|
|
1255
|
+
|
|
1256
|
+
# Build local filepath
|
|
1257
|
+
local_filepath = os.path.join(output_dir, f"{output_filename}{extension}")
|
|
1258
|
+
|
|
1259
|
+
# Stream to local file
|
|
1260
|
+
total_bytes = 0
|
|
1261
|
+
with open(local_filepath, "wb") as f:
|
|
1262
|
+
for chunk in resp.iter_bytes(chunk_size=8192):
|
|
1263
|
+
f.write(chunk)
|
|
1264
|
+
total_bytes += len(chunk)
|
|
1265
|
+
|
|
1266
|
+
self.logger.info(f"[RemoteFlacFetcher] Streamed {total_bytes / 1024 / 1024:.1f} MB to {local_filepath}")
|
|
1267
|
+
return local_filepath
|
|
1268
|
+
|
|
1269
|
+
except httpx.RequestError as e:
|
|
1270
|
+
raise DownloadError(f"Failed to stream file: {e}") from e
|
|
1271
|
+
except httpx.HTTPStatusError as e:
|
|
1272
|
+
raise DownloadError(f"Failed to stream file: {e.response.status_code}") from e
|
|
1273
|
+
|
|
1274
|
+
def select_best(self, results: List[AudioSearchResult]) -> int:
|
|
1275
|
+
"""
|
|
1276
|
+
Select the best result from a list of search results.
|
|
1277
|
+
|
|
1278
|
+
For remote fetcher, we use simple heuristics since we don't have
|
|
1279
|
+
access to flacfetch's internal ranking. Prefers:
|
|
1280
|
+
1. Lossless sources (FLAC) over lossy
|
|
1281
|
+
2. Higher seeders for torrents
|
|
1282
|
+
3. First result otherwise (API typically returns sorted by quality)
|
|
1283
|
+
|
|
1284
|
+
Args:
|
|
1285
|
+
results: List of AudioSearchResult objects from search()
|
|
1286
|
+
|
|
1287
|
+
Returns:
|
|
1288
|
+
Index of the best result in the list
|
|
1289
|
+
"""
|
|
1290
|
+
if not results:
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
# Score each result
|
|
1294
|
+
best_index = 0
|
|
1295
|
+
best_score = -1
|
|
1296
|
+
|
|
1297
|
+
for i, result in enumerate(results):
|
|
1298
|
+
score = 0
|
|
1299
|
+
|
|
1300
|
+
# Prefer lossless
|
|
1301
|
+
quality = (result.quality or "").lower()
|
|
1302
|
+
if "flac" in quality or "lossless" in quality:
|
|
1303
|
+
score += 1000
|
|
1304
|
+
elif "320" in quality:
|
|
1305
|
+
score += 500
|
|
1306
|
+
elif "256" in quality or "192" in quality:
|
|
1307
|
+
score += 200
|
|
1308
|
+
|
|
1309
|
+
# Prefer higher seeders (for torrents)
|
|
1310
|
+
if result.seeders:
|
|
1311
|
+
score += min(result.seeders, 100) # Cap at 100 points
|
|
1312
|
+
|
|
1313
|
+
# Prefer non-YouTube sources (typically higher quality)
|
|
1314
|
+
provider = (result.provider or "").lower()
|
|
1315
|
+
if "youtube" not in provider:
|
|
1316
|
+
score += 50
|
|
1317
|
+
|
|
1318
|
+
if score > best_score:
|
|
1319
|
+
best_score = score
|
|
1320
|
+
best_index = i
|
|
1321
|
+
|
|
1322
|
+
return best_index
|
|
1323
|
+
|
|
1324
|
+
def search_and_download(
|
|
1325
|
+
self,
|
|
1326
|
+
artist: str,
|
|
1327
|
+
title: str,
|
|
1328
|
+
output_dir: str,
|
|
1329
|
+
output_filename: Optional[str] = None,
|
|
1330
|
+
auto_select: bool = False,
|
|
1331
|
+
) -> AudioFetchResult:
|
|
1332
|
+
"""
|
|
1333
|
+
Search for audio and download it in one operation via remote API.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
artist: The artist name to search for
|
|
1337
|
+
title: The track title to search for
|
|
1338
|
+
output_dir: Directory to save the downloaded file
|
|
1339
|
+
output_filename: Optional filename (without extension)
|
|
1340
|
+
auto_select: If True, automatically select the best result
|
|
1341
|
+
|
|
1342
|
+
Returns:
|
|
1343
|
+
AudioFetchResult with the downloaded file path
|
|
1344
|
+
|
|
1345
|
+
Raises:
|
|
1346
|
+
NoResultsError: If no results are found
|
|
1347
|
+
DownloadError: If download fails
|
|
1348
|
+
UserCancelledError: If user cancels
|
|
1349
|
+
"""
|
|
1350
|
+
# Search
|
|
1351
|
+
results = self.search(artist, title)
|
|
1352
|
+
|
|
1353
|
+
if auto_select:
|
|
1354
|
+
# Auto mode: select best result
|
|
1355
|
+
best_index = self.select_best(results)
|
|
1356
|
+
selected = results[best_index]
|
|
1357
|
+
self.logger.info(f"[RemoteFlacFetcher] Auto-selected: {selected.title} from {selected.provider}")
|
|
1358
|
+
else:
|
|
1359
|
+
# Interactive mode: present options to user
|
|
1360
|
+
selected = self._interactive_select(results, artist, title)
|
|
1361
|
+
|
|
1362
|
+
# Download
|
|
1363
|
+
return self.download(selected, output_dir, output_filename)
|
|
1364
|
+
|
|
1365
|
+
def _convert_api_result_for_release(self, api_result: dict) -> dict:
|
|
1366
|
+
"""
|
|
1367
|
+
Convert API SearchResultItem format to format expected by Release.from_dict().
|
|
1368
|
+
|
|
1369
|
+
The flacfetch API returns:
|
|
1370
|
+
- provider: source name (RED, OPS, YouTube)
|
|
1371
|
+
- quality: display string (e.g., "FLAC 16bit CD")
|
|
1372
|
+
- quality_data: structured dict with format, bit_depth, media, etc.
|
|
1373
|
+
|
|
1374
|
+
But Release.from_dict() expects:
|
|
1375
|
+
- source_name: provider name
|
|
1376
|
+
- quality: dict with format, bit_depth, media, etc.
|
|
1377
|
+
|
|
1378
|
+
This mirrors the convert_api_result_to_display() function in flacfetch-remote CLI.
|
|
1379
|
+
"""
|
|
1380
|
+
result = dict(api_result) # Copy to avoid modifying original
|
|
1381
|
+
|
|
1382
|
+
# Map provider to source_name
|
|
1383
|
+
result["source_name"] = api_result.get("provider", "Unknown")
|
|
1384
|
+
|
|
1385
|
+
# Store original quality string as quality_str (used by display functions)
|
|
1386
|
+
result["quality_str"] = api_result.get("quality", "")
|
|
1387
|
+
|
|
1388
|
+
# Map quality_data to quality (Release.from_dict expects quality to be a dict)
|
|
1389
|
+
quality_data = api_result.get("quality_data")
|
|
1390
|
+
if quality_data and isinstance(quality_data, dict):
|
|
1391
|
+
result["quality"] = quality_data
|
|
1392
|
+
else:
|
|
1393
|
+
# Fallback: parse quality string to determine format
|
|
1394
|
+
quality_str = api_result.get("quality", "").upper()
|
|
1395
|
+
format_name = "OTHER"
|
|
1396
|
+
media_name = "OTHER"
|
|
1397
|
+
|
|
1398
|
+
if "FLAC" in quality_str:
|
|
1399
|
+
format_name = "FLAC"
|
|
1400
|
+
elif "MP3" in quality_str:
|
|
1401
|
+
format_name = "MP3"
|
|
1402
|
+
elif "WAV" in quality_str:
|
|
1403
|
+
format_name = "WAV"
|
|
1404
|
+
|
|
1405
|
+
if "CD" in quality_str:
|
|
1406
|
+
media_name = "CD"
|
|
1407
|
+
elif "WEB" in quality_str:
|
|
1408
|
+
media_name = "WEB"
|
|
1409
|
+
elif "VINYL" in quality_str:
|
|
1410
|
+
media_name = "VINYL"
|
|
1411
|
+
|
|
1412
|
+
result["quality"] = {"format": format_name, "media": media_name}
|
|
1413
|
+
|
|
1414
|
+
# Copy is_lossless if available
|
|
1415
|
+
if "is_lossless" in api_result:
|
|
1416
|
+
result["is_lossless"] = api_result["is_lossless"]
|
|
1417
|
+
|
|
1418
|
+
return result
|
|
1419
|
+
|
|
1420
|
+
def _interactive_select(
|
|
1421
|
+
self,
|
|
1422
|
+
results: List[AudioSearchResult],
|
|
1423
|
+
artist: str,
|
|
1424
|
+
title: str,
|
|
1425
|
+
) -> AudioSearchResult:
|
|
1426
|
+
"""
|
|
1427
|
+
Present search results to the user for interactive selection.
|
|
1428
|
+
|
|
1429
|
+
Uses flacfetch's built-in display functions if available, otherwise
|
|
1430
|
+
falls back to basic text display.
|
|
1431
|
+
|
|
1432
|
+
Args:
|
|
1433
|
+
results: List of AudioSearchResult objects
|
|
1434
|
+
artist: The artist name being searched
|
|
1435
|
+
title: The track title being searched
|
|
1436
|
+
|
|
1437
|
+
Returns:
|
|
1438
|
+
The selected AudioSearchResult
|
|
1439
|
+
|
|
1440
|
+
Raises:
|
|
1441
|
+
UserCancelledError: If user cancels selection
|
|
1442
|
+
"""
|
|
1443
|
+
# Try to use flacfetch's display functions with raw API results
|
|
1444
|
+
try:
|
|
1445
|
+
# Convert raw_result dicts back to Release objects for display
|
|
1446
|
+
from flacfetch.core.models import Release
|
|
1447
|
+
|
|
1448
|
+
releases = []
|
|
1449
|
+
for r in results:
|
|
1450
|
+
if r.raw_result and isinstance(r.raw_result, dict):
|
|
1451
|
+
# Convert API format to Release.from_dict() format
|
|
1452
|
+
converted = self._convert_api_result_for_release(r.raw_result)
|
|
1453
|
+
release = Release.from_dict(converted)
|
|
1454
|
+
releases.append(release)
|
|
1455
|
+
elif r.raw_result and hasattr(r.raw_result, 'title'):
|
|
1456
|
+
# It's already a Release object
|
|
1457
|
+
releases.append(r.raw_result)
|
|
1458
|
+
|
|
1459
|
+
if releases:
|
|
1460
|
+
from flacfetch.interface.cli import CLIHandler
|
|
1461
|
+
handler = CLIHandler(target_artist=artist)
|
|
1462
|
+
selected_release = handler.select_release(releases)
|
|
1463
|
+
|
|
1464
|
+
if selected_release is None:
|
|
1465
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
1466
|
+
|
|
1467
|
+
# Find the matching AudioSearchResult by index
|
|
1468
|
+
# CLIHandler returns the release at the selected index
|
|
1469
|
+
for i, release in enumerate(releases):
|
|
1470
|
+
if release == selected_release:
|
|
1471
|
+
return results[i]
|
|
1472
|
+
|
|
1473
|
+
# Fallback: try matching by download_url
|
|
1474
|
+
for r in results:
|
|
1475
|
+
if r.raw_result == selected_release or (
|
|
1476
|
+
isinstance(r.raw_result, dict) and
|
|
1477
|
+
r.raw_result.get("download_url") == getattr(selected_release, "download_url", None)
|
|
1478
|
+
):
|
|
1479
|
+
return r
|
|
1480
|
+
|
|
1481
|
+
except (ImportError, AttributeError, TypeError) as e:
|
|
1482
|
+
self.logger.debug(f"[RemoteFlacFetcher] Falling back to basic display: {e}")
|
|
1483
|
+
|
|
1484
|
+
# Fallback to basic display
|
|
1485
|
+
return self._basic_interactive_select(results, artist, title)
|
|
1486
|
+
|
|
1487
|
+
def _basic_interactive_select(
|
|
1488
|
+
self,
|
|
1489
|
+
results: List[AudioSearchResult],
|
|
1490
|
+
artist: str,
|
|
1491
|
+
title: str,
|
|
1492
|
+
) -> AudioSearchResult:
|
|
1493
|
+
"""
|
|
1494
|
+
Basic fallback for interactive selection without rich formatting.
|
|
1495
|
+
|
|
1496
|
+
Args:
|
|
1497
|
+
results: List of AudioSearchResult objects
|
|
1498
|
+
artist: The artist name being searched
|
|
1499
|
+
title: The track title being searched
|
|
1500
|
+
|
|
1501
|
+
Returns:
|
|
1502
|
+
The selected AudioSearchResult
|
|
1503
|
+
|
|
1504
|
+
Raises:
|
|
1505
|
+
UserCancelledError: If user cancels selection
|
|
1506
|
+
"""
|
|
1507
|
+
print(f"\nFound {len(results)} releases:\n")
|
|
1508
|
+
|
|
1509
|
+
for i, result in enumerate(results, 1):
|
|
1510
|
+
# Try to get lossless info from raw_result (API response)
|
|
1511
|
+
is_lossless = False
|
|
1512
|
+
if result.raw_result and isinstance(result.raw_result, dict):
|
|
1513
|
+
is_lossless = result.raw_result.get("is_lossless", False)
|
|
1514
|
+
elif result.quality:
|
|
1515
|
+
is_lossless = "flac" in result.quality.lower() or "lossless" in result.quality.lower()
|
|
1516
|
+
|
|
1517
|
+
format_indicator = "[LOSSLESS]" if is_lossless else "[lossy]"
|
|
1518
|
+
quality = f"({result.quality})" if result.quality else ""
|
|
1519
|
+
provider = f"[{result.provider}]" if result.provider else ""
|
|
1520
|
+
seeders = f"Seeders: {result.seeders}" if result.seeders else ""
|
|
1521
|
+
duration = ""
|
|
1522
|
+
if result.duration:
|
|
1523
|
+
mins, secs = divmod(result.duration, 60)
|
|
1524
|
+
duration = f"[{int(mins)}:{int(secs):02d}]"
|
|
1525
|
+
|
|
1526
|
+
print(f"{i}. {format_indicator} {provider} {result.artist}: {result.title} {quality} {duration} {seeders}")
|
|
1527
|
+
|
|
1528
|
+
print()
|
|
1529
|
+
|
|
1530
|
+
while True:
|
|
1531
|
+
try:
|
|
1532
|
+
choice = input(f"Select a release (1-{len(results)}, 0 to cancel): ").strip()
|
|
1533
|
+
|
|
1534
|
+
if choice == "0":
|
|
1535
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
1536
|
+
|
|
1537
|
+
choice_num = int(choice)
|
|
1538
|
+
if 1 <= choice_num <= len(results):
|
|
1539
|
+
selected = results[choice_num - 1]
|
|
1540
|
+
self.logger.info(f"[RemoteFlacFetcher] User selected option {choice_num}")
|
|
1541
|
+
return selected
|
|
1542
|
+
else:
|
|
1543
|
+
print(f"Please enter a number between 0 and {len(results)}")
|
|
1544
|
+
|
|
1545
|
+
except ValueError:
|
|
1546
|
+
print("Please enter a valid number")
|
|
1547
|
+
except (KeyboardInterrupt, EOFError):
|
|
1548
|
+
print("\nCancelled")
|
|
1549
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
# Alias for shorter name
|
|
1553
|
+
RemoteFlacFetcher = RemoteFlacFetchAudioFetcher
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def create_audio_fetcher(
|
|
1557
|
+
logger: Optional[logging.Logger] = None,
|
|
1558
|
+
red_api_key: Optional[str] = None,
|
|
1559
|
+
red_api_url: Optional[str] = None,
|
|
1560
|
+
ops_api_key: Optional[str] = None,
|
|
1561
|
+
ops_api_url: Optional[str] = None,
|
|
1562
|
+
flacfetch_api_url: Optional[str] = None,
|
|
1563
|
+
flacfetch_api_key: Optional[str] = None,
|
|
1564
|
+
) -> AudioFetcher:
|
|
1565
|
+
"""
|
|
1566
|
+
Factory function to create an appropriate AudioFetcher instance.
|
|
1567
|
+
|
|
1568
|
+
If FLACFETCH_API_URL and FLACFETCH_API_KEY environment variables are set
|
|
1569
|
+
(or passed as arguments), returns a RemoteFlacFetchAudioFetcher that uses
|
|
1570
|
+
the remote flacfetch HTTP API server.
|
|
1571
|
+
|
|
1572
|
+
Otherwise, returns a local FlacFetchAudioFetcher that uses the flacfetch
|
|
1573
|
+
library directly.
|
|
1574
|
+
|
|
1575
|
+
Args:
|
|
1576
|
+
logger: Logger instance for output
|
|
1577
|
+
red_api_key: API key for RED tracker (optional, for local mode)
|
|
1578
|
+
red_api_url: Base URL for RED tracker API (optional, for local mode)
|
|
1579
|
+
ops_api_key: API key for OPS tracker (optional, for local mode)
|
|
1580
|
+
ops_api_url: Base URL for OPS tracker API (optional, for local mode)
|
|
1581
|
+
flacfetch_api_url: URL of remote flacfetch API server (optional)
|
|
1582
|
+
flacfetch_api_key: API key for remote flacfetch server (optional)
|
|
1583
|
+
|
|
1584
|
+
Returns:
|
|
1585
|
+
An AudioFetcher instance (remote or local depending on configuration)
|
|
1586
|
+
"""
|
|
1587
|
+
# Check for remote flacfetch API configuration
|
|
1588
|
+
api_url = flacfetch_api_url or os.environ.get("FLACFETCH_API_URL")
|
|
1589
|
+
api_key = flacfetch_api_key or os.environ.get("FLACFETCH_API_KEY")
|
|
1590
|
+
|
|
1591
|
+
if api_url and api_key:
|
|
1592
|
+
# Use remote flacfetch API
|
|
1593
|
+
if logger:
|
|
1594
|
+
logger.info(f"Using remote flacfetch API at: {api_url}")
|
|
1595
|
+
return RemoteFlacFetchAudioFetcher(
|
|
1596
|
+
api_url=api_url,
|
|
1597
|
+
api_key=api_key,
|
|
1598
|
+
logger=logger,
|
|
1599
|
+
)
|
|
1600
|
+
elif api_url and not api_key:
|
|
1601
|
+
if logger:
|
|
1602
|
+
logger.warning("FLACFETCH_API_URL is set but FLACFETCH_API_KEY is not - falling back to local mode")
|
|
1603
|
+
elif api_key and not api_url:
|
|
1604
|
+
if logger:
|
|
1605
|
+
logger.warning("FLACFETCH_API_KEY is set but FLACFETCH_API_URL is not - falling back to local mode")
|
|
1606
|
+
|
|
1607
|
+
# Use local flacfetch library
|
|
1608
|
+
return FlacFetchAudioFetcher(
|
|
1609
|
+
logger=logger,
|
|
1610
|
+
red_api_key=red_api_key,
|
|
1611
|
+
red_api_url=red_api_url,
|
|
1612
|
+
ops_api_key=ops_api_key,
|
|
1613
|
+
ops_api_url=ops_api_url,
|
|
1614
|
+
)
|