diff --git a/core/youtube_client.py b/core/youtube_client.py index b5a423f6..5119dd18 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -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). diff --git a/web_server.py b/web_server.py index 488b32c1..6d357de5 100644 --- a/web_server.py +++ b/web_server.py @@ -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': diff --git a/webui/static/script.js b/webui/static/script.js index c755482b..1e5147e6 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 ``; }).join(''); } diff --git a/webui/static/style.css b/webui/static/style.css index b71cd982..6524f808 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;