feat: Generic platform + YouTube enhancements (chapters, SponsorBlock, thumbnails)

Generic Platform:
- New 'generic' platform type — catch-all for any URL yt-dlp supports
- GenericSource resolves channel metadata from any URL via yt-dlp extractors
- Content type auto-detection (video/audio/livestream) from yt-dlp metadata
- Works with Vimeo, Twitch, Bandcamp, Dailymotion, and 1000+ other sites
- Registered in both scheduler registry and channel route registry
- Frontend: indigo badge, URL detection fallback, AddChannelModal support

YouTube Enhancements:
- embedChapters: --embed-chapters flag on FormatProfile
- embedThumbnail: --embed-thumbnail flag on FormatProfile
- sponsorBlockRemove: --sponsorblock-remove with configurable categories
  (sponsor, selfpromo, interaction, intro, outro, preview, music_offtopic, filler)
- Migration 0011: adds columns to format_profiles table
- All three configurable per format profile via API and (future) Settings UI
This commit is contained in:
jlightner 2026-04-04 02:45:02 +00:00
parent b1e90ea8d6
commit aa09bc089c
15 changed files with 284 additions and 9 deletions

View file

@ -0,0 +1,4 @@
-- Add YouTube enhancement columns to format_profiles
ALTER TABLE format_profiles ADD COLUMN embed_chapters INTEGER NOT NULL DEFAULT 0;
ALTER TABLE format_profiles ADD COLUMN embed_thumbnail INTEGER NOT NULL DEFAULT 0;
ALTER TABLE format_profiles ADD COLUMN sponsor_block_remove TEXT; -- comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'

View file

@ -78,6 +78,13 @@
"when": 1775196046744,
"tag": "0010_special_ghost_rider",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1775253600000,
"tag": "0011_add_youtube_enhancements",
"breakpoints": true
}
]
}

View file

