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:
parent
b1e90ea8d6
commit
aa09bc089c
15 changed files with 284 additions and 9 deletions
4
drizzle/0011_add_youtube_enhancements.sql
Normal file
4
drizzle/0011_add_youtube_enhancements.sql
Normal 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'
|
||||||
|
|
@ -78,6 +78,13 @@
|
||||||
"when": 1775196046744,
|
"when": 1775196046744,
|
||||||
"tag": "0010_special_ghost_rider",
|
"tag": "0010_special_ghost_rider",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775253600000,
|
||||||
|
"tag": "0011_add_youtube_enhancements",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: 'mkv',
|
containerFormat: 'mkv',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -388,7 +388,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -642,7 +642,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -686,7 +686,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: 'mkv',
|
containerFormat: 'mkv',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -738,7 +738,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ function makeProfile(overrides: Partial<FormatProfile> = {}): FormatProfile {
|
||||||
containerFormat: 'mp4',
|
containerFormat: 'mp4',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export interface CreateFormatProfileData {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
|
embedChapters?: boolean;
|
||||||
|
embedThumbnail?: boolean;
|
||||||
|
sponsorBlockRemove?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fields that can be updated on an existing format profile. */
|
/** Fields that can be updated on an existing format profile. */
|
||||||
|
|
@ -28,6 +31,9 @@ export interface UpdateFormatProfileData {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
|
embedChapters?: boolean;
|
||||||
|
embedThumbnail?: boolean;
|
||||||
|
sponsorBlockRemove?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -60,6 +66,9 @@ export async function createFormatProfile(
|
||||||
isDefault: data.isDefault ?? false,
|
isDefault: data.isDefault ?? false,
|
||||||
subtitleLanguages: data.subtitleLanguages ?? null,
|
subtitleLanguages: data.subtitleLanguages ?? null,
|
||||||
embedSubtitles: data.embedSubtitles ?? false,
|
embedSubtitles: data.embedSubtitles ?? false,
|
||||||
|
embedChapters: data.embedChapters ?? false,
|
||||||
|
embedThumbnail: data.embedThumbnail ?? false,
|
||||||
|
sponsorBlockRemove: data.sponsorBlockRemove ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -180,6 +189,9 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
|
||||||
isDefault: row.isDefault,
|
isDefault: row.isDefault,
|
||||||
subtitleLanguages: row.subtitleLanguages ?? null,
|
subtitleLanguages: row.subtitleLanguages ?? null,
|
||||||
embedSubtitles: row.embedSubtitles,
|
embedSubtitles: row.embedSubtitles,
|
||||||
|
embedChapters: row.embedChapters,
|
||||||
|
embedThumbnail: row.embedThumbnail,
|
||||||
|
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ export const formatProfiles = sqliteTable('format_profiles', {
|
||||||
isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
|
isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
|
||||||
subtitleLanguages: text('subtitle_languages'),
|
subtitleLanguages: text('subtitle_languages'),
|
||||||
embedSubtitles: integer('embed_subtitles', { mode: 'boolean' }).notNull().default(false),
|
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')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(datetime('now'))`),
|
.default(sql`(datetime('now'))`),
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,18 @@ function detectPlatform(url: string): Platform | null {
|
||||||
return 'soundcloud';
|
return 'soundcloud';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any valid URL → Generic (yt-dlp supports 1000+ sites)
|
||||||
|
if (/^https?:\/\/.+/.test(url)) {
|
||||||
|
return 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_LABELS: Record<Platform, string> = {
|
const PLATFORM_LABELS: Record<Platform, string> = {
|
||||||
youtube: 'YouTube',
|
youtube: 'YouTube',
|
||||||
soundcloud: 'SoundCloud',
|
soundcloud: 'SoundCloud',
|
||||||
|
generic: 'Generic',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { Platform } from '@shared/types/index';
|
||||||
const PLATFORM_STYLES: Record<string, { color: string; label: string }> = {
|
const PLATFORM_STYLES: Record<string, { color: string; label: string }> = {
|
||||||
youtube: { color: '#ff0000', label: 'YouTube' },
|
youtube: { color: '#ff0000', label: 'YouTube' },
|
||||||
soundcloud: { color: '#ff7700', label: 'SoundCloud' },
|
soundcloud: { color: '#ff7700', label: 'SoundCloud' },
|
||||||
|
generic: { color: '#6366f1', label: 'Generic' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_STYLE = { color: 'var(--text-secondary)', label: 'Unknown' };
|
const DEFAULT_STYLE = { color: 'var(--text-secondary)', label: 'Unknown' };
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { HealthService } from './services/health';
|
||||||
import { PlatformRegistry } from './sources/platform-source';
|
import { PlatformRegistry } from './sources/platform-source';
|
||||||
import { YouTubeSource } from './sources/youtube';
|
import { YouTubeSource } from './sources/youtube';
|
||||||
import { SoundCloudSource } from './sources/soundcloud';
|
import { SoundCloudSource } from './sources/soundcloud';
|
||||||
|
import { GenericSource } from './sources/generic';
|
||||||
import { Platform } from './types/index';
|
import { Platform } from './types/index';
|
||||||
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
|
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
|
||||||
import type { ViteDevServer } from 'vite';
|
import type { ViteDevServer } from 'vite';
|
||||||
|
|
@ -138,6 +139,7 @@ async function main(): Promise<void> {
|
||||||
const platformRegistry = new PlatformRegistry();
|
const platformRegistry = new PlatformRegistry();
|
||||||
platformRegistry.register(Platform.YouTube, new YouTubeSource());
|
platformRegistry.register(Platform.YouTube, new YouTubeSource());
|
||||||
platformRegistry.register(Platform.SoundCloud, new SoundCloudSource());
|
platformRegistry.register(Platform.SoundCloud, new SoundCloudSource());
|
||||||
|
platformRegistry.register(Platform.Generic, new GenericSource());
|
||||||
|
|
||||||
scheduler = new SchedulerService(db, platformRegistry, rateLimiter, {
|
scheduler = new SchedulerService(db, platformRegistry, rateLimiter, {
|
||||||
onNewContent: (contentItemId: number) => {
|
onNewContent: (contentItemId: number) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { parseIdParam } from './helpers';
|
||||||
import { PlatformRegistry } from '../../sources/platform-source';
|
import { PlatformRegistry } from '../../sources/platform-source';
|
||||||
import { YouTubeSource } from '../../sources/youtube';
|
import { YouTubeSource } from '../../sources/youtube';
|
||||||
import { SoundCloudSource } from '../../sources/soundcloud';
|
import { SoundCloudSource } from '../../sources/soundcloud';
|
||||||
|
import { GenericSource } from '../../sources/generic';
|
||||||
import { YtDlpError } from '../../sources/yt-dlp';
|
import { YtDlpError } from '../../sources/yt-dlp';
|
||||||
import { Platform } from '../../types/index';
|
import { Platform } from '../../types/index';
|
||||||
import type { MonitoringMode } from '../../types/index';
|
import type { MonitoringMode } from '../../types/index';
|
||||||
|
|
@ -25,6 +26,7 @@ function buildDefaultRegistry(): PlatformRegistry {
|
||||||
const registry = new PlatformRegistry();
|
const registry = new PlatformRegistry();
|
||||||
registry.register(Platform.YouTube, new YouTubeSource());
|
registry.register(Platform.YouTube, new YouTubeSource());
|
||||||
registry.register(Platform.SoundCloud, new SoundCloudSource());
|
registry.register(Platform.SoundCloud, new SoundCloudSource());
|
||||||
|
registry.register(Platform.Generic, new GenericSource());
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ const createFormatProfileBodySchema = {
|
||||||
isDefault: { type: 'boolean' as const },
|
isDefault: { type: 'boolean' as const },
|
||||||
subtitleLanguages: { type: 'string' as const, nullable: true },
|
subtitleLanguages: { type: 'string' as const, nullable: true },
|
||||||
embedSubtitles: { type: 'boolean' as const },
|
embedSubtitles: { type: 'boolean' as const },
|
||||||
|
embedChapters: { type: 'boolean' as const },
|
||||||
|
embedThumbnail: { type: 'boolean' as const },
|
||||||
|
sponsorBlockRemove: { type: 'string' as const, nullable: true },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -37,6 +40,9 @@ const updateFormatProfileBodySchema = {
|
||||||
isDefault: { type: 'boolean' as const },
|
isDefault: { type: 'boolean' as const },
|
||||||
subtitleLanguages: { type: 'string' as const, nullable: true },
|
subtitleLanguages: { type: 'string' as const, nullable: true },
|
||||||
embedSubtitles: { type: 'boolean' as const },
|
embedSubtitles: { type: 'boolean' as const },
|
||||||
|
embedChapters: { type: 'boolean' as const },
|
||||||
|
embedThumbnail: { type: 'boolean' as const },
|
||||||
|
sponsorBlockRemove: { type: 'string' as const, nullable: true },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -65,7 +71,7 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
containerFormat?: string | null;
|
containerFormat?: string | null;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean; embedChapters?: boolean; embedThumbnail?: boolean; sponsorBlockRemove?: string | null;
|
||||||
};
|
};
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/format-profile',
|
'/api/v1/format-profile',
|
||||||
|
|
@ -117,7 +123,7 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
containerFormat?: string | null;
|
containerFormat?: string | null;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean; embedChapters?: boolean; embedThumbnail?: boolean; sponsorBlockRemove?: string | null;
|
||||||
};
|
};
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/format-profile/:id',
|
'/api/v1/format-profile/:id',
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,24 @@ export class DownloadService {
|
||||||
// Subtitle support
|
// Subtitle support
|
||||||
args.push(...this.buildSubtitleArgs(formatProfile));
|
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
|
// Always include these flags
|
||||||
args.push('--no-playlist');
|
args.push('--no-playlist');
|
||||||
args.push('--print', 'after_move:filepath');
|
args.push('--print', 'after_move:filepath');
|
||||||
|
|
|
||||||
205
src/sources/generic.ts
Normal file
205
src/sources/generic.ts
Normal 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));
|
||||||
|
}
|
||||||
|
|
@ -115,5 +115,10 @@ function detectPlatformFromUrl(url: string): Platform | null {
|
||||||
return 'soundcloud' as Platform;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
export const Platform = {
|
export const Platform = {
|
||||||
YouTube: 'youtube',
|
YouTube: 'youtube',
|
||||||
SoundCloud: 'soundcloud',
|
SoundCloud: 'soundcloud',
|
||||||
|
Generic: 'generic',
|
||||||
} as const;
|
} as const;
|
||||||
export type Platform = (typeof Platform)[keyof typeof Platform];
|
export type Platform = (typeof Platform)[keyof typeof Platform];
|
||||||
|
|
||||||
|
|
@ -140,6 +141,9 @@ export interface FormatProfile {
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
subtitleLanguages: string | null; // comma-separated lang codes e.g. "en,es,fr"
|
subtitleLanguages: string | null; // comma-separated lang codes e.g. "en,es,fr"
|
||||||
embedSubtitles: boolean;
|
embedSubtitles: boolean;
|
||||||
|
embedChapters: boolean;
|
||||||
|
embedThumbnail: boolean;
|
||||||
|
sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue