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
pull/273/head
Broque Thomas 1 month ago
parent 1f0ef08b48
commit b44bb34b44

@ -558,9 +558,79 @@ class YouTubeClient:
thumbnail = thumbs[-1].get('url')
track_result.thumbnail = thumbnail
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]]:
"""
Search YouTube for tracks matching the query (async, Soulseek-compatible interface).

@ -7873,6 +7873,8 @@ def enhanced_search():
alternate_sources.append('discogs')
if primary_source != 'hydrabase' and hydrabase_available:
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, "
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.
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
data = request.get_json()
@ -7967,6 +7969,37 @@ def enhanced_search_source(source_name):
if not query:
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:
client = None
if source_name == 'spotify':

@ -8330,6 +8330,7 @@ function initializeSearchModeToggle() {
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
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
@ -8454,7 +8455,7 @@ function initializeSearchModeToggle() {
// Fire ALL source fetches immediately in parallel with the primary endpoint.
// 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.
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) {
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']) {
_fetchAlternateSource(srcName, query);
}
@ -8512,6 +8513,9 @@ function initializeSearchModeToggle() {
}
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)
const displaySource = _activeSearchSource || data.metadata_source || '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
if (!_enhancedSearchData) return;
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];
@ -8750,6 +8755,7 @@ function initializeSearchModeToggle() {
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 === '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; }
// Re-render tabs + content if this is the active source
@ -8797,7 +8803,9 @@ function initializeSearchModeToggle() {
tabBar.innerHTML = ordered.map(name => {
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
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;
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
onclick="window._switchEnhSourceTab('${name}')"
@ -8822,6 +8830,27 @@ function initializeSearchModeToggle() {
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
const viewData = {
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, '&quot;')}">${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
async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [
@ -17543,7 +17628,8 @@ async function _gsFetchSourceStream(src, query) {
if (!res.ok) return;
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];
@ -17566,6 +17652,7 @@ async function _gsFetchSourceStream(src, query) {
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 === '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;
_gsRenderTabs();
// Re-render content if this is the active source tab
@ -17585,6 +17672,39 @@ function _gsRender(data) {
const results = document.getElementById('gsearch-results');
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 loading = src._loading || new Set();
const dbArtists = data?.db_artists || [];
@ -17602,7 +17722,7 @@ function _gsRender(data) {
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 || '';
let h = '';
@ -17699,11 +17819,13 @@ function _gsRenderTabs() {
if (!el) return;
const sources = Object.keys(_gsState.sources);
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.innerHTML = sources.map(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>`;
}).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-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-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 {
margin-bottom: 24px;

Loading…
Cancel
Save