340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import axios, { AxiosInstance } from "axios";
|
|
import { redisClient } from "../utils/redis";
|
|
|
|
interface ItunesPodcast {
|
|
collectionId: number;
|
|
collectionName: string;
|
|
artistName: string;
|
|
artworkUrl600?: string;
|
|
artworkUrl100?: string;
|
|
feedUrl: string;
|
|
genres: string[];
|
|
trackCount?: number;
|
|
country?: string;
|
|
primaryGenreName?: string;
|
|
contentAdvisoryRating?: string;
|
|
collectionViewUrl?: string;
|
|
}
|
|
|
|
class ItunesService {
|
|
private client: AxiosInstance;
|
|
private lastRequestTime = 0;
|
|
private readonly RATE_LIMIT_MS = 3000; // 20 requests per minute = 3 seconds between requests
|
|
|
|
constructor() {
|
|
this.client = axios.create({
|
|
baseURL: "https://itunes.apple.com",
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
|
|
private async rateLimit() {
|
|
const now = Date.now();
|
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
|
|
if (timeSinceLastRequest < this.RATE_LIMIT_MS) {
|
|
const delay = this.RATE_LIMIT_MS - timeSinceLastRequest;
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
|
|
this.lastRequestTime = Date.now();
|
|
}
|
|
|
|
private async cachedRequest<T>(
|
|
cacheKey: string,
|
|
requestFn: () => Promise<T>,
|
|
ttlSeconds = 604800 // 7 days default
|
|
): Promise<T> {
|
|
try {
|
|
const cached = await redisClient.get(cacheKey);
|
|
if (cached) {
|
|
return JSON.parse(cached);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Redis get error:", err);
|
|
}
|
|
|
|
await this.rateLimit();
|
|
const data = await requestFn();
|
|
|
|
try {
|
|
await redisClient.setEx(cacheKey, ttlSeconds, JSON.stringify(data));
|
|
} catch (err) {
|
|
console.warn("Redis set error:", err);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Search for podcasts by term
|
|
*/
|
|
async searchPodcasts(
|
|
term: string,
|
|
limit = 20
|
|
): Promise<ItunesPodcast[]> {
|
|
const cacheKey = `itunes:search:${term}:${limit}`;
|
|
|
|
return this.cachedRequest(
|
|
cacheKey,
|
|
async () => {
|
|
const response = await this.client.get("/search", {
|
|
params: {
|
|
term,
|
|
media: "podcast",
|
|
entity: "podcast",
|
|
limit,
|
|
},
|
|
});
|
|
|
|
return response.data.results || [];
|
|
},
|
|
2592000 // 30 days - podcast catalog changes slowly
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lookup podcast by iTunes ID
|
|
*/
|
|
async getPodcastById(podcastId: number): Promise<ItunesPodcast | null> {
|
|
const cacheKey = `itunes:podcast:${podcastId}`;
|
|
|
|
return this.cachedRequest(
|
|
cacheKey,
|
|
async () => {
|
|
const response = await this.client.get("/lookup", {
|
|
params: {
|
|
id: podcastId,
|
|
entity: "podcast",
|
|
},
|
|
});
|
|
|
|
const results = response.data.results || [];
|
|
return results.length > 0 ? results[0] : null;
|
|
},
|
|
2592000 // 30 days
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract primary keywords from podcast title/description for "similar podcasts" search
|
|
*/
|
|
extractSearchKeywords(
|
|
title: string,
|
|
description?: string,
|
|
author?: string
|
|
): string[] {
|
|
const commonWords = new Set([
|
|
"the",
|
|
"a",
|
|
"an",
|
|
"and",
|
|
"or",
|
|
"but",
|
|
"in",
|
|
"on",
|
|
"at",
|
|
"to",
|
|
"for",
|
|
"of",
|
|
"with",
|
|
"by",
|
|
"from",
|
|
"up",
|
|
"about",
|
|
"into",
|
|
"through",
|
|
"during",
|
|
"before",
|
|
"after",
|
|
"above",
|
|
"below",
|
|
"between",
|
|
"under",
|
|
"again",
|
|
"further",
|
|
"then",
|
|
"once",
|
|
"here",
|
|
"there",
|
|
"when",
|
|
"where",
|
|
"why",
|
|
"how",
|
|
"all",
|
|
"both",
|
|
"each",
|
|
"few",
|
|
"more",
|
|
"most",
|
|
"other",
|
|
"some",
|
|
"such",
|
|
"no",
|
|
"nor",
|
|
"not",
|
|
"only",
|
|
"own",
|
|
"same",
|
|
"so",
|
|
"than",
|
|
"too",
|
|
"very",
|
|
"can",
|
|
"will",
|
|
"just",
|
|
"should",
|
|
"now",
|
|
"podcast",
|
|
"show",
|
|
"episode",
|
|
"episodes",
|
|
]);
|
|
|
|
// Combine title and description
|
|
const text = [title, description || "", author || ""]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.replace(/[^\w\s]/g, " "); // Remove punctuation
|
|
|
|
// Extract words, filter common words, and count occurrences
|
|
const words = text.split(/\s+/).filter((word) => {
|
|
return (
|
|
word.length > 3 &&
|
|
!commonWords.has(word) &&
|
|
!/^\d+$/.test(word) // Remove pure numbers
|
|
);
|
|
});
|
|
|
|
// Count word frequency
|
|
const wordCount = new Map<string, number>();
|
|
words.forEach((word) => {
|
|
wordCount.set(word, (wordCount.get(word) || 0) + 1);
|
|
});
|
|
|
|
// Sort by frequency and take top 5
|
|
const topWords = Array.from(wordCount.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([word]) => word);
|
|
|
|
return topWords;
|
|
}
|
|
|
|
/**
|
|
* Get similar podcasts based on keywords extracted from title/description
|
|
* This provides a "similar podcasts" feature similar to Last.fm for music
|
|
*/
|
|
async getSimilarPodcasts(
|
|
title: string,
|
|
description?: string,
|
|
author?: string,
|
|
limit = 10
|
|
): Promise<ItunesPodcast[]> {
|
|
const keywords = this.extractSearchKeywords(title, description, author);
|
|
|
|
if (keywords.length === 0) {
|
|
console.log(
|
|
"No keywords extracted for similar podcast search, falling back to title"
|
|
);
|
|
return this.searchPodcasts(title, limit);
|
|
}
|
|
|
|
console.log(
|
|
` Searching for similar podcasts using keywords: ${keywords.join(", ")}`
|
|
);
|
|
|
|
// Search using the top keyword (most relevant)
|
|
const searchTerm = keywords[0];
|
|
const cacheKey = `itunes:similar:${searchTerm}:${limit}`;
|
|
|
|
return this.cachedRequest(
|
|
cacheKey,
|
|
async () => {
|
|
const results = await this.searchPodcasts(searchTerm, limit * 2);
|
|
|
|
// Filter out the original podcast (by title similarity)
|
|
const titleLower = title.toLowerCase();
|
|
const filtered = results.filter((podcast) => {
|
|
const podcastTitleLower = podcast.collectionName.toLowerCase();
|
|
// Exclude if titles are very similar (likely same podcast)
|
|
return !podcastTitleLower.includes(titleLower.slice(0, 20));
|
|
});
|
|
|
|
return filtered.slice(0, limit);
|
|
},
|
|
2592000 // 30 days
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get top podcasts by genre using iTunes RSS feeds
|
|
* Note: iTunes Search API doesn't support genreId filtering, but RSS feeds do
|
|
*/
|
|
async getTopPodcastsByGenre(
|
|
genreId: number,
|
|
limit = 20
|
|
): Promise<ItunesPodcast[]> {
|
|
console.log(`[iTunes SERVICE] getTopPodcastsByGenre called with genre=${genreId}, limit=${limit}`);
|
|
const cacheKey = `itunes:genre:${genreId}:${limit}`;
|
|
console.log(`[iTunes SERVICE] Cache key: ${cacheKey}`);
|
|
|
|
const result = await this.cachedRequest(
|
|
cacheKey,
|
|
async () => {
|
|
try {
|
|
console.log(`[iTunes] Fetching genre ${genreId} from RSS feed...`);
|
|
|
|
// Use iTunes RSS feed for top podcasts by genre
|
|
const response = await this.client.get(
|
|
`/us/rss/toppodcasts/genre=${genreId}/limit=${limit}/json`
|
|
);
|
|
|
|
console.log(`[iTunes] Response status: ${response.status}`);
|
|
console.log(`[iTunes] Has feed data: ${!!response.data?.feed}`);
|
|
console.log(`[iTunes] Entries count: ${response.data?.feed?.entry?.length || 0}`);
|
|
|
|
const entries = response.data?.feed?.entry || [];
|
|
|
|
// If only one entry, it might not be an array
|
|
const entriesArray = Array.isArray(entries) ? entries : [entries];
|
|
|
|
console.log(`[iTunes] Processing ${entriesArray.length} entries`);
|
|
|
|
// Convert RSS feed format to our podcast format
|
|
const podcasts = entriesArray.map((entry: any) => {
|
|
const podcast = {
|
|
collectionId: parseInt(entry.id?.attributes?.["im:id"] || "0", 10),
|
|
collectionName: entry["im:name"]?.label || entry.title?.label?.split(" - ")[0] || "Unknown",
|
|
artistName: entry["im:artist"]?.label || entry.title?.label?.split(" - ")[1] || "Unknown",
|
|
artworkUrl600: entry["im:image"]?.find((img: any) => img.attributes?.height === "170")?.label,
|
|
artworkUrl100: entry["im:image"]?.find((img: any) => img.attributes?.height === "60")?.label,
|
|
feedUrl: "", // RSS feed doesn't include feed URL
|
|
genres: entry.category ? [entry.category.attributes?.label] : [],
|
|
trackCount: 0,
|
|
primaryGenreName: entry.category?.attributes?.label,
|
|
collectionViewUrl: entry.link?.attributes?.href,
|
|
};
|
|
console.log(`[iTunes] Mapped podcast: ${podcast.collectionName} (ID: ${podcast.collectionId})`);
|
|
return podcast;
|
|
}).filter((p: any) => p.collectionId > 0); // Filter out invalid entries
|
|
|
|
console.log(`[iTunes] Returning ${podcasts.length} valid podcasts`);
|
|
return podcasts;
|
|
} catch (error) {
|
|
console.error(`[iTunes] ERROR in requestFn:`, error);
|
|
return [];
|
|
}
|
|
},
|
|
2592000 // 30 days
|
|
);
|
|
|
|
console.log(`[iTunes SERVICE] cachedRequest returned ${result.length} podcasts`);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export const itunesService = new ItunesService();
|