Improve watchlist cross-provider matching accuracy and add manual artist linking UI
This commit is contained in:
parent
0e89155c15
commit
32f1cc946c
5 changed files with 592 additions and 21 deletions
|
|
@ -841,8 +841,9 @@ class WatchlistScanner:
|
|||
return
|
||||
|
||||
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...")
|
||||
|
||||
|
||||
matched_count = 0
|
||||
unmatched_names = []
|
||||
for artist in artists_to_match:
|
||||
try:
|
||||
if provider == 'spotify':
|
||||
|
|
@ -852,7 +853,9 @@ class WatchlistScanner:
|
|||
artist.spotify_artist_id = new_id # Update in memory
|
||||
matched_count += 1
|
||||
logger.info(f"✅ Matched '{artist.artist_name}' to Spotify: {new_id}")
|
||||
|
||||
else:
|
||||
unmatched_names.append(artist.artist_name)
|
||||
|
||||
elif provider == 'itunes':
|
||||
new_id = self._match_to_itunes(artist.artist_name)
|
||||
if new_id:
|
||||
|
|
@ -860,42 +863,114 @@ class WatchlistScanner:
|
|||
artist.itunes_artist_id = new_id # Update in memory
|
||||
matched_count += 1
|
||||
logger.info(f"✅ Matched '{artist.artist_name}' to iTunes: {new_id}")
|
||||
|
||||
else:
|
||||
unmatched_names.append(artist.artist_name)
|
||||
|
||||
# Small delay to avoid API rate limits
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not match '{artist.artist_name}' to {provider}: {e}")
|
||||
unmatched_names.append(artist.artist_name)
|
||||
continue
|
||||
|
||||
|
||||
logger.info(f"✅ Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs")
|
||||
if unmatched_names:
|
||||
logger.warning(f"⚠️ Could not confidently match {len(unmatched_names)} artists: {', '.join(unmatched_names[:10])}"
|
||||
f"{'...' if len(unmatched_names) > 10 else ''} — use Watchlist Settings to link manually")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_artist_name(name: str) -> str:
|
||||
"""Normalize artist name for comparison."""
|
||||
if not name:
|
||||
return ""
|
||||
s = name.lower().strip()
|
||||
# Remove "the " prefix
|
||||
s = re.sub(r'^the\s+', '', s)
|
||||
# Remove non-alphanumeric except spaces
|
||||
s = re.sub(r'[^\w\s]', '', s)
|
||||
# Collapse whitespace
|
||||
s = re.sub(r'\s+', ' ', s).strip()
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def _artist_name_similarity(name_a: str, name_b: str) -> float:
|
||||
"""Calculate similarity between two artist names (0.0-1.0)."""
|
||||
from difflib import SequenceMatcher
|
||||
na = WatchlistScanner._normalize_artist_name(name_a)
|
||||
nb = WatchlistScanner._normalize_artist_name(name_b)
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
if na == nb:
|
||||
return 1.0
|
||||
return SequenceMatcher(None, na, nb).ratio()
|
||||
|
||||
def _best_artist_match(self, results, artist_name: str) -> Optional[str]:
|
||||
"""Pick the best matching artist from search results using name similarity.
|
||||
|
||||
Returns the artist ID only if we're confident it's the right match.
|
||||
"""
|
||||
if not results:
|
||||
return None
|
||||
|
||||
# Exact normalized match gets immediate acceptance
|
||||
for r in results:
|
||||
if self._normalize_artist_name(r.name) == self._normalize_artist_name(artist_name):
|
||||
logger.info(f" Exact match: '{r.name}' (id={r.id})")
|
||||
return r.id
|
||||
|
||||
# Score all results by name similarity + popularity bonus
|
||||
candidates = []
|
||||
for r in results:
|
||||
sim = self._artist_name_similarity(artist_name, r.name)
|
||||
# Small popularity bonus (max 0.05) to break ties between similar names
|
||||
pop_bonus = (getattr(r, 'popularity', 0) / 100) * 0.05
|
||||
score = sim + pop_bonus
|
||||
candidates.append((r, sim, score))
|
||||
logger.debug(f" Candidate: '{r.name}' sim={sim:.2f} pop={getattr(r, 'popularity', 0)} score={score:.3f}")
|
||||
|
||||
# Sort by score descending
|
||||
candidates.sort(key=lambda x: x[2], reverse=True)
|
||||
best, best_sim, best_score = candidates[0]
|
||||
|
||||
# Require high similarity to accept (0.85 threshold)
|
||||
if best_sim >= 0.85:
|
||||
logger.info(f" Best match: '{best.name}' (sim={best_sim:.2f}, id={best.id})")
|
||||
return best.id
|
||||
|
||||
# Between 0.70-0.85: accept only if it's clearly better than runner-up
|
||||
if best_sim >= 0.70 and len(candidates) > 1:
|
||||
runner_up_sim = candidates[1][1]
|
||||
if best_sim - runner_up_sim >= 0.15:
|
||||
logger.info(f" Best match (clear winner): '{best.name}' (sim={best_sim:.2f}, id={best.id})")
|
||||
return best.id
|
||||
|
||||
logger.warning(f" No confident match for '{artist_name}' — best was '{best.name}' (sim={best_sim:.2f})")
|
||||
return None
|
||||
|
||||
def _match_to_spotify(self, artist_name: str) -> Optional[str]:
|
||||
"""Match artist name to Spotify ID"""
|
||||
"""Match artist name to Spotify ID using fuzzy name comparison."""
|
||||
try:
|
||||
# Use metadata service if available, fallback to spotify_client
|
||||
if hasattr(self, '_metadata_service') and self._metadata_service:
|
||||
results = self._metadata_service.spotify.search_artists(artist_name, limit=1)
|
||||
results = self._metadata_service.spotify.search_artists(artist_name, limit=5)
|
||||
else:
|
||||
results = self.spotify_client.search_artists(artist_name, limit=1)
|
||||
|
||||
if results:
|
||||
return results[0].id
|
||||
results = self.spotify_client.search_artists(artist_name, limit=5)
|
||||
|
||||
return self._best_artist_match(results, artist_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not match {artist_name} to Spotify: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _match_to_itunes(self, artist_name: str) -> Optional[str]:
|
||||
"""Match artist name to iTunes ID"""
|
||||
"""Match artist name to iTunes ID using fuzzy name comparison."""
|
||||
try:
|
||||
# Use metadata service's iTunes client
|
||||
if hasattr(self, '_metadata_service') and self._metadata_service:
|
||||
results = self._metadata_service.itunes.search_artists(artist_name, limit=1)
|
||||
if results:
|
||||
return results[0].id
|
||||
results = self._metadata_service.itunes.search_artists(artist_name, limit=5)
|
||||
else:
|
||||
# iTunes client not available without metadata service
|
||||
logger.warning(f"Cannot match to iTunes - MetadataService not available")
|
||||
return None
|
||||
|
||||
return self._best_artist_match(results, artist_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not match {artist_name} to iTunes: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -29050,7 +29050,10 @@ def watchlist_artist_config(artist_id):
|
|||
"success": True,
|
||||
"config": config,
|
||||
"artist": artist_info,
|
||||
"recent_releases": releases
|
||||
"recent_releases": releases,
|
||||
"spotify_artist_id": spotify_id,
|
||||
"itunes_artist_id": itunes_id,
|
||||
"watchlist_name": result[7], # Original stored watchlist artist name
|
||||
})
|
||||
|
||||
else: # POST
|
||||
|
|
@ -29112,6 +29115,74 @@ def watchlist_artist_config(artist_id):
|
|||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/watchlist/artist/<artist_id>/link-provider', methods=['POST'])
|
||||
def watchlist_artist_link_provider(artist_id):
|
||||
"""Manually link a watchlist artist to a different Spotify/iTunes artist."""
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
database = get_database()
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||
|
||||
new_provider_id = data.get('provider_id', '').strip()
|
||||
provider = data.get('provider', '').strip() # 'spotify' or 'itunes'
|
||||
|
||||
if not new_provider_id or provider not in ('spotify', 'itunes'):
|
||||
return jsonify({"success": False, "error": "Missing provider or provider_id"}), 400
|
||||
|
||||
conn = sqlite3.connect(str(database.database_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Find the watchlist artist row
|
||||
cursor.execute("""
|
||||
SELECT id, artist_name, spotify_artist_id, itunes_artist_id
|
||||
FROM watchlist_artists
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
|
||||
""", (artist_id, artist_id))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({"success": False, "error": "Artist not found in watchlist"}), 404
|
||||
|
||||
watchlist_row_id = row[0]
|
||||
artist_name = row[1]
|
||||
|
||||
# Check for duplicate — another watchlist artist already has this provider ID
|
||||
col = 'spotify_artist_id' if provider == 'spotify' else 'itunes_artist_id'
|
||||
cursor.execute(f"SELECT id, artist_name FROM watchlist_artists WHERE {col} = ? AND id != ?",
|
||||
(new_provider_id, watchlist_row_id))
|
||||
duplicate = cursor.fetchone()
|
||||
if duplicate:
|
||||
conn.close()
|
||||
return jsonify({"success": False, "error": f"Another watchlist artist ('{duplicate[1]}') already has this {provider} ID"}), 409
|
||||
|
||||
if provider == 'spotify':
|
||||
cursor.execute("UPDATE watchlist_artists SET spotify_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_provider_id, watchlist_row_id))
|
||||
else:
|
||||
cursor.execute("UPDATE watchlist_artists SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_provider_id, watchlist_row_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"✅ Manually linked watchlist artist '{artist_name}' to {provider} ID: {new_provider_id}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Linked to {provider} artist successfully",
|
||||
"new_provider_id": new_provider_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error linking watchlist artist provider: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/watchlist/global-config', methods=['GET', 'POST'])
|
||||
def watchlist_global_config():
|
||||
"""Get or update global watchlist configuration (overrides per-artist settings)"""
|
||||
|
|
|
|||
|
|
@ -5075,6 +5075,15 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Provider Section -->
|
||||
<div class="config-section" id="watchlist-linked-provider-section" style="display:none">
|
||||
<h3 class="config-section-title">Linked Artist</h3>
|
||||
<p class="config-section-subtitle">The metadata provider artist linked to this watchlist entry</p>
|
||||
<div id="watchlist-linked-provider-content">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="watchlist-artist-config-footer">
|
||||
|
|
|
|||
|
|
@ -32042,6 +32042,188 @@ function closeWatchlistModal() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the linked provider section in the watchlist config modal.
|
||||
* Shows which Spotify/iTunes artist is linked and allows changing it.
|
||||
*/
|
||||
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo) {
|
||||
const section = document.getElementById('watchlist-linked-provider-section');
|
||||
const content = document.getElementById('watchlist-linked-provider-content');
|
||||
if (!section || !content) return;
|
||||
|
||||
// Determine which providers are linked
|
||||
const hasSpotify = !!spotifyId;
|
||||
const hasItunes = !!itunesId;
|
||||
|
||||
if (!hasSpotify && !hasItunes) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = '';
|
||||
|
||||
// Build linked artist display — show a card for each linked provider
|
||||
// The artist info from the API is for the currently active provider
|
||||
const linkedName = artistInfo?.name || artistName;
|
||||
const linkedImage = artistInfo?.image_url || '';
|
||||
const nameMatches = linkedName.toLowerCase().trim() === artistName.toLowerCase().trim();
|
||||
|
||||
let html = `<div class="watchlist-linked-artist-card">`;
|
||||
|
||||
if (linkedImage) {
|
||||
html += `<img src="${linkedImage}" alt="" class="watchlist-linked-artist-img">`;
|
||||
} else {
|
||||
html += `<div class="watchlist-linked-artist-img-placeholder">🎵</div>`;
|
||||
}
|
||||
|
||||
html += `<div class="watchlist-linked-artist-info">`;
|
||||
html += `<div class="watchlist-linked-artist-name">${escapeHtml(linkedName)}</div>`;
|
||||
|
||||
// Show provider badges
|
||||
html += `<div class="watchlist-linked-artist-providers">`;
|
||||
if (hasSpotify) {
|
||||
html += `<span class="watchlist-provider-badge spotify">Spotify</span>`;
|
||||
}
|
||||
if (hasItunes) {
|
||||
html += `<span class="watchlist-provider-badge itunes">iTunes</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
// Show mismatch warning if linked name differs from watchlist name
|
||||
if (!nameMatches) {
|
||||
html += `<div class="watchlist-linked-mismatch-warning">
|
||||
⚠️ Name differs from watchlist entry "<strong>${escapeHtml(artistName)}</strong>"
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`; // close info
|
||||
|
||||
html += `<button class="watchlist-linked-change-btn" id="watchlist-linked-change-btn" title="Change linked artist">Change</button>`;
|
||||
html += `</div>`; // close card
|
||||
|
||||
// Search UI (hidden by default)
|
||||
html += `<div class="watchlist-linked-search" id="watchlist-linked-search" style="display:none">
|
||||
<div class="watchlist-linked-search-input-row">
|
||||
<input type="text" id="watchlist-linked-search-input" class="watchlist-linked-search-input"
|
||||
placeholder="Search for the correct artist..." value="${escapeHtml(artistName)}">
|
||||
<button class="watchlist-linked-search-btn" id="watchlist-linked-search-go">Search</button>
|
||||
</div>
|
||||
<div class="watchlist-linked-search-results" id="watchlist-linked-search-results"></div>
|
||||
</div>`;
|
||||
|
||||
content.innerHTML = html;
|
||||
|
||||
// Wire up Change button
|
||||
document.getElementById('watchlist-linked-change-btn').onclick = () => {
|
||||
document.getElementById('watchlist-linked-search').style.display = '';
|
||||
const input = document.getElementById('watchlist-linked-search-input');
|
||||
input.focus();
|
||||
input.select();
|
||||
};
|
||||
|
||||
// Wire up search
|
||||
const doSearch = () => _searchLinkedProviderArtists(artistId, artistName);
|
||||
document.getElementById('watchlist-linked-search-go').onclick = doSearch;
|
||||
document.getElementById('watchlist-linked-search-input').onkeydown = (e) => {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for artists to link to a watchlist entry.
|
||||
*/
|
||||
async function _searchLinkedProviderArtists(currentArtistId, watchlistName) {
|
||||
const input = document.getElementById('watchlist-linked-search-input');
|
||||
const resultsContainer = document.getElementById('watchlist-linked-search-results');
|
||||
const query = input?.value?.trim();
|
||||
if (!query || !resultsContainer) return;
|
||||
|
||||
resultsContainer.innerHTML = '<div style="padding:12px;color:#888;text-align:center">Searching...</div>';
|
||||
|
||||
try {
|
||||
// Use match/search to find artists (returns genres, popularity, image)
|
||||
const response = await fetch('/api/match/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: query, context: 'artist' })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
resultsContainer.innerHTML = '<div style="padding:12px;color:#888;text-align:center">No artists found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const r of results.slice(0, 8)) {
|
||||
const a = r.artist || r;
|
||||
const img = a.image_url || '';
|
||||
const genres = (a.genres || []).slice(0, 2).join(', ');
|
||||
const pop = a.popularity || 0;
|
||||
html += `<div class="watchlist-linked-search-result" data-id="${a.id}" data-name="${escapeHtml(a.name)}">
|
||||
${img ? `<img src="${img}" alt="" class="watchlist-linked-result-img">` :
|
||||
`<div class="watchlist-linked-result-img-placeholder">🎵</div>`}
|
||||
<div class="watchlist-linked-result-info">
|
||||
<div class="watchlist-linked-result-name">${escapeHtml(a.name)}</div>
|
||||
<div class="watchlist-linked-result-meta">${genres ? escapeHtml(genres) + ' · ' : ''}Pop: ${pop}</div>
|
||||
</div>
|
||||
<button class="watchlist-linked-select-btn">Select</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
|
||||
// Wire up select buttons
|
||||
resultsContainer.querySelectorAll('.watchlist-linked-search-result').forEach(el => {
|
||||
el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const newId = el.dataset.id;
|
||||
const newName = el.dataset.name;
|
||||
await _linkProviderArtist(currentArtistId, newId, newName);
|
||||
};
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error searching for linked artist:', err);
|
||||
resultsContainer.innerHTML = '<div style="padding:12px;color:#f44;text-align:center">Search error</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a watchlist artist to a new provider artist.
|
||||
*/
|
||||
async function _linkProviderArtist(currentArtistId, newProviderId, newProviderName) {
|
||||
// Determine provider type from ID format
|
||||
const provider = /^\d+$/.test(newProviderId) ? 'itunes' : 'spotify';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/watchlist/artist/${currentArtistId}/link-provider`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider_id: newProviderId, provider: provider })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
showToast(`Failed to link artist: ${data.error}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(`Linked to "${newProviderName}" on ${provider}`, 'success');
|
||||
|
||||
// Close and reopen the config modal with the new ID to refresh everything
|
||||
closeWatchlistArtistConfigModal();
|
||||
setTimeout(() => {
|
||||
openWatchlistArtistConfigModal(newProviderId, newProviderName);
|
||||
}, 300);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error linking provider artist:', err);
|
||||
showToast('Failed to link artist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open watchlist artist configuration modal
|
||||
* @param {string} artistId - Spotify artist ID
|
||||
|
|
@ -32061,7 +32243,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
|
|||
return;
|
||||
}
|
||||
|
||||
const { config, artist } = data;
|
||||
const { config, artist, spotify_artist_id, itunes_artist_id, watchlist_name } = data;
|
||||
|
||||
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
|
||||
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist);
|
||||
|
||||
// Check if global override is active
|
||||
let globalOverrideActive = false;
|
||||
|
|
@ -32173,6 +32358,12 @@ function closeWatchlistArtistConfigModal() {
|
|||
if (heroContainer) {
|
||||
heroContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// Clear linked provider section
|
||||
const linkedContent = document.getElementById('watchlist-linked-provider-content');
|
||||
if (linkedContent) linkedContent.innerHTML = '';
|
||||
const linkedSection = document.getElementById('watchlist-linked-provider-section');
|
||||
if (linkedSection) linkedSection.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13191,6 +13191,231 @@ body {
|
|||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Linked Provider Artist */
|
||||
.watchlist-linked-artist-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.watchlist-linked-artist-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-artist-img-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-artist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-artist-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.watchlist-linked-artist-providers {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.watchlist-provider-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.watchlist-provider-badge.spotify {
|
||||
background: rgba(30, 215, 96, 0.15);
|
||||
color: #1ed760;
|
||||
border: 1px solid rgba(30, 215, 96, 0.3);
|
||||
}
|
||||
|
||||
.watchlist-provider-badge.itunes {
|
||||
background: rgba(252, 60, 68, 0.15);
|
||||
color: #fc3c44;
|
||||
border: 1px solid rgba(252, 60, 68, 0.3);
|
||||
}
|
||||
|
||||
.watchlist-linked-mismatch-warning {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #ffc107;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.watchlist-linked-mismatch-warning strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.watchlist-linked-change-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-change-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
color: #fff;
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
/* Linked Provider Search */
|
||||
.watchlist-linked-search {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.watchlist-linked-search-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watchlist-linked-search-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.watchlist-linked-search-input:focus {
|
||||
border-color: rgba(var(--accent-rgb), 0.5);
|
||||
}
|
||||
|
||||
.watchlist-linked-search-btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: rgba(var(--accent-rgb), 0.2);
|
||||
color: rgb(var(--accent-rgb));
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.watchlist-linked-search-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.watchlist-linked-search-results {
|
||||
margin-top: 8px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.watchlist-linked-search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.watchlist-linked-search-result:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.watchlist-linked-result-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-result-img-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-result-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.watchlist-linked-result-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.watchlist-linked-select-btn {
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
color: rgb(var(--accent-rgb));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.watchlist-linked-select-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.watchlist-artist-config-modal {
|
||||
|
|
|
|||
Loading…
Reference in a new issue