Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c769de1
feat(route): add iVoox podcast feed
guillevc May 20, 2026
16a0996
feat(route): cover en ivoox podcast urls
guillevc May 21, 2026
5cdbcff
docs(route): clarify iVoox podcast description
guillevc May 21, 2026
66252f0
feat(route): resolve iVoox audio urls
guillevc May 21, 2026
ac27e2d
fix(route): follow ivoox redirect to audio cdn
guillevc May 21, 2026
9c3a298
feat: add channel-level itunes artwork
guillevc May 21, 2026
673ba90
refactor(rss): split serializer helpers
guillevc May 21, 2026
f33a13c
Revert "refactor(rss): split serializer helpers"
guillevc Jun 3, 2026
c8f7c2c
refactor(route/ivoox): inline single-use selectors and ID validator
guillevc Jun 3, 2026
f8b33cb
refactor(route/ivoox): delegate item limiting to middleware
guillevc Jun 3, 2026
b4b2508
refactor(route/ivoox): remove unnecessary parseResponse and first() c…
guillevc Jun 4, 2026
91508cc
fix(route/ivoox): use feeds.ivoox.com directly to skip redirect
guillevc Jun 4, 2026
0a3d19d
refactor(route/ivoox): remove unnecessary first() on channel image el…
guillevc Jun 4, 2026
4eb1429
refactor(route/ivoox): use guid directly and type helper params
guillevc Jun 4, 2026
d19aaba
refactor(route/ivoox): use non-deprecated xml option
guillevc Jun 6, 2026
afd75e0
refactor(route/ivoox): drop decodeHTML and hoist repeated childText c…
guillevc Jun 6, 2026
de9e257
fix(route/ivoox): restore first() on duplicate itunes:explicit element
guillevc Jun 6, 2026
9eed658
refactor(route/ivoox): inline childText and childAttr helpers
guillevc Jun 6, 2026
3a5ffa7
refactor(route/ivoox): inline normalizeEpisodeId logic
guillevc Jun 7, 2026
21e9872
Merge branch 'DIYgod:master' into feat/ivoox-podcast-route
guillevc Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/routes/ivoox/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Namespace } from '@/types';

export const namespace: Namespace = {
name: 'iVoox',
url: 'www.ivoox.com',
categories: ['multimedia'],
lang: 'es',
};
188 changes: 188 additions & 0 deletions lib/routes/ivoox/podcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { load } from 'cheerio';

import InvalidParameterError from '@/errors/types/invalid-parameter';
import type { Data, DataItem, Route } from '@/types';
import { ViewType } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';

const rootUrl = 'https://www.ivoox.com';

export const route: Route = {
path: '/podcast/:id',
categories: ['multimedia'],
example: '/ivoox/podcast/11178419',
parameters: {
id: 'Podcast ID, can be found in the iVoox podcast URL after `_sq_f`, for example `11178419` or `f11178419`',
},
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true,
supportBT: false,
supportPodcast: true,
supportScihub: false,
},
radar: [
{
source: ['www.ivoox.com/podcast-*_sq_f:id_1.html', 'www.ivoox.com/en/podcast-*_sq_f:id_1.html', 'www.ivoox.com/*_sq_f:id_1.html', 'www.ivoox.com/en/*_sq_f:id_1.html'],
target: (params) => `/podcast/${params.id}`,
},
],
name: 'Podcast',
url: 'www.ivoox.com',
maintainers: ['guillevc'],
handler,
description: 'Transforms an iVoox podcast page into an RSS feed that exposes the full episode audio enclosures instead of the short clip feed.',
view: ViewType.Audios,
};

async function handler(ctx): Promise<Data> {
const rawId: string = ctx.req.param('id');
const idMatch = /^f?(\d+)$/i.exec(rawId);
if (!idMatch) {
throw new InvalidParameterError(`Invalid iVoox podcast ID: ${rawId}`);
}
const id = idMatch[1];
const feedUrl = `https://feeds.ivoox.com/feed_fg_f${id}_filtro_1.xml`;
const response = await ofetch(feedUrl);

const $ = load(response, { xml: true });
const channel = $('channel');
if (!channel.length) {
throw new Error(`Invalid iVoox podcast feed for ID ${id}`);
}

const items = (
await Promise.all(
channel
.children('item')
.toArray()
.map(async (element): Promise<DataItem | undefined> => {
const itemElement = $(element);
const enclosure = itemElement.children('enclosure');
const enclosureUrl = enclosure.attr('url');
const guid = itemElement.children('guid').text();
const itemIdMatch = /(?:_rf_|\/)(\d+)(?:_\d+)?(?:\.html)?/i.exec(guid) ?? /(\d+)/.exec(guid);
const itemId = itemIdMatch?.[1];

if (!enclosureUrl) {
return;
}

const title = itemElement.children('title').text();
const link = itemElement.children('link').text();
const image = itemElement.children(String.raw`itunes\:image`).attr('href');
const length = parseOptionalInteger(enclosure.attr('length'));
const resolvedEnclosureUrl = itemId ? await resolveEpisodeAudioUrl(itemId, enclosureUrl, link) : enclosureUrl;

return {
title,
description: itemElement.children('description').text() || undefined,
link: link || undefined,
pubDate: parseOptionalDate(itemElement.children('pubDate').text()),
guid: guid || undefined,
enclosure_url: resolvedEnclosureUrl,
enclosure_type: enclosure.attr('type') || mediaTypeFromUrl(resolvedEnclosureUrl),
enclosure_title: title,
...(length !== undefined && { enclosure_length: length }),
itunes_duration: itemElement.children(String.raw`itunes\:duration`).text() || undefined,
itunes_item_image: image,
};
})
)
).filter((current): current is DataItem => current !== undefined);

const rssImageUrl = channel.children('image').children('url').text();
const itunesImageUrl = channel.children(String.raw`itunes\:image`).attr('href');

return {
title: channel.children('title').text(),
description: channel.children('description').text() || undefined,
link: channel.children('link').text() || rootUrl,
item: items,
image: rssImageUrl || itunesImageUrl,
itunes_image: itunesImageUrl || rssImageUrl || undefined,
language: normalizeLanguage(channel.children('language').text()),
feedLink: feedUrl,
itunes_author: channel.children(String.raw`itunes\:author`).text() || undefined,
itunes_category: channel.children(String.raw`itunes\:category`).attr('text'),
itunes_explicit:
channel
.children(String.raw`itunes\:explicit`)
.first()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept first() only here. Since for some reason the explicit element is returned twice which results in "truetrue" strings if first() is not used.

.text() || undefined,
};
}

