kv-music/js/podcasts-api.js
2026-03-22 04:32:20 +00:00

255 lines
9 KiB
JavaScript

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