Add Music Videos search tab to enhanced and global search
New "Music Videos" pill tab alongside Spotify/Deezer/iTunes/Discogs in both enhanced search and global search. Searches YouTube via yt-dlp and displays results in a video card grid with 16:9 thumbnails, play overlay, duration badge, channel name, and view count. - Backend: /api/enhanced-search/source/youtube_videos endpoint with search_videos() method on YouTubeClient returning YouTubeSearchResult - Frontend: Video grid layout with responsive cards, YouTube red tab color, proper section hiding when switching between metadata and video tabs - Global search: Full parity with enhanced search video rendering - No download functionality yet — display only
This commit is contained in:
parent
1f0ef08b48
commit
b44bb34b44
4 changed files with 342 additions and 9 deletions
|
|
@ -558,9 +558,79 @@ class YouTubeClient:
|
||||||
thumbnail = thumbs[-1].get('url')
|
thumbnail = thumbs[-1].get('url')
|
||||||
|
|
||||||
track_result.thumbnail = thumbnail
|
track_result.thumbnail = thumbnail
|
||||||
|
|
||||||
return track_result
|
return track_result
|
||||||
|
|
||||||
|
async def search_videos(self, query: str, max_results: int = 20) -> List[YouTubeSearchResult]:
|
||||||
|
"""Search YouTube and return video metadata for music video display.
|
||||||
|
|
||||||
|
Unlike search() which returns TrackResult objects for download matching,
|
||||||
|
this returns YouTubeSearchResult objects with video-specific metadata
|
||||||
|
(thumbnails, view counts, channel names) for UI display.
|
||||||
|
"""
|
||||||
|
logger.info(f"🎬 Searching YouTube videos for: {query}")
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
def _search():
|
||||||
|
from config.settings import config_manager
|
||||||
|
ydl_opts = {
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'extract_flat': True,
|
||||||
|
'default_search': 'ytsearch',
|
||||||
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
}
|
||||||
|
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
||||||
|
if cookies_browser:
|
||||||
|
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
data = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False)
|
||||||
|
if not data or 'entries' not in data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for entry in data['entries']:
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
video_id = entry.get('id', '')
|
||||||
|
title = entry.get('title', '')
|
||||||
|
if not video_id or not title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip very short clips (< 30s) and very long content (> 15min)
|
||||||
|
duration = entry.get('duration') or 0
|
||||||
|
if duration < 30 or duration > 900:
|
||||||
|
continue
|
||||||
|
|
||||||
|
channel = entry.get('uploader', entry.get('channel', ''))
|
||||||
|
if channel and re.search(r'\s*-\s*Topic\s*$', channel, re.IGNORECASE):
|
||||||
|
channel = re.sub(r'\s*-\s*Topic\s*$', '', channel, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
|
thumbnail = entry.get('thumbnail')
|
||||||
|
if not thumbnail and entry.get('thumbnails'):
|
||||||
|
thumbs = entry['thumbnails']
|
||||||
|
if isinstance(thumbs, list) and thumbs:
|
||||||
|
thumbnail = thumbs[-1].get('url')
|
||||||
|
|
||||||
|
results.append(YouTubeSearchResult(
|
||||||
|
video_id=video_id,
|
||||||
|
title=title,
|
||||||
|
channel=channel,
|
||||||
|
duration=duration,
|
||||||
|
url=f"https://www.youtube.com/watch?v={video_id}",
|
||||||
|
thumbnail=thumbnail or '',
|
||||||
|
view_count=entry.get('view_count', 0) or 0,
|
||||||
|
upload_date=entry.get('upload_date', ''),
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
return await loop.run_in_executor(None, _search)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"YouTube video search failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]:
|
async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]:
|
||||||
"""
|
"""
|
||||||
Search YouTube for tracks matching the query (async, Soulseek-compatible interface).
|
Search YouTube for tracks matching the query (async, Soulseek-compatible interface).
|
||||||
|
|
|
||||||
|
|
@ -7873,6 +7873,8 @@ def enhanced_search():
|
||||||
alternate_sources.append('discogs')
|
alternate_sources.append('discogs')
|
||||||
if primary_source != 'hydrabase' and hydrabase_available:
|
if primary_source != 'hydrabase' and hydrabase_available:
|
||||||
alternate_sources.append('hydrabase')
|
alternate_sources.append('hydrabase')
|
||||||
|
# YouTube music videos always available (uses yt-dlp, no auth needed)
|
||||||
|
alternate_sources.append('youtube_videos')
|
||||||
|
|
||||||
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
|
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
|
||||||
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
|
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
|
||||||
|
|
@ -7959,7 +7961,7 @@ def enhanced_search_source(source_name):
|
||||||
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
|
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
|
||||||
Falls back to single JSON response if streaming not supported.
|
Falls back to single JSON response if streaming not supported.
|
||||||
"""
|
"""
|
||||||
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'):
|
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos'):
|
||||||
return jsonify({"error": f"Unknown source: {source_name}"}), 400
|
return jsonify({"error": f"Unknown source: {source_name}"}), 400
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
@ -7967,6 +7969,37 @@ def enhanced_search_source(source_name):
|
||||||
if not query:
|
if not query:
|
||||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||||
|
|
||||||
|
# YouTube music videos — separate flow from metadata sources
|
||||||
|
if source_name == 'youtube_videos':
|
||||||
|
if not soulseek_client or not hasattr(soulseek_client, 'youtube') or not soulseek_client.youtube:
|
||||||
|
return jsonify({"videos": [], "available": False})
|
||||||
|
try:
|
||||||
|
def generate_videos():
|
||||||
|
try:
|
||||||
|
# Search YouTube via yt-dlp
|
||||||
|
video_query = f"{query} official music video"
|
||||||
|
results = run_async(soulseek_client.youtube.search_videos(video_query, max_results=20))
|
||||||
|
videos = []
|
||||||
|
for v in (results or []):
|
||||||
|
videos.append({
|
||||||
|
'video_id': v.video_id,
|
||||||
|
'title': v.title,
|
||||||
|
'channel': v.channel,
|
||||||
|
'duration': v.duration,
|
||||||
|
'thumbnail': v.thumbnail,
|
||||||
|
'url': v.url,
|
||||||
|
'view_count': v.view_count,
|
||||||
|
'upload_date': v.upload_date,
|
||||||
|
})
|
||||||
|
yield json.dumps({"type": "videos", "data": videos}) + "\n"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"YouTube music video search failed: {e}")
|
||||||
|
yield json.dumps({"type": "videos", "data": []}) + "\n"
|
||||||
|
yield json.dumps({"type": "done"}) + "\n"
|
||||||
|
return app.response_class(generate_videos(), mimetype='application/x-ndjson')
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = None
|
client = None
|
||||||
if source_name == 'spotify':
|
if source_name == 'spotify':
|
||||||
|
|
|
||||||
|
|
@ -8330,6 +8330,7 @@ function initializeSearchModeToggle() {
|
||||||
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
|
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
|
||||||
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
|
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
|
||||||
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
|
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
|
||||||
|
youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live search with debouncing
|
// Live search with debouncing
|
||||||
|
|
@ -8454,7 +8455,7 @@ function initializeSearchModeToggle() {
|
||||||
// Fire ALL source fetches immediately in parallel with the primary endpoint.
|
// Fire ALL source fetches immediately in parallel with the primary endpoint.
|
||||||
// Don't guess which is primary — the main endpoint response will tell us.
|
// Don't guess which is primary — the main endpoint response will tell us.
|
||||||
// If an alternate duplicates the primary, it just overwrites with same data.
|
// If an alternate duplicates the primary, it just overwrites with same data.
|
||||||
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) {
|
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']) {
|
||||||
_fetchAlternateSource(srcName, query);
|
_fetchAlternateSource(srcName, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8512,6 +8513,9 @@ function initializeSearchModeToggle() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDropdownResults(data) {
|
function renderDropdownResults(data) {
|
||||||
|
// Music Videos tab — don't render regular sections
|
||||||
|
if (_activeSearchSource === 'youtube_videos') return;
|
||||||
|
|
||||||
// Determine source badge from active tab (not just primary)
|
// Determine source badge from active tab (not just primary)
|
||||||
const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
|
const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
|
||||||
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
|
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
|
||||||
|
|
@ -8726,7 +8730,8 @@ function initializeSearchModeToggle() {
|
||||||
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
|
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
|
||||||
if (!_enhancedSearchData) return;
|
if (!_enhancedSearchData) return;
|
||||||
if (!_enhancedSearchData.sources[sourceName]) {
|
if (!_enhancedSearchData.sources[sourceName]) {
|
||||||
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) };
|
const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
|
||||||
|
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
|
||||||
}
|
}
|
||||||
const sourceData = _enhancedSearchData.sources[sourceName];
|
const sourceData = _enhancedSearchData.sources[sourceName];
|
||||||
|
|
||||||
|
|
@ -8750,6 +8755,7 @@ function initializeSearchModeToggle() {
|
||||||
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
|
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
|
||||||
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
|
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
|
||||||
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
|
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
|
||||||
|
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
|
||||||
else if (chunk.type === 'done') { delete sourceData._loading; break; }
|
else if (chunk.type === 'done') { delete sourceData._loading; break; }
|
||||||
|
|
||||||
// Re-render tabs + content if this is the active source
|
// Re-render tabs + content if this is the active source
|
||||||
|
|
@ -8797,7 +8803,9 @@ function initializeSearchModeToggle() {
|
||||||
tabBar.innerHTML = ordered.map(name => {
|
tabBar.innerHTML = ordered.map(name => {
|
||||||
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
|
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
|
||||||
const src = sources[name] || {};
|
const src = sources[name] || {};
|
||||||
const count = (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
|
const count = name === 'youtube_videos'
|
||||||
|
? (src.videos?.length || 0)
|
||||||
|
: (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
|
||||||
const isActive = name === _activeSearchSource;
|
const isActive = name === _activeSearchSource;
|
||||||
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
|
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
|
||||||
onclick="window._switchEnhSourceTab('${name}')"
|
onclick="window._switchEnhSourceTab('${name}')"
|
||||||
|
|
@ -8822,6 +8830,27 @@ function initializeSearchModeToggle() {
|
||||||
tab.classList.toggle('active', tab.dataset.source === sourceName);
|
tab.classList.toggle('active', tab.dataset.source === sourceName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Music Videos tab — render video cards instead of regular sections
|
||||||
|
if (sourceName === 'youtube_videos') {
|
||||||
|
// Hide ALL regular sections including wrappers
|
||||||
|
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.add('hidden');
|
||||||
|
});
|
||||||
|
// Hide the artists wrapper div too
|
||||||
|
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
|
||||||
|
if (artistsWrapper) artistsWrapper.style.display = 'none';
|
||||||
|
_renderVideoResults(src.videos || []);
|
||||||
|
resultsContainer.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide videos section and restore regular layout when switching to a metadata tab
|
||||||
|
const videosSec = document.getElementById('enh-videos-section');
|
||||||
|
if (videosSec) videosSec.classList.add('hidden');
|
||||||
|
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
|
||||||
|
if (artistsWrapper) artistsWrapper.style.display = '';
|
||||||
|
|
||||||
// Build data in the shape renderDropdownResults expects
|
// Build data in the shape renderDropdownResults expects
|
||||||
const viewData = {
|
const viewData = {
|
||||||
db_artists: _enhancedSearchData.db_artists,
|
db_artists: _enhancedSearchData.db_artists,
|
||||||
|
|
@ -8854,6 +8883,62 @@ function initializeSearchModeToggle() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function _renderVideoResults(videos) {
|
||||||
|
let section = document.getElementById('enh-videos-section');
|
||||||
|
if (!section) {
|
||||||
|
// Create the section dynamically if it doesn't exist
|
||||||
|
const container = document.getElementById('enhanced-results-container');
|
||||||
|
if (!container) return;
|
||||||
|
section = document.createElement('div');
|
||||||
|
section.id = 'enh-videos-section';
|
||||||
|
section.className = 'enh-dropdown-section';
|
||||||
|
section.innerHTML = `
|
||||||
|
<div class="enh-section-header">
|
||||||
|
<span class="enh-section-icon">🎬</span>
|
||||||
|
<h4 class="enh-section-title">Music Videos</h4>
|
||||||
|
<span class="enh-section-count" id="enh-videos-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="enh-video-grid" id="enh-videos-list"></div>
|
||||||
|
`;
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
const countEl = document.getElementById('enh-videos-count');
|
||||||
|
const listEl = document.getElementById('enh-videos-list');
|
||||||
|
if (countEl) countEl.textContent = videos.length;
|
||||||
|
|
||||||
|
if (!videos.length) {
|
||||||
|
listEl.innerHTML = '<div class="enh-empty-state">No music videos found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = videos.map(v => {
|
||||||
|
const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
|
||||||
|
const views = v.view_count ? _formatViewCount(v.view_count) : '';
|
||||||
|
return `
|
||||||
|
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="window.open('${v.url}', '_blank')">
|
||||||
|
<div class="enh-video-thumb">
|
||||||
|
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
|
||||||
|
<div class="enh-video-play">▶</div>
|
||||||
|
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="enh-video-info">
|
||||||
|
<div class="enh-video-title" title="${v.title.replace(/"/g, '"')}">${v.title}</div>
|
||||||
|
<div class="enh-video-channel">${v.channel}${views ? ` · ${views} views` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatViewCount(count) {
|
||||||
|
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
|
||||||
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||||
|
return String(count);
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy load artist images for enhanced search results
|
// Lazy load artist images for enhanced search results
|
||||||
async function lazyLoadEnhancedSearchArtistImages() {
|
async function lazyLoadEnhancedSearchArtistImages() {
|
||||||
const artistLists = [
|
const artistLists = [
|
||||||
|
|
@ -17543,7 +17628,8 @@ async function _gsFetchSourceStream(src, query) {
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
|
|
||||||
if (!_gsState.sources[src]) {
|
if (!_gsState.sources[src]) {
|
||||||
_gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) };
|
const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
|
||||||
|
_gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
|
||||||
}
|
}
|
||||||
const sourceData = _gsState.sources[src];
|
const sourceData = _gsState.sources[src];
|
||||||
|
|
||||||
|
|
@ -17566,6 +17652,7 @@ async function _gsFetchSourceStream(src, query) {
|
||||||
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
|
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
|
||||||
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
|
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
|
||||||
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
|
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
|
||||||
|
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
|
||||||
if (chunk.type === 'done') delete sourceData._loading;
|
if (chunk.type === 'done') delete sourceData._loading;
|
||||||
_gsRenderTabs();
|
_gsRenderTabs();
|
||||||
// Re-render content if this is the active source tab
|
// Re-render content if this is the active source tab
|
||||||
|
|
@ -17585,6 +17672,39 @@ function _gsRender(data) {
|
||||||
const results = document.getElementById('gsearch-results');
|
const results = document.getElementById('gsearch-results');
|
||||||
if (!results) return;
|
if (!results) return;
|
||||||
|
|
||||||
|
// Music Videos tab — render video grid instead of regular results
|
||||||
|
if (_gsState.activeSource === 'youtube_videos') {
|
||||||
|
const src = _gsState.sources['youtube_videos'] || {};
|
||||||
|
const videos = src.videos || [];
|
||||||
|
const isLoading = src._loading && src._loading.size > 0;
|
||||||
|
let h = '';
|
||||||
|
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
|
||||||
|
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
|
||||||
|
h += '<div class="gsearch-results-body">';
|
||||||
|
if (isLoading) {
|
||||||
|
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
|
||||||
|
} else if (videos.length === 0) {
|
||||||
|
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
|
||||||
|
} else {
|
||||||
|
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
|
||||||
|
h += '<div class="enh-video-grid">';
|
||||||
|
h += videos.map(v => {
|
||||||
|
const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
|
||||||
|
const views = v.view_count >= 1000000 ? `${(v.view_count/1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count/1000).toFixed(1)}K` : (v.view_count || '');
|
||||||
|
return `<div class="enh-video-card" onclick="window.open('${v.url}', '_blank')">
|
||||||
|
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play">▶</div>${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
|
||||||
|
<div class="enh-video-info"><div class="enh-video-title">${_escToast(v.title)}</div><div class="enh-video-channel">${_escToast(v.channel)}${views ? ` · ${views} views` : ''}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
h += '</div>';
|
||||||
|
results.innerHTML = h;
|
||||||
|
results.classList.add('visible');
|
||||||
|
_gsRenderTabs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const src = _gsState.sources[_gsState.activeSource] || {};
|
const src = _gsState.sources[_gsState.activeSource] || {};
|
||||||
const loading = src._loading || new Set();
|
const loading = src._loading || new Set();
|
||||||
const dbArtists = data?.db_artists || [];
|
const dbArtists = data?.db_artists || [];
|
||||||
|
|
@ -17602,7 +17722,7 @@ function _gsRender(data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' };
|
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
|
||||||
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
|
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
|
||||||
|
|
||||||
let h = '';
|
let h = '';
|
||||||
|
|
@ -17699,11 +17819,13 @@ function _gsRenderTabs() {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const sources = Object.keys(_gsState.sources);
|
const sources = Object.keys(_gsState.sources);
|
||||||
if (sources.length < 2) { el.style.display = 'none'; return; }
|
if (sources.length < 2) { el.style.display = 'none'; return; }
|
||||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' };
|
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
|
||||||
el.style.display = 'flex';
|
el.style.display = 'flex';
|
||||||
el.innerHTML = sources.map(s => {
|
el.innerHTML = sources.map(s => {
|
||||||
const d = _gsState.sources[s];
|
const d = _gsState.sources[s];
|
||||||
const c = (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
|
const c = s === 'youtube_videos'
|
||||||
|
? (d.videos?.length || 0)
|
||||||
|
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
|
||||||
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
|
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32887,6 +32887,114 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
.enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; }
|
.enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; }
|
||||||
.enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; }
|
.enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; }
|
||||||
.enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; }
|
.enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; }
|
||||||
|
.enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; }
|
||||||
|
|
||||||
|
/* Music Video Grid */
|
||||||
|
.enh-video-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-play {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-card:hover .enh-video-play {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-duration {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-info {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-channel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.enh-video-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.enh-dropdown-section {
|
.enh-dropdown-section {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue