Skip to content

Commit b5e30b0

Browse files
committed
feat(web): add YouTube search feature using yt-search-lib
1 parent a3af893 commit b5e30b0

File tree

6 files changed

+434
-3
lines changed

6 files changed

+434
-3
lines changed

web/index.html

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ <h1>music-cli</h1>
5757
</div>
5858
<div class="search-container" id="youtube-search-container" style="display: none;">
5959
<div class="header-input-group">
60-
<input type="text" id="youtube-url" placeholder="Paste YouTube URL here..." class="search-input" />
60+
<input type="text" id="youtube-search" placeholder="Search YouTube videos..." class="search-input" />
61+
<button id="youtube-search-btn" class="header-play-btn">Search</button>
62+
</div>
63+
<div class="header-input-group" style="margin-top: 8px;">
64+
<input type="text" id="youtube-url" placeholder="Or paste YouTube URL here..." class="search-input" />
6165
<button id="youtube-play-btn" class="header-play-btn">Play</button>
6266
</div>
6367
</div>
@@ -99,6 +103,14 @@ <h3>Moods</h3>
99103
</div>
100104
</div>
101105

106+
<div class="youtube-search-results-container" id="youtube-search-results" style="display: none;">
107+
<div class="search-results-header">
108+
<h3>Search Results</h3>
109+
<button id="clear-search-results-btn" class="clear-history-btn" title="Clear Results">Clear</button>
110+
</div>
111+
<div id="search-results-grid" class="search-results-grid"></div>
112+
</div>
113+
102114
<div class="youtube-history-container">
103115
<div class="history-header">
104116
<h3>Recently Played</h3>

web/package-lock.json

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"vite": "^7.2.4"
1414
},
1515
"dependencies": {
16-
"lucide": "^0.562.0"
16+
"lucide": "^0.562.0",
17+
"yt-search-lib": "^1.0.2"
1718
}
1819
}

web/src/main.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { stations, categories, moods, moodIcons, searchStations, getStationsByCa
33
import audioManager from './services/AudioManager.js';
44
import youtubeManager from './services/YouTubeManager.js';
55
import historyService from './services/HistoryService.js';
6+
import youtubeSearchService from './services/YouTubeSearchService.js';
67
import { createIconElement } from './utils/icons.js';
78
import consentService from './services/ConsentService.js';
89
import { LegalPages } from './pages/LegalPages.js';
@@ -87,6 +88,8 @@ function init() {
8788

8889
renderHistory();
8990

91+
youtubeSearchService.init();
92+
9093
router.register('/', () => switchMode('radio'));
9194
router.register('/youtube', () => switchMode('youtube'));
9295
router.register('/privacy', () => {
@@ -416,11 +419,20 @@ function setupEventListeners() {
416419
if (e.key === 'Enter') playYouTubeVideo();
417420
});
418421

422+
document.getElementById('youtube-search-btn').addEventListener('click', searchYouTubeVideos);
423+
document.getElementById('youtube-search').addEventListener('keypress', (e) => {
424+
if (e.key === 'Enter') searchYouTubeVideos();
425+
});
426+
419427
document.getElementById('clear-history-btn').addEventListener('click', () => {
420428
historyService.clearHistory();
421429
renderHistory();
422430
});
423431

432+
document.getElementById('clear-search-results-btn').addEventListener('click', () => {
433+
clearSearchResults();
434+
});
435+
424436
const enableYoutubeBtn = document.getElementById('enable-youtube-btn');
425437
if (enableYoutubeBtn) {
426438
enableYoutubeBtn.addEventListener('click', () => {
@@ -453,6 +465,11 @@ function switchMode(mode) {
453465
youtubeSearchContainer.style.display = mode === 'youtube' ? 'block' : 'none';
454466
}
455467

468+
const searchResultsContainer = document.getElementById('youtube-search-results');
469+
if (searchResultsContainer) {
470+
searchResultsContainer.style.display = mode === 'youtube' ? 'none' : 'none';
471+
}
472+
456473
if (mode === 'youtube') {
457474
audioManager.pause();
458475
if (!consentService.hasConsent('youtube')) {
@@ -667,6 +684,106 @@ function playYouTubeVideo() {
667684
updatePlaybackUrl('v', videoId);
668685
}
669686

687+
async function searchYouTubeVideos() {
688+
const searchInput = document.getElementById('youtube-search');
689+
const query = searchInput.value.trim();
690+
691+
if (!query) {
692+
clearSearchResults();
693+
return;
694+
}
695+
696+
const searchBtn = document.getElementById('youtube-search-btn');
697+
const resultsContainer = document.getElementById('youtube-search-results');
698+
699+
try {
700+
if (!youtubeSearchService.isReady()) {
701+
showSearchError('YouTube search not initialized. Please refresh the page.');
702+
return;
703+
}
704+
705+
searchBtn.disabled = true;
706+
searchBtn.textContent = 'Searching...';
707+
708+
const results = await youtubeSearchService.search(query, 12);
709+
710+
if (results.length === 0) {
711+
showSearchError('No results found');
712+
} else {
713+
renderSearchResults(results);
714+
}
715+
} catch (error) {
716+
console.error('Search error:', error);
717+
showSearchError(error.message || 'Search failed. Please try again.');
718+
} finally {
719+
searchBtn.disabled = false;
720+
searchBtn.textContent = 'Search';
721+
}
722+
}
723+
724+
function renderSearchResults(results) {
725+
const container = document.getElementById('search-results-grid');
726+
const resultsContainer = document.getElementById('youtube-search-results');
727+
728+
container.innerHTML = '';
729+
resultsContainer.style.display = 'block';
730+
731+
results.forEach(video => {
732+
const card = document.createElement('div');
733+
card.className = 'search-result-card';
734+
735+
card.innerHTML = `
736+
<div class="search-result-thumbnail">
737+
<img src="${video.thumbnail}" alt="${video.title}" />
738+
<div class="search-result-overlay">
739+
<div class="play-overlay-icon"></div>
740+
</div>
741+
</div>
742+
<div class="search-result-info">
743+
<div class="search-result-title">${video.title}</div>
744+
<div class="search-result-channel">${video.channel}</div>
745+
${video.duration ? `<div class="search-result-duration">${video.duration}</div>` : ''}
746+
</div>
747+
`;
748+
749+
const playOverlayIcon = card.querySelector('.play-overlay-icon');
750+
playOverlayIcon.appendChild(createIconElement('play', 32));
751+
752+
card.addEventListener('click', () => {
753+
playYouTubeVideoById(video.videoId, video.title);
754+
});
755+
756+
container.appendChild(card);
757+
});
758+
}
759+
760+
function playYouTubeVideoById(videoId, title) {
761+
clearSearchResults();
762+
document.getElementById('youtube-url').value = `https://www.youtube.com/watch?v=${videoId}`;
763+
playYouTubeVideo();
764+
}
765+
766+
function clearSearchResults() {
767+
const container = document.getElementById('search-results-grid');
768+
const resultsContainer = document.getElementById('youtube-search-results');
769+
770+
container.innerHTML = '';
771+
resultsContainer.style.display = 'none';
772+
document.getElementById('youtube-search').value = '';
773+
}
774+
775+
function showSearchError(message) {
776+
const container = document.getElementById('search-results-grid');
777+
const resultsContainer = document.getElementById('youtube-search-results');
778+
779+
container.innerHTML = `
780+
<div class="search-error-message">
781+
<p>${message}</p>
782+
</div>
783+
`;
784+
resultsContainer.style.display = 'block';
785+
}
786+
670787
function togglePlayback() {
671788
if (currentMode === 'radio') {
672789
if (audioManager.isPlaying) {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import YouTubeClient from 'yt-search-lib';
2+
3+
class YouTubeSearchService {
4+
constructor() {
5+
this.client = null;
6+
this.lastRequestTime = 0;
7+
this.minRequestInterval = 300;
8+
}
9+
10+
init(proxyUrl = 'https://api.allorigins.win/raw?url=') {
11+
this.client = new YouTubeClient({
12+
proxyUrl,
13+
useCache: true,
14+
cacheMaxAge: 3600000
15+
});
16+
}
17+
18+
isReady() {
19+
return this.client !== null;
20+
}
21+
22+
async _enforceRateLimit() {
23+
const now = Date.now();
24+
const timeSinceLastRequest = now - this.lastRequestTime;
25+
26+
if (timeSinceLastRequest < this.minRequestInterval) {
27+
await new Promise(resolve =>
28+
setTimeout(() => resolve(), this.minRequestInterval - timeSinceLastRequest)
29+
);
30+
}
31+
32+
this.lastRequestTime = Date.now();
33+
}
34+
35+
async search(query, maxResults = 10) {
36+
if (!this.isReady()) {
37+
throw new Error('YouTube search service not initialized. Call init(proxyUrl) first.');
38+
}
39+
40+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
41+
throw new Error('Invalid search query');
42+
}
43+
44+
await this._enforceRateLimit();
45+
46+
try {
47+
const results = await this.client.search(query.trim(), {
48+
limit: Math.min(Math.max(maxResults, 1), 50),
49+
type: 'video'
50+
});
51+
52+
return this._normalizeResults(results);
53+
} catch (error) {
54+
console.error('YouTube search error:', error);
55+
throw this._parseError(error);
56+
}
57+
}
58+
59+
_normalizeResults(results) {
60+
if (!Array.isArray(results)) {
61+
return [];
62+
}
63+
64+
return results
65+
.filter(item => item && item.id && item.title)
66+
.map(item => ({
67+
videoId: item.id,
68+
title: item.title || 'Unknown Title',
69+
description: item.description || '',
70+
channel: item.author || 'Unknown Channel',
71+
channelId: '',
72+
publishedAt: item.publishedAt || new Date().toISOString(),
73+
duration: item.duration || '',
74+
viewCount: item.viewCount || '',
75+
thumbnail: this._getBestThumbnail(item)
76+
}));
77+
}
78+
79+
_getBestThumbnail(item) {
80+
if (item.thumbnails && item.thumbnails.length > 0) {
81+
const sorted = [...item.thumbnails].sort((a, b) => b.width - a.width);
82+
return sorted[0]?.url || item.thumbnail_url;
83+
}
84+
85+
return item.thumbnail_url || null;
86+
}
87+
88+
_parseError(error) {
89+
if (error.message && error.message.includes('Network error')) {
90+
return new Error('Network error. Check your connection and CORS proxy.');
91+
}
92+
93+
if (error.message && error.message.includes('CORS')) {
94+
return new Error('CORS error. Try a different proxy URL.');
95+
}
96+
97+
return error;
98+
}
99+
100+
clearCache() {
101+
if (this.client) {
102+
this.client.clearCache();
103+
}
104+
}
105+
}
106+
107+
export const youtubeSearchService = new YouTubeSearchService();
108+
export default youtubeSearchService;

0 commit comments

Comments
 (0)