@ -334,7 +334,7 @@ describe('DownloadService', () => {
containerFormat: 'mkv',
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
createdAt: '',
updatedAt: '',
};
@ -388,7 +388,7 @@ describe('DownloadService', () => {
containerFormat: null,
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
createdAt: '',
updatedAt: '',
};
@ -642,7 +642,7 @@ describe('DownloadService', () => {
containerFormat: null,
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
createdAt: '',
updatedAt: '',
};
@ -686,7 +686,7 @@ describe('DownloadService', () => {
containerFormat: 'mkv',
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
createdAt: '',
updatedAt: '',
};
@ -738,7 +738,7 @@ describe('DownloadService', () => {
containerFormat: null,
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
createdAt: '',
updatedAt: '',
};

View file

@ -148,7 +148,7 @@ function makeProfile(overrides: Partial<FormatProfile> = {}): FormatProfile {
containerFormat: 'mp4',
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
createdAt: '',
updatedAt: '',
...overrides,

View file

@ -16,6 +16,9 @@ export interface CreateFormatProfileData {
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
embedChapters?: boolean;
embedThumbnail?: boolean;
sponsorBlockRemove?: string | null;
}
/** Fields that can be updated on an existing format profile. */
@ -28,6 +31,9 @@ export interface UpdateFormatProfileData {
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
embedChapters?: boolean;
embedThumbnail?: boolean;
sponsorBlockRemove?: string | null;
}
type Db = LibSQLDatabase<typeof schema>;
@ -60,6 +66,9 @@ export async function createFormatProfile(
isDefault: data.isDefault ?? false,
subtitleLanguages: data.subtitleLanguages ?? null,
embedSubtitles: data.embedSubtitles ?? false,
embedChapters: data.embedChapters ?? false,
embedThumbnail: data.embedThumbnail ?? false,
sponsorBlockRemove: data.sponsorBlockRemove ?? null,
})
.returning();
@ -180,6 +189,9 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
isDefault: row.isDefault,
subtitleLanguages: row.subtitleLanguages ?? null,
embedSubtitles: row.embedSubtitles,
embedChapters: row.embedChapters,
embedThumbnail: row.embedThumbnail,
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};

View file

@ -41,6 +41,9 @@ export const formatProfiles = sqliteTable('format_profiles', {
isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
subtitleLanguages: text('subtitle_languages'),
embedSubtitles: integer('embed_subtitles', { mode: 'boolean' }).notNull().default(false),
embedChapters: integer('embed_chapters', { mode: 'boolean' }).notNull().default(false),
embedThumbnail: integer('embed_thumbnail', { mode: 'boolean' }).notNull().default(false),
sponsorBlockRemove: text('sponsor_block_remove'), // comma-separated categories: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),

View file

@ -31,12 +31,18 @@ function detectPlatform(url: string): Platform | null {
return 'soundcloud';
}
// Any valid URL → Generic (yt-dlp supports 1000+ sites)
if (/^https?:\/\/.+/.test(url)) {
return 'generic';
}
return null;
}
const PLATFORM_LABELS: Record<Platform, string> = {
youtube: 'YouTube',
soundcloud: 'SoundCloud',
generic: 'Generic',
};
// ── Component ──

View file

@ -5,6 +5,7 @@ import type { Platform } from '@shared/types/index';
const PLATFORM_STYLES: Record<string, { color: string; label: string }> = {
youtube: { color: '#ff0000', label: 'YouTube' },
soundcloud: { color: '#ff7700', label: 'SoundCloud' },
generic: { color: '#6366f1', label: 'Generic' },
};
const DEFAULT_STYLE = { color: 'var(--text-secondary)', label: 'Unknown' };

View file

@ -22,6 +22,7 @@ import { HealthService } from './services/health';
import { PlatformRegistry } from './sources/platform-source';
import { YouTubeSource } from './sources/youtube';
import { SoundCloudSource } from './sources/soundcloud';
import { GenericSource } from './sources/generic';
import { Platform } from './types/index';
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
import type { ViteDevServer } from 'vite';
@ -138,6 +139,7 @@ async function main(): Promise<void> {
const platformRegistry = new PlatformRegistry();
platformRegistry.register(Platform.YouTube, new YouTubeSource());
platformRegistry.register(Platform.SoundCloud, new SoundCloudSource());
platformRegistry.register(Platform.Generic, new GenericSource());
scheduler = new SchedulerService(db, platformRegistry, rateLimiter, {
onNewContent: (contentItemId: number) => {

View file

@ -3,6 +3,7 @@ import { parseIdParam } from './helpers';
import { PlatformRegistry } from '../../sources/platform-source';
import { YouTubeSource } from '../../sources/youtube';
import { SoundCloudSource } from '../../sources/soundcloud';
import { GenericSource } from '../../sources/generic';
import { YtDlpError } from '../../sources/yt-dlp';
import { Platform } from '../../types/index';
import type { MonitoringMode } from '../../types/index';
@ -25,6 +26,7 @@ function buildDefaultRegistry(): PlatformRegistry {
const registry = new PlatformRegistry();
registry.register(Platform.YouTube, new YouTubeSource());
registry.register(Platform.SoundCloud, new SoundCloudSource());
registry.register(Platform.Generic, new GenericSource());
return registry;
}

View file

@ -22,6 +22,9 @@ const createFormatProfileBodySchema = {
isDefault: { type: 'boolean' as const },
subtitleLanguages: { type: 'string' as const, nullable: true },
embedSubtitles: { type: 'boolean' as const },
embedChapters: { type: 'boolean' as const },
embedThumbnail: { type: 'boolean' as const },
sponsorBlockRemove: { type: 'string' as const, nullable: true },
},
additionalProperties: false,
};
@ -37,6 +40,9 @@ const updateFormatProfileBodySchema = {
isDefault: { type: 'boolean' as const },
subtitleLanguages: { type: 'string' as const, nullable: true },
embedSubtitles: { type: 'boolean' as const },
embedChapters: { type: 'boolean' as const },
embedThumbnail: { type: 'boolean' as const },
sponsorBlockRemove: { type: 'string' as const, nullable: true },
},
additionalProperties: false,
};
@ -65,7 +71,7 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
containerFormat?: string | null;
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
embedSubtitles?: boolean; embedChapters?: boolean; embedThumbnail?: boolean; sponsorBlockRemove?: string | null;
};
}>(
'/api/v1/format-profile',
@ -117,7 +123,7 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
containerFormat?: string | null;
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
embedSubtitles?: boolean; embedChapters?: boolean; embedThumbnail?: boolean; sponsorBlockRemove?: string | null;
};
}>(
'/api/v1/format-profile/:id',

View file

@ -271,6 +271,24 @@ export class DownloadService {
// Subtitle support
args.push(...this.buildSubtitleArgs(formatProfile));
// Chapter embedding
if (formatProfile?.embedChapters) {
args.push('--embed-chapters');
}
// Thumbnail embedding
if (formatProfile?.embedThumbnail) {
args.push('--embed-thumbnail');
}
// SponsorBlock segment removal
if (formatProfile?.sponsorBlockRemove) {
const categories = formatProfile.sponsorBlockRemove.trim();
if (categories) {
args.push('--sponsorblock-remove', categories);
}
}
// Always include these flags
args.push('--no-playlist');
args.push('--print', 'after_move:filepath');

205
src/sources/generic.ts Normal file
View file

@ -0,0 +1,205 @@
import type { Channel, PlatformSourceMetadata, PlatformContentMetadata, ContentType } from '../types/index';
import type { PlatformSource, FetchRecentContentOptions } from './platform-source';
import { execYtDlp, parseJsonLines, parseSingleJson } from './yt-dlp';
/**
* Generic platform source catch-all for any URL yt-dlp supports.
*
* Works with Vimeo, Twitch VODs, Bandcamp, Dailymotion, Twitter/X,
* Instagram, TikTok, Reddit, news sites with embedded video, blogs,
* and hundreds of other sites yt-dlp can extract from.
*
* Unlike YouTube/SoundCloud sources which use channel-level enumeration,
* the Generic source treats the channel URL as a playlist/page to scrape.
* Content discovery uses yt-dlp's built-in extractors with no platform-specific logic.
*/
export class GenericSource implements PlatformSource {
/**
* Resolve a URL to channel-like metadata.
*
* For generic URLs, the "channel" is whatever yt-dlp identifies as the
* playlist/page/uploader. Falls back to the URL domain as the name
* if yt-dlp can't extract structured metadata.
*/
async resolveChannel(url: string): Promise<PlatformSourceMetadata> {
try {
const result = await execYtDlp(
[
'--dump-single-json',
'--playlist-items', '0',
'--flat-playlist',
url,
],
{ timeout: 30_000 }
);
const data = parseSingleJson(result.stdout) as Record<string, unknown>;
// yt-dlp returns various shapes depending on the site
const name = data.channel
?? data.uploader
?? data.playlist_title
?? data.title
?? new URL(url).hostname;
const platformId = data.channel_id
?? data.uploader_id
?? data.playlist_id
?? data.id
?? url;
const channelUrl = data.channel_url
?? data.uploader_url
?? data.webpage_url
?? url;
// Best thumbnail
const thumbnails = data.thumbnails as Array<{ url: string; width?: number }> | undefined;
const imageUrl = thumbnails?.length
? thumbnails[thumbnails.length - 1].url
: null;
return {
name: String(name),
platformId: String(platformId),
imageUrl,
url: String(channelUrl),
platform: 'generic' as const,
description: data.description ? String(data.description) : null,
};
} catch {
// Fallback: use URL domain as name, URL as identifier
const hostname = (() => {
try { return new URL(url).hostname; } catch { return 'Unknown'; }
})();
return {
name: hostname,
platformId: url,
imageUrl: null,
url,
platform: 'generic' as const,
};
}
}
/**
* Fetch content from a generic URL.
*
* Treats the channel URL as a page/playlist and enumerates items via
* --flat-playlist. Each item is a potential downloadable media file.
*/
async fetchRecentContent(
channel: Channel,
options?: FetchRecentContentOptions
): Promise<PlatformContentMetadata[]> {
const limit = options?.limit ?? 50;
const discoveryOnly = options?.discoveryOnly ?? false;
const existingIds = options?.existingIds ?? new Set<string>();
const rateLimitDelay = options?.rateLimitDelay ?? 2000;
const signal = options?.signal;
// Discovery: enumerate items from the URL
const discoveryTimeout = 60_000 + Math.ceil(limit / 500) * 30_000;
const flatResult = await execYtDlp(
[
'--flat-playlist',
'--dump-json',
'--playlist-items', `1:${limit}`,
channel.url,
],
{ timeout: discoveryTimeout }
);
const flatEntries = parseJsonLines(flatResult.stdout) as Record<string, unknown>[];
const discoveredItems = flatEntries.map((entry) => mapEntry(entry));
if (discoveryOnly) {
return discoveredItems;
}
// Enrichment: fetch full metadata for new items only
const newItems = discoveredItems.filter(
(item) => !existingIds.has(item.platformContentId)
);
if (newItems.length === 0) return discoveredItems;
console.log(
`[generic] Enriching ${newItems.length} new items (${discoveredItems.length - newItems.length} already known)`
);
const enrichedMap = new Map<string, PlatformContentMetadata>();
for (let i = 0; i < newItems.length; i++) {
if (signal?.aborted) {
console.log(`[generic] Enrichment aborted after ${i} items`);
break;
}
const item = newItems[i];
if (i > 0 && rateLimitDelay > 0) {
await sleep(rateLimitDelay);
}
try {
const enrichResult = await execYtDlp(
['--dump-json', '--no-playlist', item.url],
{ timeout: 30_000 }
);
const enrichedEntry = parseSingleJson(enrichResult.stdout) as Record<string, unknown>;
enrichedMap.set(item.platformContentId, mapEntry(enrichedEntry));
} catch (err) {
console.warn(
`[generic] Enrichment failed for ${item.platformContentId}: ${err instanceof Error ? err.message : err}`
);
}
}
return discoveredItems.map((item) => {
const enriched = enrichedMap.get(item.platformContentId);
return enriched ?? item;
});
}
}
// ── Helpers ──
function mapEntry(entry: Record<string, unknown>): PlatformContentMetadata {
const id = String(entry.id ?? entry.url ?? '');
const title = String(entry.title ?? entry.fulltitle ?? 'Untitled');
const url = String(entry.webpage_url ?? entry.url ?? entry.original_url ?? '');
// Content type detection
const liveStatus = entry.live_status as string | undefined;
const isLive = liveStatus === 'is_live' || liveStatus === 'is_upcoming';
const isAudio = entry._type === 'audio'
|| (entry.vcodec === 'none' && entry.acodec !== 'none')
|| /\.(mp3|flac|wav|ogg|opus|m4a|aac)$/i.test(url);
let contentType: ContentType = 'video';
if (isLive) contentType = 'livestream';
else if (isAudio) contentType = 'audio';
// Duration
const duration = typeof entry.duration === 'number' ? Math.round(entry.duration) : null;
// Thumbnail — best quality
const thumbnails = entry.thumbnails as Array<{ url: string }> | undefined;
const thumbnailUrl = thumbnails?.length
? thumbnails[thumbnails.length - 1].url
: (entry.thumbnail as string | undefined) ?? null;
// Published date
let publishedAt: string | null = null;
const uploadDate = entry.upload_date as string | undefined;
if (uploadDate && /^\d{8}$/.test(uploadDate)) {
publishedAt = `${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}T00:00:00Z`;
}
return { platformContentId: id, title, url, contentType, duration, thumbnailUrl, publishedAt };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -115,5 +115,10 @@ function detectPlatformFromUrl(url: string): Platform | null {
return 'soundcloud' as Platform;
}
// Any URL with a valid scheme → Generic (yt-dlp supports 1000+ sites)
if (/^https?:\/\/.+/.test(url)) {
return 'generic' as Platform;
}
return null;
}

View file

@ -3,6 +3,7 @@
export const Platform = {
YouTube: 'youtube',
SoundCloud: 'soundcloud',
Generic: 'generic',
} as const;
export type Platform = (typeof Platform)[keyof typeof Platform];
@ -140,6 +141,9 @@ export interface FormatProfile {
isDefault: boolean;
subtitleLanguages: string | null; // comma-separated lang codes e.g. "en,es,fr"
embedSubtitles: boolean;
embedChapters: boolean;
embedThumbnail: boolean;
sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
createdAt: string;
updatedAt: string;
}