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.
pull/615/head
Broque Thomas 1 week ago
parent 85984d4174
commit fa73c41ef6

@ -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…
Cancel
Save