diff --git a/server/src/db/ChannelDB.ts b/server/src/db/ChannelDB.ts index 002b455c7..9a1f7162b 100644 --- a/server/src/db/ChannelDB.ts +++ b/server/src/db/ChannelDB.ts @@ -162,6 +162,7 @@ function updateRequestToChannel(updateReq: SaveChannelRequest): ChannelUpdate { guideFlexTitle: updateReq.guideFlexTitle, transcodeConfigId: updateReq.transcodeConfigId, streamMode: updateReq.streamMode, + subtitlesEnabled: booleanToNumber(updateReq.subtitlesEnabled), } satisfies ChannelUpdate; } @@ -198,6 +199,7 @@ function createRequestToChannel(saveReq: SaveChannelRequest): NewChannel { fillerRepeatCooldown: saveReq.fillerRepeatCooldown, guideFlexTitle: saveReq.guideFlexTitle, streamMode: saveReq.streamMode, + subtitlesEnabled: saveReq.subtitlesEnabled ? 1 : 0, transcodeConfigId: saveReq.transcodeConfigId, } satisfies NewChannel; } diff --git a/server/src/db/converters/channelConverters.ts b/server/src/db/converters/channelConverters.ts index 0ff675b1b..e076260d6 100644 --- a/server/src/db/converters/channelConverters.ts +++ b/server/src/db/converters/channelConverters.ts @@ -33,6 +33,7 @@ export const dbChannelToApiChannel = ({ transcoding: nilToUndefined(channel.transcoding), duration: channel.duration, stealth: channel.stealth === 1, + subtitlesEnabled: channel.subtitlesEnabled === 1, onDemand: { enabled: isDefined(lineup.onDemandConfig), }, diff --git a/server/src/db/schema/Channel.ts b/server/src/db/schema/Channel.ts index 7a38af988..621439c19 100644 --- a/server/src/db/schema/Channel.ts +++ b/server/src/db/schema/Channel.ts @@ -31,6 +31,7 @@ export interface ChannelTable extends WithUuid, WithCreatedAt, WithUpdatedAt { startTime: number; stealth: ColumnType; streamMode: ColumnType; + subtitlesEnabled: Generated; transcoding: JSONColumnType; transcodeConfigId: string; watermark: JSONColumnType; @@ -54,6 +55,7 @@ const ChannelTableKeys: (keyof ChannelTable)[] = [ 'startTime', 'stealth', 'streamMode', + 'subtitlesEnabled', 'transcoding', 'transcodeConfigId', 'updatedAt', diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index eecf047f7..8f1dfedaf 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -686,8 +686,8 @@ export class FfmpegStreamFactory extends IFFMPEG { return isNonEmptyString(this.ffmpegSettings.vaapiDevice) ? this.ffmpegSettings.vaapiDevice : isLinux() - ? '/dev/dri/renderD128' - : undefined; + ? '/dev/dri/renderD128' + : undefined; } private getVaapiDriver() { diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index fb9900361..01acfbebb 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -65,6 +65,7 @@ export type HlsOptions = { streamBaseUrl: string; segmentNameFormat: string; streamNameFormat: string; + subtitleNameFormat: string; deleteThreshold: Nullable; appendSegments: boolean; }; @@ -94,6 +95,7 @@ export const defaultHlsOptions: DeepRequired = { segmentNameFormat: 'data%05d.ts', streamNameFormat: 'stream.m3u8', streamBaseUrl: 'hls/', + subtitleNameFormat: 'stream_vtt.m3u8', deleteThreshold: 3, appendSegments: false, }; @@ -350,8 +352,8 @@ export class FFMPEG implements IFFMPEG { streamDetails, startTime, duration, - realtime, enableIcon, + realtime, outputFormat, ptsOffset ?? null, ); @@ -394,8 +396,8 @@ export class FFMPEG implements IFFMPEG { streamStats, undefined, streamStats.duration!, - true, /*watermark=*/ undefined, + true, outputFormat, null, ); @@ -417,8 +419,8 @@ export class FFMPEG implements IFFMPEG { streamStats, undefined, duration, - true, undefined, + true, outputFormat, null, ); @@ -429,8 +431,8 @@ export class FFMPEG implements IFFMPEG { streamStats: Maybe, startTime: Maybe, duration: Duration, - realtime: boolean, watermark: Maybe, + realtime: boolean, outputFormat: OutputFormat, ptsOffset: Nullable, ): Promise> { @@ -449,6 +451,11 @@ export class FFMPEG implements IFFMPEG { find(streamStats?.audioDetails, { selected: true }) ?? find(streamStats?.audioDetails, { default: true }) ?? first(streamStats?.audioDetails); + const subtitleStream = + find(streamStats?.subtitleDetails, { selected: true }) ?? + find(streamStats?.subtitleDetails, { default: true }) ?? + first(streamStats?.subtitleDetails) ?? + undefined; // Initialize like this because we're not checking whether or not // the input is hardware decodeable, yet. @@ -729,6 +736,12 @@ export class FFMPEG implements IFFMPEG { iH = iH!; } + if (this.channel.subtitlesEnabled && subtitleStream) { + console.log('Adding sub opts'); + console.log(subtitleStream) + ffmpegArgs.push('-map', `0:${subtitleStream.index}`, '-c:s', 'webvtt', '-var_stream_map', `v:0,a:0,s:0,name:subs`) + } + if (doOverlay && !isNil(watermark?.url)) { if (watermark.animated) { ffmpegArgs.push('-ignore_loop', '0', '-stream_loop', '-1'); @@ -1111,7 +1124,6 @@ export class FFMPEG implements IFFMPEG { this.logger.info('ffmpeg preemptively killed'); return; } - return this.createProcess(ffmpegArgs, duration); } @@ -1120,7 +1132,7 @@ export class FFMPEG implements IFFMPEG { streamDuration?: Duration, ): FfmpegTranscodeSession { const process = new FfmpegProcess(this.opts, this.ffmpegName, ffmpegArgs); - + console.log(process); // TODO: Do we need a more accurate measure of "streamEndTime" by passing in // the request start time? Or is this really inaccurate because we still have // a short amount of time before the stream is actually started... @@ -1184,6 +1196,7 @@ export class FFMPEG implements IFFMPEG { '-master_pl_name', 'master.m3u8', path.join('streams', hlsOpts.streamBasePath, hlsOpts.streamNameFormat), + path.join('streams', hlsOpts.streamBasePath, hlsOpts.subtitleNameFormat), ]; } diff --git a/server/src/migration/DirectMigrationProvider.ts b/server/src/migration/DirectMigrationProvider.ts index 47cea496e..6f1a612d7 100644 --- a/server/src/migration/DirectMigrationProvider.ts +++ b/server/src/migration/DirectMigrationProvider.ts @@ -21,6 +21,7 @@ import LegacyMigration9 from './db/LegacyMigration9.ts'; import Migration1730806741 from './db/Migration1730806741.ts'; import Migration1731982492 from './db/Migration1731982492.ts'; import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts'; +import Migration1736904784_AddSubtitles from './db/Migration1736904784_AddSubtitles.ts'; export const LegacyMigrationNameToNewMigrationName = [ ['Migration20240124115044', '_Legacy_Migration00'], @@ -87,6 +88,7 @@ export class DirectMigrationProvider implements MigrationProvider { migration1731982492: Migration1731982492, migration1732969335: Migration1732969335_AddTranscodeConfig, migration1735044379: Migration1735044379_AddHlsDirect, + migration1736904784: Migration1736904784_AddSubtitles, }, wrapWithTransaction, ), diff --git a/server/src/migration/db/Migration1735044379_AddHlsDirect.ts b/server/src/migration/db/Migration1735044379_AddHlsDirect.ts index 538bd75c9..ce02b3b28 100644 --- a/server/src/migration/db/Migration1735044379_AddHlsDirect.ts +++ b/server/src/migration/db/Migration1735044379_AddHlsDirect.ts @@ -53,6 +53,7 @@ export default { .addColumn('transcode_config_id', 'text', (col) => col.references('transcode_config.uuid'), ) + .addColumn('subtitles_enabled', 'boolean', (col) => col.notNull().defaultTo(false)) .execute(); await db @@ -76,6 +77,7 @@ export default { 'transcoding', 'guideFlexTitle', 'streamMode', + 'subtitlesEnabled', ]) .expression( db @@ -99,6 +101,7 @@ export default { 'transcoding', 'guideFlexTitle', 'streamMode', + 'subtitlesEnabled', ]), ) .execute(); @@ -190,6 +193,7 @@ export default { .addColumn('transcode_config_id', 'text', (col) => col.references('transcode_config.uuid'), ) + .addColumn('subtitles_enabled', 'boolean', (col) => col.notNull().defaultTo(false)) .execute(); await db @@ -213,6 +217,7 @@ export default { 'transcoding', 'guideFlexTitle', 'streamMode', + 'subtitlesEnabled', ]) .expression( db @@ -236,6 +241,7 @@ export default { 'transcoding', 'guideFlexTitle', 'streamMode', + 'subtitlesEnabled', ]), ) .execute(); diff --git a/server/src/migration/db/Migration1736904784_AddSubtitles.ts b/server/src/migration/db/Migration1736904784_AddSubtitles.ts new file mode 100644 index 000000000..aadd05285 --- /dev/null +++ b/server/src/migration/db/Migration1736904784_AddSubtitles.ts @@ -0,0 +1,294 @@ +import { Channel } from '@/db/schema/Channel.ts'; +import { CompiledQuery, Kysely, sql } from 'kysely'; + +interface ChannelProgramsInMigration { + channelUuid: string; + programUuid: string; +} + +type DB = { + channel: Channel; + channelTempAlter: Channel; + channelPrograms: ChannelProgramsInMigration; + channelProgramsTempAlter: ChannelProgramsInMigration; +}; + +export default { + async up(db: Kysely) { + await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = OFF')); + await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = ON')); + + await db.schema + .createTable('channel_temp_alter') + .ifNotExists() + .addColumn('uuid', 'text', (col) => col.primaryKey().notNull()) + .addColumn('created_at', 'datetime') + .addColumn('updated_at', 'datetime') + .addColumn('number', 'integer', (col) => col.notNull().unique()) + .addColumn('icon', 'json') + .addColumn('guide_minimum_duration', 'integer', (col) => col.notNull()) + .addColumn('disable_filler_overlay', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .addColumn('name', 'text', (col) => col.notNull()) + .addColumn('duration', 'integer', (col) => col.notNull()) + .addColumn('stealth', 'boolean', (col) => col.notNull().defaultTo(false)) + .addColumn('group_title', 'text') + .addColumn('start_time', 'integer', (col) => col.notNull()) + .addColumn('offline', 'json', (col) => + col.notNull().defaultTo(`{"mode":"clip"}`), + ) + .addColumn('filler_repeat_cooldown', 'integer') + .addColumn('watermark', 'json') + .addColumn('transcoding', 'json') + .addColumn('guide_flex_title', 'text') + .addColumn('stream_mode', 'text', (b) => + b + .check( + sql`(\`stream_mode\`) in ('hls', 'hls_slower', 'mpegts', 'hls_direct')`, + ) + .notNull() + .defaultTo('hls'), + ) + .addColumn('transcode_config_id', 'text', (col) => + col.references('transcode_config.uuid'), + ) + .addColumn('subtitles_enabled', 'boolean', (col) => col.defaultTo(false)) + .execute(); + + await db + .insertInto('channelTempAlter') + .columns([ + 'uuid', + 'createdAt', + 'updatedAt', + 'number', + 'icon', + 'guideMinimumDuration', + 'disableFillerOverlay', + 'name', + 'duration', + 'stealth', + 'groupTitle', + 'startTime', + 'offline', + 'fillerRepeatCooldown', + 'watermark', + 'transcoding', + 'guideFlexTitle', + 'streamMode', + ]) + .expression( + db + .selectFrom('channel') + .select([ + 'uuid', + 'createdAt', + 'updatedAt', + 'number', + 'icon', + 'guideMinimumDuration', + 'disableFillerOverlay', + 'name', + 'duration', + 'stealth', + 'groupTitle', + 'startTime', + 'offline', + 'fillerRepeatCooldown', + 'watermark', + 'transcoding', + 'guideFlexTitle', + 'streamMode', + ]), + ) + .execute(); + + await db.schema + .createTable('channel_programs_temp_alter') + .addColumn('channel_uuid', 'text', (col) => col.notNull()) + .addColumn('program_uuid', 'text', (col) => col.notNull()) + .addPrimaryKeyConstraint('primary_key', ['channel_uuid', 'program_uuid']) + .addForeignKeyConstraint( + 'channel_programs_channel_uuid_foreign', + ['channel_uuid'], + 'channel_temp_alter', + ['uuid'], + (b) => b.onDelete('cascade').onUpdate('cascade'), + ) + .addForeignKeyConstraint( + 'channel_programs_program_uuid_foreign', + ['program_uuid'], + 'program', + ['uuid'], + (b) => b.onDelete('cascade').onUpdate('cascade'), + ) + .execute(); + + await db + .insertInto('channelProgramsTempAlter') + .columns(['channelUuid', 'programUuid']) + .expression( + db + .selectFrom('channelPrograms') + .select([ + 'channelPrograms.channelUuid', + 'channelPrograms.programUuid', + ]), + ) + .execute(); + + await db.schema.dropTable('channel').execute(); + await db.schema + .alterTable('channel_temp_alter') + .renameTo('channel') + .execute(); + + await db.schema.dropTable('channel_programs').execute(); + await db.schema + .alterTable('channel_programs_temp_alter') + .renameTo('channel_programs') + .execute(); + + await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON')); + await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = OFF')); + }, + + async down(db: Kysely) { + await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = OFF')); + await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = ON')); + + await db.schema + .createTable('channel_temp_alter') + .ifNotExists() + .addColumn('uuid', 'text', (col) => col.primaryKey().notNull()) + .addColumn('created_at', 'datetime') + .addColumn('updated_at', 'datetime') + .addColumn('number', 'integer', (col) => col.notNull().unique()) + .addColumn('icon', 'json') + .addColumn('guide_minimum_duration', 'integer', (col) => col.notNull()) + .addColumn('disable_filler_overlay', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .addColumn('name', 'text', (col) => col.notNull()) + .addColumn('duration', 'integer', (col) => col.notNull()) + .addColumn('stealth', 'boolean', (col) => col.notNull().defaultTo(false)) + .addColumn('group_title', 'text') + .addColumn('start_time', 'integer', (col) => col.notNull()) + .addColumn('offline', 'json', (col) => + col.notNull().defaultTo(`{"mode":"clip"}`), + ) + .addColumn('filler_repeat_cooldown', 'integer') + .addColumn('watermark', 'json') + .addColumn('transcoding', 'json') + .addColumn('guide_flex_title', 'text') + .addColumn('stream_mode', 'text', (b) => + b + .check(sql`(\`stream_mode\`) in ('hls', 'hls_slower', 'mpegts')`) + .notNull() + .defaultTo('hls'), + ) + .addColumn('transcode_config_id', 'text', (col) => + col.references('transcode_config.uuid'), + ) + .addColumn('subtitles_enabled', 'boolean', (col) => col.defaultTo(false)) + .execute(); + + await db + .insertInto('channelTempAlter') + .columns([ + 'uuid', + 'createdAt', + 'updatedAt', + 'number', + 'icon', + 'guideMinimumDuration', + 'disableFillerOverlay', + 'name', + 'duration', + 'stealth', + 'groupTitle', + 'startTime', + 'offline', + 'fillerRepeatCooldown', + 'watermark', + 'transcoding', + 'guideFlexTitle', + 'streamMode', + ]) + .expression( + db + .selectFrom('channel') + .select([ + 'uuid', + 'createdAt', + 'updatedAt', + 'number', + 'icon', + 'guideMinimumDuration', + 'disableFillerOverlay', + 'name', + 'duration', + 'stealth', + 'groupTitle', + 'startTime', + 'offline', + 'fillerRepeatCooldown', + 'watermark', + 'transcoding', + 'guideFlexTitle', + 'streamMode', + ]), + ) + .execute(); + + await db.schema + .createTable('channel_programs_temp_alter') + .addColumn('channel_uuid', 'text', (col) => col.notNull()) + .addColumn('program_uuid', 'text', (col) => col.notNull()) + .addPrimaryKeyConstraint('primary_key', ['channel_uuid', 'program_uuid']) + .addForeignKeyConstraint( + 'channel_programs_channel_uuid_foreign', + ['channel_uuid'], + 'channel_temp_alter', + ['uuid'], + (b) => b.onDelete('cascade').onUpdate('cascade'), + ) + .addForeignKeyConstraint( + 'channel_programs_program_uuid_foreign', + ['program_uuid'], + 'program', + ['uuid'], + (b) => b.onDelete('cascade').onUpdate('cascade'), + ) + .execute(); + + await db + .insertInto('channelProgramsTempAlter') + .columns(['channelUuid', 'programUuid']) + .expression( + db + .selectFrom('channelPrograms') + .select([ + 'channelPrograms.channelUuid', + 'channelPrograms.programUuid', + ]), + ) + .execute(); + + await db.schema.dropTable('channel').execute(); + await db.schema + .alterTable('channel_temp_alter') + .renameTo('channel') + .execute(); + + await db.schema.dropTable('channel_programs').execute(); + await db.schema + .alterTable('channel_programs_temp_alter') + .renameTo('channel_programs') + .execute(); + + await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON')); + await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = OFF')); + }, +}; diff --git a/server/src/services/M3UService.ts b/server/src/services/M3UService.ts index ffbc76e8a..ab989144b 100644 --- a/server/src/services/M3UService.ts +++ b/server/src/services/M3UService.ts @@ -40,17 +40,16 @@ export class M3uService { */ private async getChannelsM3UInternal(): Promise { devAssert(M3uService.lock.isLocked()); - + const cachedM3U = await attempt(() => this.#fileCacheService.getCache(M3uService.cacheKey), ); - + if (!isError(cachedM3U) && isDefined(cachedM3U)) { return cachedM3U; } const channels = sortBy(await this.#channelDB.getAllChannels(), 'number'); - const tvg = `{{host}}/api/xmltv.xml`; let data = `#EXTM3U url-tvg="${tvg}" x-tvg-url="${tvg}"\n`; @@ -59,15 +58,15 @@ export class M3uService { if (channel.stealth) { continue; } + const channelId = getChannelId(channel.number); - data += `#EXTINF:-1 tvg-id="${channelId}" channel-id="${channelId}" CUID="${channelId}" tvg-chno="${ - channel.number - }" tvg-name="${channel.name}" tvg-logo="${ + data += `#EXTINF:-1 tvg-id="${channelId}" channel-id="${channelId}" CUID="${channelId}" tvg-chno="${channel.number}" tvg-name="${channel.name}" tvg-logo="${ isNonEmptyString(channel.icon?.path) ? channel.icon.path : '{{host}}/images/tunarr.png' }" group-title="${channel.groupTitle}",${channel.name}\n`; + // Video Stream reference data += `{{host}}/stream/channels/${channel.uuid}.ts\n`; } diff --git a/server/src/stream/hls/HlsSession.ts b/server/src/stream/hls/HlsSession.ts index 77b2d923f..f21d40aa7 100644 --- a/server/src/stream/hls/HlsSession.ts +++ b/server/src/stream/hls/HlsSession.ts @@ -230,6 +230,7 @@ export class HlsSession extends BaseHlsSession { HlsOutputFormat({ hlsDeleteThreshold: 3, streamNameFormat: 'stream.m3u8', + subtitleNameFormat: 'stream_vtt.m3u8', segmentNameFormat: 'data%06d.ts', streamBasePath: `stream_${this.channel.uuid}`, streamBaseUrl: `/stream/channels/${this.channel.uuid}/${this.sessionType}/`, diff --git a/server/src/stream/jellyfin/JellyfinStreamDetails.ts b/server/src/stream/jellyfin/JellyfinStreamDetails.ts index d3807442d..d881b241c 100644 --- a/server/src/stream/jellyfin/JellyfinStreamDetails.ts +++ b/server/src/stream/jellyfin/JellyfinStreamDetails.ts @@ -42,6 +42,7 @@ import { ProgramStreamResult, StreamDetails, StreamSource, + SubtitleStreamDetails, VideoStreamDetails, } from '../types.js'; @@ -173,6 +174,7 @@ export class JellyfinStreamDetails { }; } else { const path = details.serverPath ?? item.plexFilePath; + if (isNonEmptyString(path)) { streamSource = new HttpStreamSource( `${trimEnd(this.server.uri, '/')}/Videos/${trimStart( @@ -284,6 +286,37 @@ export class JellyfinStreamDetails { }, ); + const subtitleStreamDetails = map( + sortBy( + filter( + firstMediaSource?.MediaStreams, + (stream) => ( + stream.Type === 'Subtitle' && + (stream.Codec === 'ass' || + stream.Codec === 'srt' || + stream.Codec === 'vtt' + )), + ), + (stream) => [stream.Index ?? 0, !stream.IsDefault], + ), + (subtitleStream) => { + return { + language: nullToUndefined(subtitleStream.Language), + title: nullToUndefined(subtitleStream.DisplayTitle), + codec: nullToUndefined(subtitleStream.Codec), + default: nullToUndefined(subtitleStream.IsDefault), + index: + ifDefined(subtitleStream.Index, (streamIndex) => { + const index = streamIndex - externalStreamCount; + if (index >= 0) { + return index.toString(); + } + return; + }) ?? undefined, + } satisfies SubtitleStreamDetails; + }, + ); + if (!videoStreamDetails && isEmpty(audioStreamDetails)) { this.logger.warn( 'Could not find a video nor audio stream for Plex item %s', @@ -301,6 +334,9 @@ export class JellyfinStreamDetails { audioDetails: isEmpty(audioStreamDetails) ? undefined : (audioStreamDetails as NonEmptyArray), + subtitleDetails: isEmpty(subtitleStreamDetails) + ? undefined + : (subtitleStreamDetails as NonEmptyArray), }; if (audioOnly) { diff --git a/server/src/stream/local/LocalFileStreamDetails.ts b/server/src/stream/local/LocalFileStreamDetails.ts index 9b0e1ffd9..89d0564ce 100644 --- a/server/src/stream/local/LocalFileStreamDetails.ts +++ b/server/src/stream/local/LocalFileStreamDetails.ts @@ -1,6 +1,10 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.ts'; -import { FfprobeAudioStream, FfprobeVideoStream } from '@/types/ffmpeg.ts'; +import { + FfprobeAudioStream, + FfprobeVideoStream, + FfprobeSubtitleStream, +} from '@/types/ffmpeg.ts'; import { Maybe, Nullable } from '@/types/util.ts'; import dayjs from '@/util/dayjs.ts'; import { fileExists } from '@/util/fsUtil.ts'; @@ -13,6 +17,7 @@ import { FileStreamSource, HttpStreamSource, ProgramStreamResult, + SubtitleStreamDetails, VideoStreamDetails, } from '../types.ts'; @@ -53,8 +58,8 @@ export class LocalFileStreamDetails { videoStream.field_order === 'interlaced' ? 'interlaced' : videoStream.field_order === 'progressive' - ? 'progressive' - : 'unknown', + ? 'progressive' + : 'unknown', width: videoStream.width, height: videoStream.height, framerate: videoStream.r_frame_rate ?? undefined, @@ -90,14 +95,32 @@ export class LocalFileStreamDetails { }, ); + const subtitleStreamDetails = map( + filter( + probeResult.streams, + (stream): stream is FfprobeSubtitleStream => + stream.codec_type === 'subtitle', + ), + (subtitleStream) => { + return { + index: subtitleStream.index.toFixed(), + language: subtitleStream.tags?.['language'], + } satisfies SubtitleStreamDetails; + }, + ); + return { streamDetails: { videoDetails: videoDetails ? [videoDetails] : undefined, audioDetails: isEmpty(audioStreamDetails) ? undefined : (audioStreamDetails as NonEmptyArray), + subtitleDetails: isEmpty(subtitleStreamDetails) + ? undefined + : (subtitleStreamDetails as NonEmptyArray), duration: dayjs.duration({ seconds: probeResult.format.duration }), }, + streamSource: this.path.startsWith('http') ? new HttpStreamSource(this.path) : new FileStreamSource(this.path), diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index 0a519751b..e988e1310 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -19,6 +19,7 @@ export type StreamDetails = { // If defined, there is at least one video stream videoDetails?: [VideoStreamDetails, ...VideoStreamDetails[]]; audioDetails?: [AudioStreamDetails, ...AudioStreamDetails[]]; + subtitleDetails?: [SubtitleStreamDetails, ...SubtitleStreamDetails[]]; audioOnly?: boolean; placeholderImage?: string; @@ -56,6 +57,14 @@ export type AudioStreamDetails = { forced?: boolean; }; +export type SubtitleStreamDetails = { + index?: string; + default?: boolean; + title?: string; + language?: string; + codec?: string +}; + // TODO: Move me export function getPixelFormatForStream(details: StreamDetails) { if (isUndefined(first(details.videoDetails))) { diff --git a/server/src/types/ffmpeg.ts b/server/src/types/ffmpeg.ts index 8ddde61a8..8366c8013 100644 --- a/server/src/types/ffmpeg.ts +++ b/server/src/types/ffmpeg.ts @@ -58,6 +58,19 @@ export const FfprobeAudioStreamSchema = BaseFfprobeMediaStreamSchema.extend({ export type FfprobeAudioStream = z.infer; +export const FfprobeSubtitleStreamSchema = BaseFfprobeMediaStreamSchema.extend({ + codec_type: z.literal('subtitle'), + width: z.number().optional(), + height: z.number().optional(), + time_base: z.string().optional(), + start_pts: z.number().optional(), + r_frame_rate: z.string().optional(), + avg_frame_rate: z.string().optional(), + duration: z.string().optional(), +}); + +export type FfprobeSubtitleStream = z.infer; + function parsePossibleFractionToFloat(s: string) { if (s.includes('/')) { const [num, den] = split(s, '/', 2); @@ -74,6 +87,7 @@ function parsePossibleFractionToFloat(s: string) { export const FfprobeMediaStreamSchema = z.discriminatedUnion('codec_type', [ FfprobeVideoStreamSchema, FfprobeAudioStreamSchema, + FfprobeSubtitleStreamSchema, ]); export const FfprobeMediaFormatSchema = z.object({ diff --git a/types/src/schemas/channelSchema.ts b/types/src/schemas/channelSchema.ts index 55a040e91..18bc31bce 100644 --- a/types/src/schemas/channelSchema.ts +++ b/types/src/schemas/channelSchema.ts @@ -122,6 +122,7 @@ export const ChannelSchema = z.object({ stealth: z.boolean(), transcoding: ChannelTranscodingOptionsSchema.optional(), watermark: WatermarkSchema.optional(), + subtitlesEnabled: z.boolean(), onDemand: z.object({ enabled: z.boolean(), }), diff --git a/types/src/schemas/settingsSchemas.ts b/types/src/schemas/settingsSchemas.ts index b37258e2e..910cec76b 100644 --- a/types/src/schemas/settingsSchemas.ts +++ b/types/src/schemas/settingsSchemas.ts @@ -81,7 +81,7 @@ export const FfmpegSettingsSchema = z.object({ concatMuxDelay: z.number().default(0), enableLogging: z.boolean().default(false), enableFileLogging: z.boolean().default(false), - logLevel: z.enum(FfmpegLogLevels).optional().default('warning'), + logLevel: z.enum(FfmpegLogLevels).optional().default('trace'), // DEPRECATED enableTranscoding: z.boolean().default(true).describe('DEPRECATED'), audioVolumePercent: z.number().default(100), diff --git a/web/src/components/Video.tsx b/web/src/components/Video.tsx index 2b82d5e02..f6b5e7103 100644 --- a/web/src/components/Video.tsx +++ b/web/src/components/Video.tsx @@ -13,6 +13,7 @@ import { useSettings } from '../store/settings/selectors.ts'; type VideoProps = { channelId: string; + hasSubtitles?: boolean; }; export default function Video({ channelId }: VideoProps) { @@ -75,6 +76,14 @@ export default function Video({ channelId }: VideoProps) { setLoadedStream(true); hls.loadSource(`${backendUri}/stream/channels/${channelId}.m3u8`); hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, function (_, data) { + console.log(data) + // console.log('Available subtitle tracks:', data.subtitles); + // if (data.subtitles.length > 0) { + // hls.subtitleTrack = 0; // Enable the first subtitle track + // } + }); } }, [ autoPlayEnabled, @@ -100,6 +109,8 @@ export default function Video({ channelId }: VideoProps) { HLS not supported in this browser! ); + } else { + console.log(hls); } if (!isNil(ffmpegSettings) && ffmpegSettings.audioEncoder === 'ac3') { diff --git a/web/src/components/channel_config/ChannelPropertiesEditor.tsx b/web/src/components/channel_config/ChannelPropertiesEditor.tsx index 618cc837c..db41ab1e5 100644 --- a/web/src/components/channel_config/ChannelPropertiesEditor.tsx +++ b/web/src/components/channel_config/ChannelPropertiesEditor.tsx @@ -227,6 +227,32 @@ export function ChannelPropertiesEditor({ isNew }: Props) { /> + + + Subtitles + + + Enable subtitles for channel media where available. +
+ NOTE: Only external subtitle files are + supported at this time. +
+ ( + field.onChange(e.target.checked)} + /> + } + label="Enabled" + /> + )} + /> +
On-Demand diff --git a/web/src/components/channel_config/EditChannelForm.tsx b/web/src/components/channel_config/EditChannelForm.tsx index 758995152..858ecb8a2 100644 --- a/web/src/components/channel_config/EditChannelForm.tsx +++ b/web/src/components/channel_config/EditChannelForm.tsx @@ -71,6 +71,7 @@ function getDefaultFormValues(channel: Channel): SaveChannelRequest { }, ], }, + subtitlesEnabled: channel.subtitlesEnabled ?? false, onDemand: { enabled: channel.onDemand.enabled, }, diff --git a/web/src/helpers/constants.ts b/web/src/helpers/constants.ts index 30a695ba9..51cb855df 100644 --- a/web/src/helpers/constants.ts +++ b/web/src/helpers/constants.ts @@ -31,6 +31,7 @@ export const DefaultChannel: MarkOptional< picture: '', soundtrack: '', }, + subtitlesEnabled: false, onDemand: { enabled: false, }, diff --git a/web/src/pages/watch/ChannelWatchPage.tsx b/web/src/pages/watch/ChannelWatchPage.tsx index 974f6826b..720263d4b 100644 --- a/web/src/pages/watch/ChannelWatchPage.tsx +++ b/web/src/pages/watch/ChannelWatchPage.tsx @@ -23,7 +23,7 @@ export default function ChannelWatchPage() { "{channel.name}" Live -