Wire Amazon Music as a first-class download source
Follows the exact same standard as Tidal, Qobuz, HiFi, and Deezer. registry.py — import + register AmazonDownloadClient as 'amazon'. amazon_download_client.py — read amazon_download.quality / allow_fallback from config on init; pass quality as preferred_codec to AmazonClient; _download_sync codec waterfall respects allow_fallback flag. download_orchestrator.py — reload_settings() updates preferred_codec + allow_fallback on the live client after a settings save. 'amazon' added to _streaming_sources so search_and_download_best routes it correctly. api_call_tracker.py — 'amazon' registered in RATE_LIMITS (120/min), SERVICE_LABELS, and SERVICE_ORDER so API call monitoring shows Amazon. web_server.py — 'amazon_download' added to the settings service loop. 'amazon' added to serverless_sources (no slskd probe needed). Streaming file-finder extended to handle amazon username + ||asin||title encoding (extension-less fuzzy match, same as Tidal/Qobuz/HiFi). New endpoint: GET /api/amazon/test-connection → checks T2Tunes proxy status. webui/index.html — amazon-download-settings-container: quality dropdown (flac/opus/eac3), allow-fallback checkbox, test-connection button. webui/static/settings.js — 'Amazon Music' added to HYBRID_SOURCES, _hybridSourceEnabled, allSources mode list, loadSettings(), saveSettings() payload, updateDownloadSourceUI() show/hide + auto-test. New testAmazonConnection() function.
This commit is contained in:
parent
85984d4174
commit
fa73c41ef6
7 changed files with 120 additions and 15 deletions
|
|
@ -78,7 +78,10 @@ class AmazonDownloadClient(DownloadSourcePlugin):
|
|||
self.download_path = Path(download_path)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._client = AmazonClient()
|
||||
self._quality = config_manager.get("amazon_download.quality", "flac")
|
||||
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
|
||||
|
||||
self._client = AmazonClient(preferred_codec=self._quality)
|
||||
self.session = http_requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "SoulSync/1.0",
|
||||
|
|
@ -213,7 +216,8 @@ class AmazonDownloadClient(DownloadSourcePlugin):
|
|||
display_name: str,
|
||||
) -> Optional[str]:
|
||||
asin = str(target_id)
|
||||
for codec in CODEC_PREFERENCE:
|
||||
codecs = CODEC_PREFERENCE if self._allow_fallback else [self._quality]
|
||||
for codec in codecs:
|
||||
try:
|
||||
streams = self._client.media_from_asin(asin, codec=codec)
|
||||
except AmazonClientError as exc:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ RATE_LIMITS = {
|
|||
'tidal': 120, # MIN_API_INTERVAL=0.5s → ~120/min
|
||||
'qobuz': 60, # Variable throttle, ~60/min estimate
|
||||
'discogs': 60, # MIN_API_INTERVAL=1.0s with auth → ~60/min
|
||||
'amazon': 120, # MIN_API_INTERVAL=0.5s → ~120/min (T2Tunes proxy)
|
||||
}
|
||||
|
||||
# Display names for UI
|
||||
|
|
@ -44,12 +45,13 @@ SERVICE_LABELS = {
|
|||
'tidal': 'Tidal',
|
||||
'qobuz': 'Qobuz',
|
||||
'discogs': 'Discogs',
|
||||
'amazon': 'Amazon Music',
|
||||
}
|
||||
|
||||
# Display order
|
||||
SERVICE_ORDER = [
|
||||
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,15 @@ class DownloadOrchestrator:
|
|||
deezer_dl.reconnect(deezer_arl)
|
||||
deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||
|
||||
# Reload Amazon quality preference (T2Tunes needs no reconnect — public proxy)
|
||||
amazon = self.client('amazon')
|
||||
if amazon:
|
||||
quality = config_manager.get('amazon_download.quality', 'flac')
|
||||
amazon._quality = quality
|
||||
amazon._allow_fallback = config_manager.get('amazon_download.allow_fallback', True)
|
||||
if hasattr(amazon, '_client') and amazon._client:
|
||||
amazon._client.preferred_codec = quality
|
||||
|
||||
# Reload download path for all clients that cache it.
|
||||
# Soulseek owns the path config and is reloaded above; every
|
||||
# other source mirrors that path so files all land in one
|
||||
|
|
@ -319,7 +328,7 @@ class DownloadOrchestrator:
|
|||
return None
|
||||
|
||||
# 2. Filter and validate results
|
||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
|
||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
|
||||
is_streaming = tracks[0].username in _streaming_sources if tracks else False
|
||||
|
||||
if is_streaming and expected_track:
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from core.download_plugins.base import DownloadSourcePlugin
|
|||
# than the legacy module-top imports here. Importing everything at
|
||||
# registry-load time pins the bindings the same way the legacy
|
||||
# orchestrator did.
|
||||
from core.amazon_download_client import AmazonDownloadClient
|
||||
from core.deezer_download_client import DeezerDownloadClient
|
||||
from core.hifi_client import HiFiClient
|
||||
from core.lidarr_download_client import LidarrDownloadClient
|
||||
|
|
@ -176,6 +177,7 @@ def build_default_registry() -> DownloadPluginRegistry:
|
|||
"""
|
||||
registry = DownloadPluginRegistry()
|
||||
|
||||
registry.register(PluginSpec(name='amazon', factory=AmazonDownloadClient, display_name='Amazon Music'))
|
||||
registry.register(PluginSpec(name='soulseek', factory=SoulseekClient, display_name='Soulseek'))
|
||||
registry.register(PluginSpec(name='youtube', factory=YouTubeClient, display_name='YouTube'))
|
||||
registry.register(PluginSpec(name='tidal', factory=TidalDownloadClient, display_name='Tidal'))
|
||||
|
|
|
|||
|
|
@ -1750,21 +1750,22 @@ def _find_downloaded_file(download_path, track_data):
|
|||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||
target_filename = extract_filename(track_data.get('filename', ''))
|
||||
|
||||
# YOUTUBE/TIDAL/QOBUZ/HIFI SUPPORT: Handle encoded filename format "id||title"
|
||||
# YOUTUBE/TIDAL/QOBUZ/HIFI/AMAZON SUPPORT: Handle encoded filename format "id||title"
|
||||
# The file on disk will be "title.ext", not "id||title"
|
||||
is_youtube = track_data.get('username') == 'youtube'
|
||||
is_tidal = track_data.get('username') == 'tidal'
|
||||
is_qobuz = track_data.get('username') == 'qobuz'
|
||||
is_hifi = track_data.get('username') == 'hifi'
|
||||
is_streaming_source = is_youtube or is_tidal or is_qobuz or is_hifi
|
||||
is_amazon = track_data.get('username') == 'amazon'
|
||||
is_streaming_source = is_youtube or is_tidal or is_qobuz or is_hifi or is_amazon
|
||||
target_filename_youtube = None
|
||||
if is_streaming_source and '||' in target_filename:
|
||||
_, title = target_filename.split('||', 1)
|
||||
if is_tidal or is_qobuz or is_hifi:
|
||||
# Tidal/Qobuz/HiFi files can be flac or m4a — match any audio extension
|
||||
if is_tidal or is_qobuz or is_hifi or is_amazon:
|
||||
# Tidal/Qobuz/HiFi/Amazon files can be flac, opus, eac3, or m4a — match any audio extension
|
||||
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
||||
target_filename_youtube = safe_title # Extension-less for flexible matching
|
||||
source_name = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else 'Tidal')
|
||||
source_name = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Amazon' if is_amazon else 'Tidal'))
|
||||
logger.debug(f"[{source_name} Stream] Looking for file starting with: {target_filename_youtube}")
|
||||
else:
|
||||
# yt-dlp will create "Title.mp3" from "Title"
|
||||
|
|
@ -1802,11 +1803,11 @@ def _find_downloaded_file(download_path, track_data):
|
|||
# For Tidal, compare without extension (file could be .flac or .m4a)
|
||||
compare_target = target_filename_youtube.lower()
|
||||
compare_file = file.lower()
|
||||
if is_tidal or is_qobuz or is_hifi:
|
||||
if is_tidal or is_qobuz or is_hifi or is_amazon:
|
||||
compare_file = os.path.splitext(compare_file)[0]
|
||||
similarity = SequenceMatcher(None, compare_file, compare_target).ratio()
|
||||
|
||||
source_label = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube'))
|
||||
source_label = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Amazon' if is_amazon else ('Tidal' if is_tidal else 'YouTube')))
|
||||
logger.debug(f"[{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
|
||||
|
||||
# Keep track of best match
|
||||
|
|
@ -1826,7 +1827,7 @@ def _find_downloaded_file(download_path, track_data):
|
|||
|
||||
# For YouTube/Tidal, if we found a good enough match (80%+), use it
|
||||
if is_streaming_source and best_match and best_similarity >= 0.80:
|
||||
source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube')
|
||||
source_label = 'Qobuz' if is_qobuz else ('Amazon' if is_amazon else ('Tidal' if is_tidal else 'YouTube'))
|
||||
logger.debug(f"Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}")
|
||||
return best_match
|
||||
|
||||
|
|
@ -2121,7 +2122,7 @@ def get_status():
|
|||
# don't depend on slskd being reachable — when one of these is the
|
||||
# active source, surface "connected" without probing slskd so the
|
||||
# dashboard / sidebar indicator stays green.
|
||||
serverless_sources = ('youtube', 'hifi', 'qobuz', 'tidal', 'deezer_dl', 'lidarr', 'soundcloud')
|
||||
serverless_sources = ('youtube', 'hifi', 'qobuz', 'tidal', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
|
||||
is_serverless = (download_mode in serverless_sources or
|
||||
(download_mode == 'hybrid' and
|
||||
hybrid_order and any(s in serverless_sources for s in hybrid_order)))
|
||||
|
|
@ -2748,7 +2749,7 @@ def handle_settings():
|
|||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'lidarr_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
@ -19683,6 +19684,26 @@ def deezer_download_test_download():
|
|||
|
||||
|
||||
# ===================================================================
|
||||
# AMAZON DOWNLOAD ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
@app.route('/api/amazon/test-connection', methods=['GET'])
|
||||
@admin_only
|
||||
def amazon_test_connection():
|
||||
"""Check whether the T2Tunes proxy is up and Amazon Music is reachable."""
|
||||
try:
|
||||
from core.amazon_client import AmazonClient
|
||||
c = AmazonClient()
|
||||
status = c.status()
|
||||
amazon_up = str(status.get('amazonMusic', '')).lower() == 'up'
|
||||
return jsonify({
|
||||
'connected': amazon_up,
|
||||
'status': status,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'connected': False, 'error': str(e)}), 200
|
||||
|
||||
|
||||
# TIDAL DOWNLOAD AUTH ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -4616,6 +4616,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amazon Music Download Settings (shown only when amazon mode is selected) -->
|
||||
<div id="amazon-download-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Amazon Music Quality:</label>
|
||||
<select id="amazon-quality" class="form-select">
|
||||
<option value="flac">FLAC Lossless (24-bit/48kHz Hi-Res)</option>
|
||||
<option value="opus">Opus (320kbps)</option>
|
||||
<option value="eac3">EAC3 Dolby Atmos (768kbps 5.1)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
Preferred codec tier. FLAC is 24-bit/48kHz Hi-Res — no subscription required.
|
||||
Downloads via T2Tunes proxy.
|
||||
</div>
|
||||
<label class="checkbox-inline" style="margin-top: 8px;">
|
||||
<input type="checkbox" id="amazon-allow-fallback" checked>
|
||||
Allow quality fallback
|
||||
</label>
|
||||
<div class="setting-help-text">
|
||||
Fall back to the next codec tier if the preferred one is unavailable.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Connection:</label>
|
||||
<div class="form-actions" style="margin-top: 4px;">
|
||||
<button class="test-button" onclick="testAmazonConnection()">Test Connection</button>
|
||||
<span id="amazon-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
|
||||
</div>
|
||||
<div class="setting-help-text">
|
||||
No account required — T2Tunes is a public Amazon Music proxy.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SoundCloud Download Settings -->
|
||||
<div id="soundcloud-download-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -595,12 +595,13 @@ const HYBRID_SOURCES = [
|
|||
{ id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
|
||||
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
|
||||
{ id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' },
|
||||
{ id: 'amazon', name: 'Amazon Music', icon: null, emoji: '🛒' },
|
||||
{ id: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' },
|
||||
{ id: 'soundcloud', name: 'SoundCloud', icon: 'https://www.svgrepo.com/show/452219/soundcloud.svg', emoji: '☁️' },
|
||||
];
|
||||
|
||||
let _hybridSourceOrder = ['soulseek', 'youtube'];
|
||||
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false, soundcloud: false };
|
||||
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, amazon: false, lidarr: false, soundcloud: false };
|
||||
let _hybridVisualOrder = null; // Full visual order including disabled sources
|
||||
|
||||
function buildHybridSourceList() {
|
||||
|
|
@ -942,6 +943,8 @@ async function loadSettingsData() {
|
|||
document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac';
|
||||
document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
|
||||
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
|
||||
document.getElementById('amazon-quality').value = settings.amazon_download?.quality || 'flac';
|
||||
document.getElementById('amazon-allow-fallback').checked = settings.amazon_download?.allow_fallback !== false;
|
||||
document.getElementById('lidarr-url').value = settings.lidarr_download?.url || '';
|
||||
document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || '';
|
||||
// Sync ARL to connections tab field + bidirectional listeners
|
||||
|
|
@ -1477,6 +1480,7 @@ function updateDownloadSourceUI() {
|
|||
const youtubeContainer = document.getElementById('youtube-settings-container');
|
||||
const hifiContainer = document.getElementById('hifi-download-settings-container');
|
||||
const deezerDlContainer = document.getElementById('deezer-download-settings-container');
|
||||
const amazonContainer = document.getElementById('amazon-download-settings-container');
|
||||
const lidarrContainer = document.getElementById('lidarr-download-settings-container');
|
||||
const soundcloudContainer = document.getElementById('soundcloud-download-settings-container');
|
||||
|
||||
|
|
@ -1499,6 +1503,7 @@ function updateDownloadSourceUI() {
|
|||
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
||||
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
|
||||
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
|
||||
if (amazonContainer) amazonContainer.style.display = activeSources.has('amazon') ? 'block' : 'none';
|
||||
if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
|
||||
if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none';
|
||||
|
||||
|
|
@ -1519,6 +1524,9 @@ function updateDownloadSourceUI() {
|
|||
if (activeSources.has('hifi')) {
|
||||
testHiFiConnection();
|
||||
}
|
||||
if (activeSources.has('amazon')) {
|
||||
testAmazonConnection();
|
||||
}
|
||||
if (activeSources.has('soundcloud')) {
|
||||
testSoundcloudConnection();
|
||||
}
|
||||
|
|
@ -1535,6 +1543,7 @@ function updateHybridSecondaryOptions() {
|
|||
{ value: 'qobuz', label: 'Qobuz' },
|
||||
{ value: 'hifi', label: 'HiFi' },
|
||||
{ value: 'deezer_dl', label: 'Deezer' },
|
||||
{ value: 'amazon', label: 'Amazon Music' },
|
||||
{ value: 'lidarr', label: 'Lidarr' },
|
||||
{ value: 'soundcloud', label: 'SoundCloud' },
|
||||
];
|
||||
|
|
@ -2675,6 +2684,10 @@ async function saveSettings(quiet = false) {
|
|||
arl: document.getElementById('deezer-download-arl').value || '',
|
||||
allow_fallback: document.getElementById('deezer-allow-fallback').checked,
|
||||
},
|
||||
amazon_download: {
|
||||
quality: document.getElementById('amazon-quality').value || 'flac',
|
||||
allow_fallback: document.getElementById('amazon-allow-fallback').checked,
|
||||
},
|
||||
lidarr_download: {
|
||||
url: document.getElementById('lidarr-url').value || '',
|
||||
api_key: document.getElementById('lidarr-api-key').value || '',
|
||||
|
|
@ -3758,6 +3771,27 @@ async function testDeezerDownloadConnection() {
|
|||
}
|
||||
}
|
||||
|
||||
async function testAmazonConnection() {
|
||||
const statusEl = document.getElementById('amazon-connection-status');
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = 'Checking...';
|
||||
statusEl.style.color = '#aaa';
|
||||
try {
|
||||
const resp = await fetch('/api/amazon/test-connection');
|
||||
const data = await resp.json();
|
||||
if (data.connected) {
|
||||
statusEl.textContent = '✓ Connected — T2Tunes up';
|
||||
statusEl.style.color = '#4caf50';
|
||||
} else {
|
||||
statusEl.textContent = '✗ ' + (data.error || 'T2Tunes unreachable');
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = '✗ Connection error';
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTidalDownloadAuthStatus() {
|
||||
const statusEl = document.getElementById('tidal-download-auth-status');
|
||||
const btn = document.getElementById('tidal-download-auth-btn');
|
||||
|
|
|
|||
Loading…
Reference in a new issue