From c2f8d3fca1ea2228bc1d62b5b9789ce5716a7123 Mon Sep 17 00:00:00 2001 From: edidealt Date: Sun, 22 Mar 2026 04:32:10 +0000 Subject: [PATCH] feat: podcasts --- index.html | 44 ++++++ js/accounts/pocketbase.js | 4 + js/db.js | 4 + js/music-api.js | 18 +++ js/player.js | 31 +++- js/podcasts-api.js | 255 ++++++++++++++++++++++++++++++++ js/router.js | 7 + js/ui.js | 303 ++++++++++++++++++++++++++++++++++++++ js/utils.js | 6 +- 9 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 js/podcasts-api.js diff --git a/index.html b/index.html index 9af8e98..6bfb86e 100644 --- a/index.html +++ b/index.html @@ -1858,6 +1858,7 @@ +
@@ -1874,6 +1875,9 @@
+
+
+
@@ -2017,6 +2021,46 @@
+
+
+ +
+
Podcast
+

+
+
+ +
+
+
+
+

Episodes

+
+
+
+ +
+

Browse Podcasts

+
+ + +
+ +
+
+
+
+
0) { + activeElement.currentTime = startTime; + } + const played = await this.safePlay(activeElement); + if (!played) return; + } else if (isTracker || (track.audioUrl && !track.isLocal)) { streamUrl = track.audioUrl; if ( diff --git a/js/podcasts-api.js b/js/podcasts-api.js new file mode 100644 index 0000000..ba0ee94 --- /dev/null +++ b/js/podcasts-api.js @@ -0,0 +1,255 @@ +// js/podcasts-api.js +// PodcastIndex.org API integration for Monochrome Music + +const PODCASTINDEX_API_BASE = 'https://api.podcastindex.org/api/1.0'; + +const PODCAST_API_KEY = 'YU5HMSDYBQQVYDF6QN4P'; +const PODCAST_API_SECRET = '8hCvpjSL7T$S7^5ftnf5MhqQwYUYVjM^fmUL3Ld$'; + +export class PodcastsAPI { + constructor() { + this.cache = new Map(); + this.cacheTimeout = 1000 * 60 * 5; + } + + async getAuthHeaders() { + const apiHeaderTime = Math.floor(Date.now() / 1000).toString(); + const combined = PODCAST_API_KEY + PODCAST_API_SECRET + apiHeaderTime; + const authHeader = await this.sha1(combined); + return { + 'User-Agent': 'MonochromeMusic/1.0', + 'X-Auth-Key': PODCAST_API_KEY, + 'X-Auth-Date': apiHeaderTime, + Authorization: authHeader, + }; + } + + async sha1(str) { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex; + } + + async fetchWithRetry(endpoint, options = {}) { + const url = `${PODCASTINDEX_API_BASE}${endpoint}`; + const cacheKey = url; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.cacheTimeout) { + return cached.data; + } + + try { + const headers = await this.getAuthHeaders(); + const response = await fetch(url, { + method: 'GET', + headers: headers, + signal: options.signal, + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); + this.cache.set(cacheKey, { data, timestamp: Date.now() }); + return data; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('PodcastIndex API request failed:', error); + throw error; + } + } + + async searchPodcasts(query, options = {}) { + try { + const max = options.max || 20; + const clean = options.clean ? '&clean' : ''; + const data = await this.fetchWithRetry( + `/search/byterm?q=${encodeURIComponent(query)}&max=${max}${clean}&pretty`, + options + ); + + if (data.status !== 'true' || !data.feeds) { + return { items: [], total: 0 }; + } + + const podcasts = data.feeds.map((feed) => this.transformPodcast(feed)); + return { + items: podcasts, + total: data.count || podcasts.length, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Podcast search failed:', error); + return { items: [], total: 0 }; + } + } + + async searchPodcastsByTitle(query, options = {}) { + try { + const max = options.max || 20; + const clean = options.clean ? '&clean' : ''; + const data = await this.fetchWithRetry( + `/search/bytitle?q=${encodeURIComponent(query)}&max=${max}${clean}&pretty`, + options + ); + + if (data.status !== 'true' || !data.feeds) { + return { items: [], total: 0 }; + } + + const podcasts = data.feeds.map((feed) => this.transformPodcast(feed)); + return { + items: podcasts, + total: data.count || podcasts.length, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Podcast search by title failed:', error); + return { items: [], total: 0 }; + } + } + + async getPodcastById(id, options = {}) { + try { + const data = await this.fetchWithRetry(`/podcasts/byfeedid?id=${id}&pretty`, options); + + if (data.status !== 'true' || !data.feed) { + return null; + } + + return this.transformPodcastFull(data.feed); + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Get podcast by ID failed:', error); + return null; + } + } + + async getPodcastEpisodes(id, options = {}) { + try { + const max = options.max || 50; + const offset = options.offset || 0; + const data = await this.fetchWithRetry( + `/episodes/byfeedid?id=${id}&max=${max}&offset=${offset}&pretty`, + options + ); + + if (data.status !== 'true' || !data.items) { + return { items: [], total: 0, hasMore: false }; + } + + const episodes = data.items.map((item) => this.transformEpisode(item)); + return { + items: episodes, + total: data.count || episodes.length, + hasMore: episodes.length === max, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Get podcast episodes failed:', error); + return { items: [], total: 0, hasMore: false }; + } + } + + async getTrendingPodcasts(options = {}) { + try { + const max = options.max || 20; + const lang = options.lang || ''; + const cat = options.cat || ''; + const since = options.since || ''; + const params = new URLSearchParams({ max, pretty: '' }); + if (lang) params.append('lang', lang); + if (cat) params.append('cat', cat); + if (since) params.append('since', since); + const queryString = params.toString().replace(/&pretty=$/, ''); + const data = await this.fetchWithRetry(`/podcasts/trending?${queryString}`, options); + + if (data.status !== 'true' || !data.feeds) { + return { items: [], total: 0 }; + } + + const podcasts = data.feeds.map((feed) => this.transformPodcast(feed)); + return { + items: podcasts, + total: data.count || podcasts.length, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Get trending podcasts failed:', error); + return { items: [], total: 0 }; + } + } + + async testAuth() { + console.log('Testing PodcastIndex auth...'); + try { + const response = await fetch(`${PODCASTINDEX_API_BASE}/hub/pubnotify?id=75075&pretty`, { + headers: await this.getAuthHeaders(), + }); + const data = await response.json(); + console.log('Test response:', data); + return data; + } catch (error) { + console.error('Auth test failed:', error); + } + } + + transformPodcast(feed) { + return { + id: feed.id?.toString() || '', + podcastGuid: feed.podcastGuid || '', + title: feed.title || 'Unknown Podcast', + author: feed.author || feed.ownerName || '', + description: feed.description || '', + image: feed.image || feed.artwork || '', + link: feed.link || '', + feedUrl: feed.url || '', + language: feed.language || '', + categories: feed.categories || {}, + explicit: feed.explicit || false, + episodeCount: feed.episodeCount || 0, + newestItemPublishTime: feed.newestItemPubdate || feed.newestItemPublishTime || null, + }; + } + + transformPodcastFull(feed) { + const podcast = this.transformPodcast(feed); + podcast.generator = feed.generator || ''; + podcast.locked = feed.locked || 0; + podcast.medium = feed.medium || ''; + podcast.dead = feed.dead || 0; + podcast.value = feed.value || null; + podcast.funding = feed.funding || null; + return podcast; + } + + transformEpisode(item) { + return { + id: item.id?.toString() || '', + title: item.title || 'Unknown Episode', + description: item.description || '', + link: item.link || '', + guid: item.guid || '', + datePublished: item.datePublished || 0, + datePublishedPretty: item.datePublishedPretty || '', + enclosureUrl: item.enclosureUrl || '', + enclosureType: item.enclosureType || '', + enclosureLength: item.enclosureLength || 0, + duration: item.duration || null, + explicit: item.explicit || 0, + episode: item.episode || null, + episodeType: item.episodeType || 'full', + season: item.season || null, + image: item.image || '', + feedId: item.feedId || null, + feedTitle: item.feedTitle || '', + feedImage: item.feedImage || '', + }; + } +} + +export const podcastsAPI = new PodcastsAPI(); diff --git a/js/router.js b/js/router.js index 0bc33e6..5432fb7 100644 --- a/js/router.js +++ b/js/router.js @@ -101,6 +101,13 @@ export function createRouter(ui) { await ui.renderUnreleasedPage(); } break; + case 'podcasts': + if (param) { + await ui.renderPodcastPage(param); + } else { + await ui.renderPodcastsBrowsePage(); + } + break; case 'home': await ui.renderHomePage(); break; diff --git a/js/ui.js b/js/ui.js index 94e5500..1a14c96 100644 --- a/js/ui.js +++ b/js/ui.js @@ -2798,11 +2798,13 @@ export class UIRenderer { const artistsContainer = document.getElementById('search-artists-container'); const albumsContainer = document.getElementById('search-albums-container'); const playlistsContainer = document.getElementById('search-playlists-container'); + const podcastsContainer = document.getElementById('search-podcasts-container'); tracksContainer.innerHTML = this.createSkeletonTracks(8, true); artistsContainer.innerHTML = this.createSkeletonCards(6, true); albumsContainer.innerHTML = this.createSkeletonCards(6, false); playlistsContainer.innerHTML = this.createSkeletonCards(6, false); + podcastsContainer.innerHTML = this.createSkeletonCards(6, true); if (this.searchAbortController) { this.searchAbortController.abort(); @@ -2918,6 +2920,8 @@ export class UIRenderer { this.updateLikeState(el, 'playlist', playlist.uuid); } }); + + await this.renderPodcastSearchResults(query); } catch (error) { if (error.name === 'AbortError') return; console.error('Search failed:', error); @@ -2926,6 +2930,7 @@ export class UIRenderer { artistsContainer.innerHTML = errorMsg; albumsContainer.innerHTML = errorMsg; playlistsContainer.innerHTML = errorMsg; + podcastsContainer.innerHTML = errorMsg; } } @@ -5307,4 +5312,302 @@ export class UIRenderer { artistEl.innerHTML = ''; } } + + async renderPodcastsBrowsePage() { + this.showPage('podcasts-browse'); + const trendingContainer = document.getElementById('podcasts-trending-container'); + const recentContainer = document.getElementById('podcasts-recent-container'); + trendingContainer.innerHTML = this.createSkeletonCards(12, true); + recentContainer.innerHTML = this.createSkeletonCards(12, true); + + try { + const { podcastsAPI } = await import('./podcasts-api.js'); + const trendingResult = await podcastsAPI.getTrendingPodcasts({ max: 24 }); + if (trendingResult.items.length > 0) { + trendingContainer.innerHTML = trendingResult.items + .map((podcast) => this.createPodcastCardHTML(podcast)) + .join(''); + this.attachPodcastCardListeners(trendingContainer, trendingResult.items); + } else { + trendingContainer.innerHTML = createPlaceholder('No trending podcasts found.'); + } + } catch (error) { + console.error('Failed to load trending podcasts:', error); + trendingContainer.innerHTML = createPlaceholder('Failed to load trending podcasts.'); + } + + document.title = 'Podcasts - Monochrome Music'; + } + + cleanupPodcastState() { + if (this.podcastScrollHandler) { + const mainContent = document.querySelector('.main-content'); + if (mainContent) { + mainContent.removeEventListener('scroll', this.podcastScrollHandler); + } + this.podcastScrollHandler = null; + } + this.podcastState = null; + } + + async renderPodcastPage(podcastId) { + this.cleanupPodcastState(); + this.showPage('podcasts'); + + this.podcastState = { + id: podcastId, + episodes: [], + offset: 0, + hasMore: true, + isLoading: false, + }; + + const nameEl = document.getElementById('podcasts-detail-name'); + const metaEl = document.getElementById('podcasts-detail-meta'); + const imageEl = document.getElementById('podcasts-detail-image'); + const episodesContainer = document.getElementById('podcasts-episodes-container'); + + nameEl.textContent = 'Loading...'; + metaEl.textContent = ''; + episodesContainer.innerHTML = this.createSkeletonTracks(8, true); + + try { + const { podcastsAPI } = await import('./podcasts-api.js'); + const podcastResult = await podcastsAPI.getPodcastById(podcastId); + + if (podcastResult) { + nameEl.textContent = podcastResult.title; + metaEl.textContent = `${podcastResult.episodeCount} episodes • ${podcastResult.author}`; + if (podcastResult.image) { + imageEl.src = podcastResult.image; + this.setPageBackground(podcastResult.image); + } + + this.podcastState.podcastTitle = podcastResult.title; + const playBtn = document.getElementById('play-podcasts-btn'); + } else { + this.podcastState.podcastTitle = 'Unknown Podcast'; + } + + document.title = `${podcastResult?.title || 'Podcast'} - Monochrome Music`; + + episodesContainer.innerHTML = ''; + await this.loadMorePodcastEpisodes(); + } catch (error) { + console.error('Failed to load podcast:', error); + nameEl.textContent = 'Podcast not found'; + episodesContainer.innerHTML = createPlaceholder('Failed to load podcast.'); + } + } + + async loadMorePodcastEpisodes() { + if (this.podcastState.isLoading || !this.podcastState.hasMore) return; + + this.podcastState.isLoading = true; + const episodesContainer = document.getElementById('podcasts-episodes-container'); + + if (this.podcastState.offset === 0) { + episodesContainer.innerHTML = this.createSkeletonTracks(8, true); + } else { + const loader = document.createElement('div'); + loader.id = 'podcast-load-more'; + loader.className = 'loading-more'; + loader.innerHTML = '
'.repeat(4); + episodesContainer.appendChild(loader); + } + + try { + const { podcastsAPI } = await import('./podcasts-api.js'); + const result = await podcastsAPI.getPodcastEpisodes(this.podcastState.id, { + max: 50, + offset: this.podcastState.offset, + }); + + console.log( + 'Podcast episodes loaded:', + result.items.length, + 'hasMore:', + result.hasMore, + 'offset:', + this.podcastState.offset + ); + + const isFirstLoad = this.podcastState.offset === 0; + + this.podcastState.episodes.push(...result.items); + this.podcastState.offset += result.items.length; + this.podcastState.hasMore = result.hasMore; + + if (isFirstLoad) { + const podcastTitle = this.podcastState.podcastTitle || 'Unknown Podcast'; + const tracks = result.items.map((ep) => this.transformPodcastEpisodeToTrack(ep, podcastTitle)); + this.renderListWithTracks(episodesContainer, tracks, true); + + const sentinel = document.createElement('div'); + sentinel.id = 'podcast-scroll-sentinel'; + sentinel.style.height = '1px'; + episodesContainer.appendChild(sentinel); + + const playBtn = document.getElementById('play-podcasts-btn'); + if (playBtn && result.items.length > 0) { + playBtn.onclick = () => { + const tracksToPlay = this.podcastState.episodes.map((ep) => + this.transformPodcastEpisodeToTrack(ep, podcastTitle) + ); + if (this.player) { + this.player.setQueue(tracksToPlay, 0); + this.player.playTrackFromQueue(); + } + }; + } + + this.setupPodcastInfiniteScroll(); + } else { + const loader = document.getElementById('podcast-load-more'); + if (loader) loader.remove(); + const podcastTitle = this.podcastState.podcastTitle || 'Unknown Podcast'; + const tracks = result.items.map((ep) => this.transformPodcastEpisodeToTrack(ep, podcastTitle)); + this.appendListWithTracks(tracks); + } + + if (!this.podcastState.hasMore) { + const loader = document.getElementById('podcast-load-more'); + if (loader) loader.remove(); + } + } catch (error) { + console.error('Failed to load more episodes:', error); + const loader = document.getElementById('podcast-load-more'); + if (loader) loader.remove(); + } + + this.podcastState.isLoading = false; + } + + setupPodcastInfiniteScroll() { + const mainContent = document.querySelector('.main-content'); + if (!mainContent) return; + + const scrollHandler = () => { + const scrollTop = mainContent.scrollTop; + const scrollHeight = mainContent.scrollHeight; + const clientHeight = mainContent.clientHeight; + + if (scrollTop + clientHeight >= scrollHeight - 200) { + if (this.podcastState?.hasMore && !this.podcastState?.isLoading) { + console.log('Loading more podcast episodes...'); + this.loadMorePodcastEpisodes(); + } + } + }; + + mainContent.addEventListener('scroll', scrollHandler); + this.podcastScrollHandler = scrollHandler; + } + + appendListWithTracks(tracks) { + const listContainer = document.getElementById('podcasts-episodes-container'); + const sentinel = document.getElementById('podcast-scroll-sentinel'); + const existingTracks = listContainer.querySelectorAll('.track-row, .track-item').length; + + tracks.forEach((track, index) => { + const trackHtml = this.createTrackItemHTML(track, existingTracks + index, true); + const trackEl = document.createElement('div'); + trackEl.innerHTML = trackHtml; + const row = trackEl.firstElementChild; + + if (sentinel) { + listContainer.insertBefore(row, sentinel); + } else { + listContainer.appendChild(row); + } + + trackDataStore.set(row, track); + }); + } + + async renderPodcastSearchResults(query) { + const podcastsContainer = document.getElementById('search-podcasts-container'); + podcastsContainer.innerHTML = this.createSkeletonCards(12, true); + + try { + const { podcastsAPI } = await import('./podcasts-api.js'); + const result = await podcastsAPI.searchPodcasts(query, { max: 20 }); + + if (result.items.length > 0) { + podcastsContainer.innerHTML = result.items + .map((podcast) => this.createPodcastCardHTML(podcast)) + .join(''); + this.attachPodcastCardListeners(podcastsContainer, result.items); + } else { + podcastsContainer.innerHTML = createPlaceholder('No podcasts found.'); + } + } catch (error) { + console.error('Podcast search failed:', error); + podcastsContainer.innerHTML = createPlaceholder('Failed to search podcasts.'); + } + } + + createPodcastCardHTML(podcast) { + const title = escapeHtml(podcast.title || 'Unknown Podcast'); + const author = escapeHtml(podcast.author || ''); + const image = podcast.image || ''; + const description = escapeHtml((podcast.description || '').substring(0, 120)); + const episodeCount = podcast.episodeCount || 0; + + return ` +
+
+ ${title} +
+ +
+
+
+

${title}

+

${author}

+

${description}${podcast.description?.length > 120 ? '...' : ''}

+ ${episodeCount} episodes +
+
+ `; + } + + attachPodcastCardListeners(container, podcasts) { + const cards = container.querySelectorAll('.card[data-podcast-id]'); + cards.forEach((card) => { + const podcastId = card.dataset.podcastId; + const podcast = podcasts.find((p) => p.id === podcastId); + if (podcast) { + card.addEventListener('click', () => { + navigate(`/podcasts/${podcastId}`); + }); + } + }); + } + + transformPodcastEpisodeToTrack(episode, podcastTitle = 'Unknown Podcast') { + return { + id: `podcast_${episode.id}`, + title: episode.title, + artist: { id: null, name: podcastTitle }, + artists: [{ id: null, name: podcastTitle }], + album: { + id: null, + title: podcastTitle, + cover: episode.image || episode.feedImage || '', + }, + duration: episode.duration, + explicit: episode.explicit, + dateAdded: episode.datePublished, + isPodcast: true, + enclosureUrl: episode.enclosureUrl, + enclosureType: episode.enclosureType, + enclosureLength: episode.enclosureLength, + episodeNumber: episode.episode, + episodeType: episode.episodeType, + season: episode.season, + description: episode.description, + podcastEpisode: episode, + }; + } } diff --git a/js/utils.js b/js/utils.js index b7425ab..e6d5856 100644 --- a/js/utils.js +++ b/js/utils.js @@ -41,8 +41,12 @@ export const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment export const formatTime = (seconds) => { if (isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); + if (h > 0) { + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + } return `${m}:${String(s).padStart(2, '0')}`; };