feat: podcasts

This commit is contained in:
edidealt 2026-03-22 04:32:10 +00:00
parent 3cd54a2b5f
commit c2f8d3fca1
9 changed files with 669 additions and 3 deletions

View file

@ -1858,6 +1858,7 @@
<button class="search-tab" data-tab="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</button>
<button class="search-tab" data-tab="playlists">Playlists</button>
<button class="search-tab" data-tab="podcasts">Podcasts</button>
</div>
<div class="search-tab-content active" id="search-tab-tracks">
<div class="track-list" id="search-tracks-container"></div>
@ -1874,6 +1875,9 @@
<div class="search-tab-content" id="search-tab-playlists">
<div class="card-grid" id="search-playlists-container"></div>
</div>
<div class="search-tab-content" id="search-tab-podcasts">
<div class="card-grid" id="search-podcasts-container"></div>
</div>
</div>
<div id="page-library" class="page">
@ -2017,6 +2021,46 @@
<div id="unreleased-content" style="padding: 1rem 0"></div>
</div>
<div id="page-podcasts" class="page">
<header class="detail-header">
<img
id="podcasts-detail-image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt=""
class="detail-header-image artist"
/>
<div class="detail-header-info">
<div class="type">Podcast</div>
<h1 class="title" id="podcasts-detail-name"></h1>
<div class="meta" id="podcasts-detail-meta"></div>
<div class="detail-header-actions">
<button id="play-podcasts-btn" class="btn-primary" title="Play Latest Episode">
<use svg="./images/play.svg" size="20" />
<span>Play Latest</span>
</button>
</div>
</div>
</header>
<div class="content-section">
<h2 class="section-title">Episodes</h2>
<div class="track-list" id="podcasts-episodes-container"></div>
</div>
</div>
<div id="page-podcasts-browse" class="page">
<h2 class="section-title">Browse Podcasts</h2>
<div class="search-tabs">
<button class="search-tab active" data-tab="trending">Trending</button>
<button class="search-tab" data-tab="recent">Recent</button>
</div>
<div class="search-tab-content active" id="podcasts-tab-trending">
<div class="card-grid" id="podcasts-trending-container"></div>
</div>
<div class="search-tab-content" id="podcasts-tab-recent">
<div class="card-grid" id="podcasts-recent-container"></div>
</div>
</div>
<div id="page-tracker-artist" class="page">
<header class="detail-header">
<img

View file

@ -225,6 +225,10 @@ const syncManager = {
streamStartDate: item.streamStartDate || null,
version: item.version || null,
mixes: item.mixes || null,
isPodcast: item.isPodcast || (item.id && String(item.id).startsWith('podcast_')) || null,
enclosureUrl: item.enclosureUrl || null,
enclosureType: item.enclosureType || null,
enclosureLength: item.enclosureLength || null,
};
}

View file

@ -257,6 +257,10 @@ export class MusicDatabase {
mixes: item.mixes || null,
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
trackerInfo: item.trackerInfo || null,
isPodcast: item.isPodcast || (item.id && String(item.id).startsWith('podcast_')) || null,
enclosureUrl: item.enclosureUrl || null,
enclosureType: item.enclosureType || null,
enclosureLength: item.enclosureLength || null,
audioUrl: item.remoteUrl || item.audioUrl || null,
remoteUrl: item.remoteUrl || null,
audioQuality: item.audioQuality || null,

View file

@ -3,6 +3,7 @@
import { LosslessAPI } from './api.js';
import { QobuzAPI } from './qobuz-api.js';
import { PodcastsAPI } from './podcasts-api.js';
import { musicProviderSettings } from './storage.js';
export class MusicAPI {
@ -18,6 +19,7 @@ export class MusicAPI {
constructor(settings) {
this.tidalAPI = new LosslessAPI(settings);
this.qobuzAPI = new QobuzAPI();
this.podcastsAPI = new PodcastsAPI();
this._settings = settings;
this.videoArtworkCache = new Map();
}
@ -71,6 +73,22 @@ export class MusicAPI {
return this.tidalAPI.searchVideos(query, options);
}
async searchPodcasts(query, options = {}) {
return this.podcastsAPI.searchPodcasts(query, options);
}
async getPodcast(id, options = {}) {
return this.podcastsAPI.getPodcastById(id, options);
}
async getPodcastEpisodes(id, options = {}) {
return this.podcastsAPI.getPodcastEpisodes(id, options);
}
async getTrendingPodcasts(options = {}) {
return this.podcastsAPI.getTrendingPodcasts(options);
}
// Get methods
async getTrack(id, quality, provider = null) {
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();

View file

@ -467,7 +467,8 @@ export class Player {
for (const { track } of tracksToPreload) {
if (this.preloadCache.has(track.id)) continue;
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
if (track.isLocal || isTracker || (track.audioUrl && !track.isLocal)) continue;
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue;
try {
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
@ -781,8 +782,34 @@ export class Player {
let streamUrl;
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
if (isTracker || (track.audioUrl && !track.isLocal)) {
if (isPodcast) {
streamUrl = track.enclosureUrl;
if (!streamUrl) {
console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`);
track.isUnavailable = true;
this.playNext();
return;
}
if (this.playbackSequence !== currentSequence) return;
this.currentRgValues = null;
this.applyReplayGain();
activeElement.src = streamUrl;
this.applyAudioEffects();
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
if (!canPlay || this.playbackSequence !== currentSequence) return;
if (startTime > 0) {
activeElement.currentTime = startTime;
}
const played = await this.safePlay(activeElement);
if (!played) return;
} else if (isTracker || (track.audioUrl && !track.isLocal)) {
streamUrl = track.audioUrl;
if (

255
js/podcasts-api.js Normal file
View file

@ -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();

View file

@ -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;

303
js/ui.js
View file

@ -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 = '<div class="skeleton-track"></div>'.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 `
<div class="card" data-podcast-id="${podcast.id}">
<div class="card-image-container">
<img src="${image}" alt="${title}" loading="lazy" onerror="this.style.display='none'" />
<div class="card-image-placeholder" ${image ? 'style="display:none"' : ''}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23333" width="100" height="100"/><circle cx="50" cy="45" r="20" fill="%23666"/><rect x="35" y="70" width="30" height="15" rx="3" fill="%23666"/></svg>
</div>
</div>
<div class="card-info">
<h3 class="card-title">${title}</h3>
<p class="card-subtitle">${author}</p>
<p class="card-description">${description}${podcast.description?.length > 120 ? '...' : ''}</p>
<span class="card-meta">${episodeCount} episodes</span>
</div>
</div>
`;
}
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,
};
}
}

View file

@ -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')}`;
};