function resolveEpisodeAudioUrl(audioId: string, fallbackUrl: string, referer: string): Promise<string> {
return cache.tryGet(`ivoox:audio-url:${audioId}`, async () => {
try {
const response = await ofetch(`https://vcore-web.ivoox.com/v1/public/audios/${audioId}/download-url`);
const downloadUrl = response?.data?.downloadUrl;
if (typeof downloadUrl === 'string' && downloadUrl) {
const audioResponse = await ofetch.raw(new URL(downloadUrl, rootUrl).href, {
headers: {
Referer: referer,
},
redirect: 'manual',
method: 'GET',
});

if (audioResponse.status >= 300 && audioResponse.status < 400) {
const location = audioResponse.headers.get('location');
if (location) {
return new URL(location, rootUrl).href;
}
}

if (audioResponse.url) {
return audioResponse.url;
}

return new URL(downloadUrl, rootUrl).href;
}
} catch {
// Fall back to the original feed enclosure when iVoox does not return a direct URL.
}

return fallbackUrl;
});
}

function parseOptionalDate(value: string): Date | undefined {
return value ? parseDate(value) : undefined;
}

function parseOptionalInteger(value: string | undefined): number | undefined {
if (!value) {
return;
}

const number = Number.parseInt(value, 10);
return Number.isNaN(number) ? undefined : number;
}

function normalizeLanguage(value: string): Data['language'] | undefined {
const language = value.toLowerCase();
return language ? ((language === 'es-es' ? 'es' : language) as Data['language']) : undefined;
}

function mediaTypeFromUrl(url: string): string {
const pathname = new URL(url).pathname.toLowerCase();
if (pathname.endsWith('.m4a')) {
return 'audio/mp4';
}

if (pathname.endsWith('.ogg') || pathname.endsWith('.opus')) {
return 'audio/ogg';
}

if (pathname.endsWith('.aac')) {
return 'audio/aac';
}

return 'audio/mpeg';
}
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type Data = {
itunes_author?: string;
itunes_category?: string;
itunes_explicit?: string | boolean;
itunes_image?: string;
id?: string;
icon?: string;
logo?: string;
Expand Down
3 changes: 2 additions & 1 deletion lib/views/rss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { FC } from 'hono/jsx';
import type { Data } from '@/types';

const RSS: FC<{ data: Data }> = ({ data }) => {
const hasItunes = data.itunes_author || data.itunes_category || (data.item && data.item.some((i) => i.itunes_item_image || i.itunes_duration));
const hasItunes = data.itunes_author || data.itunes_category || data.itunes_image || (data.item && data.item.some((i) => i.itunes_item_image || i.itunes_duration));
const hasMedia = data.item?.some((i) => i.media);
const isTelegramLink = data.link?.startsWith('https://t.me/s/');

Expand All @@ -19,6 +19,7 @@ const RSS: FC<{ data: Data }> = ({ data }) => {
{data.itunes_author && <itunes:author>{data.itunes_author}</itunes:author>}
{data.itunes_category && <itunes:category text={data.itunes_category} />}
{data.itunes_author && <itunes:explicit>{data.itunes_explicit || 'false'}</itunes:explicit>}
{data.itunes_image && <itunes:image href={data.itunes_image} />}
<language>{data.language || 'en'}</language>
{data.image && (
<image>
Expand Down
2 changes: 2 additions & 0 deletions lib/views/views.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('RSS view', () => {
itunes_author: 'Podcast Author',
itunes_category: 'Tech',
itunes_explicit: 'true',
itunes_image: 'https://example.com/podcast-itunes.jpg',
image: 'https://example.com/image.jpg',
item: [
{
Expand Down Expand Up @@ -129,6 +130,7 @@ describe('RSS view', () => {
expect(html).toContain('<itunes:author>Podcast Author</itunes:author>');
expect(html).toContain('itunes:category text="Tech"');
expect(html).toContain('<itunes:explicit>true</itunes:explicit>');
expect(html).toContain('<itunes:image href="https://example.com/podcast-itunes.jpg"');
expect(html).toContain('<height>31</height>');
expect(html).toContain('<width>88</width>');
expect(html).toContain('media:content');
Expand Down