From e02475a9ca109ad2a203f1f862dd7147d6678873 Mon Sep 17 00:00:00 2001 From: Andrei Baltuta Date: Sun, 12 Apr 2026 22:57:04 +0300 Subject: [PATCH] feat: add program text overlay --- .../configure/channels/now-playing-overlay.md | 49 +++ mkdocs.yml | 1 + .../src/db/channel/BasicChannelRepository.ts | 23 +- server/src/db/derived_types/StreamLineup.ts | 9 + server/src/db/schema/base.ts | 13 + server/src/ffmpeg/FfmpegStreamFactory.ts | 209 ++++++++++++ server/src/ffmpeg/NowPlayingOverlay.test.ts | 307 ++++++++++++++++++ server/src/ffmpeg/NowPlayingOverlay.ts | 301 +++++++++++++++++ .../ffmpeg/builder/filter/ComplexFilter.ts | 11 + .../src/ffmpeg/builder/filter/FilterChain.ts | 1 + .../filter/NowPlayingOverlayFilter.test.ts | 163 ++++++++++ .../builder/filter/NowPlayingOverlayFilter.ts | 207 ++++++++++++ .../builder/pipeline/BasePipelineBuilder.ts | 17 +- .../pipeline/PipelineBuilderFactory.ts | 19 +- .../pipeline/hardware/QsvPipelineBuilder.ts | 1 + .../pipeline/hardware/VaapiPipelineBuilder.ts | 4 +- .../pipeline/nvidia/NvidiaPipelineBuilder.ts | 10 +- .../software/SoftwarePipelineBuilder.ts | 21 ++ server/src/ffmpeg/ffmpegBase.ts | 2 + server/src/ffmpeg/ffmpegInfo.ts | 9 + server/src/stream/ProgramStream.ts | 25 ++ .../stream/StreamProgramCalculator.test.ts | 227 +++++++++++++ server/src/stream/StreamProgramCalculator.ts | 80 ++++- server/src/stream/emby/EmbyProgramStream.ts | 1 + server/src/stream/hls/BaseHlsSession.ts | 2 +- .../stream/jellyfin/JellyfinProgramStream.ts | 1 + server/src/stream/local/LocalProgramStream.ts | 1 + server/src/stream/plex/PlexProgramStream.ts | 1 + server/src/testing/fakes/FakeChannelDB.ts | 13 + server/src/types/ffmpeg.ts | 2 + types/src/schemas/channelSchema.ts | 19 ++ .../ChannelTranscodingConfig.tsx | 160 +++++++++ .../channel_config/EditChannelForm.tsx | 28 ++ 33 files changed, 1917 insertions(+), 20 deletions(-) create mode 100644 docs/configure/channels/now-playing-overlay.md create mode 100644 server/src/ffmpeg/NowPlayingOverlay.test.ts create mode 100644 server/src/ffmpeg/NowPlayingOverlay.ts create mode 100644 server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.test.ts create mode 100644 server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.ts diff --git a/docs/configure/channels/now-playing-overlay.md b/docs/configure/channels/now-playing-overlay.md new file mode 100644 index 000000000..0c0d757bd --- /dev/null +++ b/docs/configure/channels/now-playing-overlay.md @@ -0,0 +1,49 @@ +# Now Playing Overlay + +Channels can display a "now playing" lower-third overlay on the stream, showing the current program's title, artist, album, and year. This is especially useful for music video channels where viewers want to know what's playing. + +The overlay appears as a semi-transparent bar at the bottom of the video with text that fades in and out. + +There are several ways to customize the overlay for a channel. Here are some details on specific options: + +### Position + +Controls whether the overlay appears at the bottom-left or bottom-right of the video. + +### Show at Start + +How long (in seconds) the overlay is visible at the beginning of each program. For example, a value of 8 means the overlay will appear for the first 8 seconds. + +### Show at End + +How long (in seconds) the overlay reappears before the program ends. Set to 0 to disable the closing overlay. This is useful for giving viewers a heads-up that the current program is about to end. + +### Start Padding + +Adds a delay (in seconds) before the opening overlay appears. For example, a value of 2 means the overlay won't appear until 2 seconds into the program. This can help avoid showing the overlay during intro sequences. + +### End Padding + +Adds a gap (in seconds) between the closing overlay and the end of the program. For example, a value of 2 means the closing overlay will disappear 2 seconds before the program ends. + +### Fade Duration + +Controls how long (in seconds) the text takes to fade in and out. Set to 0 for instant appearance. A value of 0.5 provides a subtle, smooth transition. + +## Coming Up Next + +The overlay can also display a "coming up next" card showing the next program's title and metadata. This appears as a separate overlay before the closing card. + +### Duration + +How long (in seconds) the "coming up next" card is shown. Set to 0 to disable this feature entirely. + +### Offset from End + +How far from the end of the program (in seconds) the "coming up next" card starts. For example, a value of 30 means it will appear 30 seconds before the program ends. + +## Things to consider + +- The overlay requires video transcoding. It will not appear when using HLS Direct or HLS Direct v2 stream modes, since those modes pass the video through without modification. +- If a program is too short to fit all configured overlays without overlapping, the "coming up next" card is skipped first, followed by the closing card. +- Program metadata (title, artist, album, year) comes from the media source (Plex, Jellyfin, Emby, or local file tags). For local files, embedded metadata from the file itself is preferred when available. diff --git a/mkdocs.yml b/mkdocs.yml index 51761f747..f3d892fef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - EPG: configure/channels/epg.md - Transcoding: configure/channels/transcoding.md - Watermarks: configure/channels/watermarks.md + - Now Playing Overlay: configure/channels/now-playing-overlay.md - Programming: configure/channels/programming.md - Library: - configure/library/index.md diff --git a/server/src/db/channel/BasicChannelRepository.ts b/server/src/db/channel/BasicChannelRepository.ts index e4804b0a1..bcb6d1658 100644 --- a/server/src/db/channel/BasicChannelRepository.ts +++ b/server/src/db/channel/BasicChannelRepository.ts @@ -24,6 +24,7 @@ import { MarkRequired } from 'ts-essentials'; import { v4 } from 'uuid'; import { isDefined, isNonEmptyString } from '../../util/index.ts'; import { ChannelAndLineup } from '../interfaces/IChannelDB.ts'; +import type { ChannelTranscodingSettings } from '../schema/base.ts'; import { Channel, ChannelOrm, @@ -59,11 +60,23 @@ function sanitizeChannelWatermark( }; } -function updateRequestToChannel(updateReq: SaveableChannel): ChannelUpdate { +function updateRequestToChannel( + updateReq: SaveableChannel, + existingTranscoding?: ChannelTranscodingSettings | null, +): ChannelUpdate { const sanitizedWatermark = sanitizeChannelWatermark(updateReq.watermark); + let transcoding: string | undefined; + if (isDefined(updateReq.transcoding?.nowPlayingOverlay)) { + transcoding = JSON.stringify({ + ...(existingTranscoding ?? {}), + nowPlayingOverlay: updateReq.transcoding.nowPlayingOverlay, + }); + } + return { number: updateReq.number, + transcoding, watermark: sanitizedWatermark ? JSON.stringify(sanitizedWatermark) : undefined, @@ -86,12 +99,16 @@ function updateRequestToChannel(updateReq: SaveableChannel): ChannelUpdate { function createRequestToChannel(saveReq: SaveableChannel): NewChannel { const now = +dayjs(); + const transcoding = isDefined(saveReq.transcoding) + ? JSON.stringify(saveReq.transcoding) + : null; return { uuid: v4(), createdAt: now, updatedAt: now, number: saveReq.number, + transcoding, watermark: saveReq.watermark ? JSON.stringify(saveReq.watermark) : null, icon: JSON.stringify(saveReq.icon), guideMinimumDuration: saveReq.guideMinimumDuration, @@ -274,7 +291,7 @@ export class BasicChannelRepository { throw new ChannelNotFoundError(id); } - const update = updateRequestToChannel(updateReq); + const update = updateRequestToChannel(updateReq, channel.transcoding); if ( isNonEmptyString(updateReq.watermark?.url) && @@ -426,10 +443,10 @@ export class BasicChannelRepository { number: maxId + 1, icon: JSON.stringify(channel.icon), offline: JSON.stringify(channel.offline), + transcoding: null, watermark: JSON.stringify(channel.watermark), createdAt: now, updatedAt: now, - transcoding: null, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/server/src/db/derived_types/StreamLineup.ts b/server/src/db/derived_types/StreamLineup.ts index 5a9cfcf1b..536228629 100644 --- a/server/src/db/derived_types/StreamLineup.ts +++ b/server/src/db/derived_types/StreamLineup.ts @@ -12,11 +12,20 @@ import type { } from '../schema/derivedTypes.ts'; import type { ProgramType } from '../schema/Program.ts'; +export type NextProgramMetadata = { + title?: string; + artist?: string; + album?: string; + year?: string; + filePath?: string; +}; + type BaseStreamLineupItem = { streamDuration: number; startOffset?: number; programBeginMs: number; duration: number; + nextProgramMetadata?: NextProgramMetadata; }; export type StreamLineupProgram = MarkNotNilable< diff --git a/server/src/db/schema/base.ts b/server/src/db/schema/base.ts index 05be0288c..5677d0066 100644 --- a/server/src/db/schema/base.ts +++ b/server/src/db/schema/base.ts @@ -69,6 +69,19 @@ export const ChannelTranscodingSettingsSchema = z.object({ targetResolution: ResolutionSchema.optional().catch(undefined), videoBitrate: z.number().nonnegative().optional().catch(undefined), videoBufferSize: z.number().nonnegative().optional().catch(undefined), + nowPlayingOverlay: z + .object({ + enabled: z.boolean().default(false).catch(false), + showForSeconds: z.number().positive().default(8).catch(8), + showAtEndForSeconds: z.number().nonnegative().default(0).catch(0), + startPaddingSeconds: z.number().nonnegative().default(0).catch(0), + endPaddingSeconds: z.number().nonnegative().default(0).catch(0), + comingUpNextForSeconds: z.number().nonnegative().default(0).catch(0), + comingUpNextOffsetSeconds: z.number().nonnegative().default(30).catch(30), + fadeDurationSeconds: z.number().nonnegative().default(0.5).catch(0.5), + }) + .optional() + .catch(undefined), }); export type ChannelTranscodingSettings = z.infer< diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index 24dc22faa..4549bd3ff 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -4,6 +4,14 @@ import type { } from '@/db/interfaces/ISettingsDB.js'; import type { ChannelOrm } from '@/db/schema/Channel.js'; import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js'; +import { globalOptions } from '@/globals.js'; +import { + filenameToNowPlayingTitle, + getNowPlayingMetadata, + getNowPlayingMetadataFromFfprobe, + type NowPlayingOverlayPayload, + resolveNowPlayingOverlay, +} from '@/ffmpeg/NowPlayingOverlay.js'; import { InfiniteLoopInputOption } from '@/ffmpeg/builder/options/input/InfiniteLoopInputOption.js'; import type { AudioStreamDetails } from '@/stream/types.js'; import { FileStreamSource, HttpStreamSource } from '@/stream/types.js'; @@ -15,6 +23,8 @@ import { ChannelStreamModes } from '@tunarr/types'; import dayjs from 'dayjs'; import type { Duration } from 'dayjs/plugin/duration.js'; import { isUndefined } from 'lodash-es'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; import type { DeepReadonly, NonEmptyArray } from 'ts-essentials'; import { match, P } from 'ts-pattern'; import type { IChannelDB } from '../db/interfaces/IChannelDB.ts'; @@ -294,6 +304,7 @@ export class FfmpegStreamFactory extends IFFMPEG { duration, realtime, watermark, + nowPlayingOverlay: nowPlayingOverlayConfig, streamMode, }, lineupItem, @@ -491,12 +502,21 @@ export class FfmpegStreamFactory extends IFFMPEG { } } + const nowPlayingOverlay = await this.maybeCreateNowPlayingOverlay({ + config: nowPlayingOverlayConfig, + lineupItem, + playbackParams, + startTime, + streamSource, + }); + const builder = await this.pipelineBuilderFactory(this.transcodeConfig) .setHardwareAccelerationMode(playbackParams.hwAccel) .setVideoInputSource(videoInputSource) .setAudioInputSource(audioInput) .setWatermarkInputSource(watermarkSource) .setSubtitleInputSource(subtitleSource) + .setNowPlayingOverlay(nowPlayingOverlay) .build(); const scaledSize = videoStream.squarePixelFrameSize( @@ -567,6 +587,195 @@ export class FfmpegStreamFactory extends IFFMPEG { ); } + private async maybeCreateNowPlayingOverlay(args: { + config: StreamSessionCreateArgs['options']['nowPlayingOverlay']; + lineupItem: StreamSessionCreateArgs['lineupItem']; + playbackParams: ReturnType; + startTime: Duration; + streamSource: FileStreamSource | HttpStreamSource; + }): Promise> { + const { config, lineupItem, playbackParams, startTime, streamSource } = args; + if ( + !config?.enabled || + playbackParams.videoFormat === VideoFormats.Copy + ) { + this.logger.debug( + { + enabled: config?.enabled ?? false, + videoFormat: playbackParams.videoFormat, + }, + 'Skipping now playing overlay before metadata enrichment', + ); + return null; + } + + const capabilities = await this.ffmpegInfo.getCapabilities(); + if (!capabilities.hasFilter('drawtext')) { + this.logger.warn( + 'Skipping now playing overlay because drawtext is not available in this FFmpeg build', + ); + return null; + } + + let metadata = getNowPlayingMetadata(lineupItem); + if (streamSource.type === 'file') { + try { + this.logger.debug( + { path: streamSource.path }, + 'Probing current program file for now playing overlay metadata', + ); + const probe = await this.ffmpegInfo.probeFileWithCache(streamSource.path); + if (probe) { + const fileMetadata = getNowPlayingMetadataFromFfprobe(probe); + this.logger.debug( + { path: streamSource.path, fileMetadata }, + 'Resolved current program file metadata for now playing overlay', + ); + metadata = { + title: fileMetadata.title ?? metadata.title, + artist: fileMetadata.artist ?? metadata.artist, + album: fileMetadata.album ?? metadata.album, + year: fileMetadata.year ?? metadata.year, + }; + } + } catch (error) { + this.logger.debug( + { err: error, path: streamSource.path }, + 'Unable to probe local file metadata for now playing overlay', + ); + } + } + + let nextMetadata = lineupItem.nextProgramMetadata; + if (isNonEmptyString(nextMetadata?.filePath)) { + try { + this.logger.debug( + { path: nextMetadata.filePath }, + 'Probing next program file for now playing overlay metadata', + ); + const probe = await this.ffmpegInfo.probeFileWithCache( + nextMetadata.filePath, + ); + if (probe) { + const fileMetadata = getNowPlayingMetadataFromFfprobe(probe); + this.logger.debug( + { path: nextMetadata.filePath, fileMetadata }, + 'Resolved next program file metadata for now playing overlay', + ); + nextMetadata = { + title: fileMetadata.title ?? nextMetadata.title, + artist: fileMetadata.artist ?? nextMetadata.artist, + album: fileMetadata.album ?? nextMetadata.album, + year: fileMetadata.year ?? nextMetadata.year, + filePath: nextMetadata.filePath, + }; + } + } catch (error) { + this.logger.debug( + { err: error, path: nextMetadata.filePath }, + 'Unable to probe next local file metadata for now playing overlay', + ); + } + } + + const overlay = resolveNowPlayingOverlay({ + metadata, + nextMetadata, + filePath: streamSource.type === 'file' ? streamSource.path : undefined, + fontFile: this.resolveNowPlayingOverlayFontFile(), + showForSeconds: config.showForSeconds, + showAtEndForSeconds: config.showAtEndForSeconds, + startPaddingSeconds: config.startPaddingSeconds, + endPaddingSeconds: config.endPaddingSeconds, + comingUpNextForSeconds: config.comingUpNextForSeconds, + comingUpNextOffsetSeconds: config.comingUpNextOffsetSeconds, + fadeDurationSeconds: config.fadeDurationSeconds, + startOffsetSeconds: startTime.asSeconds(), + remainingDurationSeconds: lineupItem.streamDuration / 1000, + }); + const resolvedNextTitle = + nextMetadata?.title ?? filenameToNowPlayingTitle(nextMetadata?.filePath); + + if ((config.comingUpNextForSeconds ?? 0) <= 0) { + this.logger.debug( + { + comingUpNextForSeconds: config.comingUpNextForSeconds ?? 0, + }, + 'Skipping coming up next overlay because duration is disabled', + ); + } else if (!nextMetadata) { + this.logger.debug( + 'Skipping coming up next overlay because no next program metadata was available', + ); + } else if (!resolvedNextTitle) { + this.logger.debug( + { + nextMetadata, + }, + 'Skipping coming up next overlay because next title could not be resolved from metadata or filename', + ); + } else if ((overlay?.comingUpNextWindows?.length ?? 0) === 0) { + this.logger.debug( + { + openingWindows: overlay?.windows ?? [], + nextMetadata, + showAtEndForSeconds: config.showAtEndForSeconds ?? 0, + comingUpNextForSeconds: config.comingUpNextForSeconds ?? 0, + comingUpNextOffsetSeconds: config.comingUpNextOffsetSeconds ?? 0, + remainingDurationSeconds: lineupItem.streamDuration / 1000, + }, + 'Skipping coming up next overlay because timing constraints eliminated its window', + ); + } + + this.logger.debug( + { + metadata, + nextMetadata, + hasOverlay: !!overlay, + windows: overlay?.windows, + comingUpNextWindows: overlay?.comingUpNextWindows, + }, + 'Resolved now playing overlay payload', + ); + + return overlay ?? null; + } + + private resolveNowPlayingOverlayFontFile(): string | undefined { + const candidates: string[] = []; + + const dbDir = globalOptions().databaseDirectory; + if (isNonEmptyString(dbDir)) { + candidates.push(path.join(dbDir, 'font.ttf')); + } + + switch (process.platform) { + case 'win32': + candidates.push( + 'C:/Windows/Fonts/segoeui.ttf', + 'C:/Windows/Fonts/arial.ttf', + 'C:/Windows/Fonts/tahoma.ttf', + ); + break; + case 'darwin': + candidates.push( + '/System/Library/Fonts/Supplemental/Arial.ttf', + '/Library/Fonts/Arial.ttf', + ); + break; + default: + candidates.push( + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + '/usr/share/fonts/TTF/DejaVuSans.ttf', + '/usr/share/fonts/truetype/freefont/FreeSans.ttf', + ); + break; + } + + return candidates.find((candidate) => existsSync(candidate)); + } + async createErrorSession( title: string, subtitle: Maybe, diff --git a/server/src/ffmpeg/NowPlayingOverlay.test.ts b/server/src/ffmpeg/NowPlayingOverlay.test.ts new file mode 100644 index 000000000..42313b983 --- /dev/null +++ b/server/src/ffmpeg/NowPlayingOverlay.test.ts @@ -0,0 +1,307 @@ +import type { ContentBackedStreamLineupItem } from '@/db/derived_types/StreamLineup.js'; +import type { FfprobeMediaInfo } from '@/types/ffmpeg.js'; +import { + getNowPlayingMetadata, + getNowPlayingMetadataFromFfprobe, + resolveNowPlayingOverlay, +} from './NowPlayingOverlay.ts'; + +function makeLineupItem( + program: Partial = {}, +): ContentBackedStreamLineupItem { + return { + type: 'program', + streamDuration: 180_000, + duration: 180_000, + programBeginMs: 0, + infiniteLoop: false, + program: { + title: 'Song Title', + artistName: 'Artist Name', + albumName: 'Album Name', + year: 1998, + originalAirDate: null, + sourceType: 'local', + mediaSourceId: 'local-source', + externalIds: [], + ...program, + }, + } as unknown as ContentBackedStreamLineupItem; +} + +describe('NowPlayingOverlay', () => { + describe('getNowPlayingMetadata', () => { + test('reads title, artist, album, and year from the lineup item', () => { + const metadata = getNowPlayingMetadata(makeLineupItem()); + expect(metadata).toEqual({ + title: 'Song Title', + artist: 'Artist Name', + album: 'Album Name', + year: '1998', + }); + }); + }); + + describe('getNowPlayingMetadataFromFfprobe', () => { + test('reads case-insensitive ffprobe tags and extracts a year', () => { + const metadata = getNowPlayingMetadataFromFfprobe({ + format: { + tags: { + TITLE: 'Tagged Title', + ALBUM_ARTIST: 'Tagged Artist', + }, + }, + streams: [ + { + tags: { + album: 'Tagged Album', + creation_time: '1997-05-04T12:00:00.000000Z', + }, + }, + ], + } as unknown as FfprobeMediaInfo); + + expect(metadata).toEqual({ + title: 'Tagged Title', + artist: 'Tagged Artist', + album: 'Tagged Album', + year: '1997', + }); + }); + }); + + describe('resolveNowPlayingOverlay', () => { + test('returns undefined when metadata is empty and no filepath', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: {}, + showForSeconds: 8, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + expect(overlay).toBeUndefined(); + }); + + test('returns undefined when metadata contains only whitespace', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: ' \t ', artist: ' ', album: '' }, + showForSeconds: 8, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + expect(overlay).toBeUndefined(); + }); + + test('returns undefined when showForSeconds is 0 and no end window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 0, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + expect(overlay).toBeUndefined(); + }); + + test('builds opening window adjusted by start offset', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title', artist: 'Artist' }, + showForSeconds: 8, + startOffsetSeconds: 2.25, + remainingDurationSeconds: 177.75, + }); + + expect(overlay!.title).toBe('Song Title'); + expect(overlay!.subtitle).toBe('Artist'); + expect(overlay!.windows[0]!.endSeconds).toBeCloseTo(5.75, 2); + }); + + test('skips overlay when viewer joins after the opening window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 6, + startOffsetSeconds: 6, + remainingDurationSeconds: 174, + }); + expect(overlay).toBeUndefined(); + }); + + test('adds a closing window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 6, + showAtEndForSeconds: 8, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.windows).toHaveLength(2); + expect(overlay!.windows[0]).toEqual({ startSeconds: 0, endSeconds: 6 }); + expect(overlay!.windows[1]).toEqual({ + startSeconds: 172, + endSeconds: 180, + }); + }); + + test('skips closing window when it would overlap opening window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 6, + showAtEndForSeconds: 8, + startOffsetSeconds: 0, + remainingDurationSeconds: 5, + }); + + expect(overlay!.windows).toHaveLength(1); + expect(overlay!.windows[0]!.endSeconds).toBe(5); + }); + + test('startPaddingSeconds delays the opening window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 8, + startPaddingSeconds: 2, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.windows[0]).toEqual({ + startSeconds: 2, + endSeconds: 10, + }); + }); + + test('endPaddingSeconds adds gap before program end', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 8, + showAtEndForSeconds: 8, + endPaddingSeconds: 2, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.windows[1]).toEqual({ + startSeconds: 170, + endSeconds: 178, + }); + }); + + test('creates coming-up-next window when configured', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + nextMetadata: { title: 'Next Song', artist: 'Next Artist' }, + showForSeconds: 8, + comingUpNextForSeconds: 6, + comingUpNextOffsetSeconds: 30, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.nextTitle).toBe('Next Song'); + expect(overlay!.nextSubtitle).toBe('Next Artist'); + expect(overlay!.comingUpNextWindows).toEqual([ + { startSeconds: 150, endSeconds: 156 }, + ]); + }); + + test('uses next file metadata fallback for coming up next', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + nextMetadata: { + artist: 'Next Artist', + album: 'Next Album', + year: '1995', + filePath: "D:/music/No Doubt - Don't Speak (Official 4K Music Video) [No Doubt].mp4", + }, + showForSeconds: 8, + comingUpNextForSeconds: 6, + comingUpNextOffsetSeconds: 30, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.nextTitle).toBe( + "No Doubt - Don't Speak (Official 4K Music Video) [No Doubt]", + ); + expect(overlay!.nextSubtitle).toBe('Next Artist - Next Album - 1995'); + }); + + test('skips coming-up-next when comingUpNextForSeconds is 0', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + nextMetadata: { title: 'Next Song' }, + showForSeconds: 8, + comingUpNextForSeconds: 0, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.comingUpNextWindows).toEqual([]); + }); + + test('skips coming-up-next when no next metadata', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 8, + comingUpNextForSeconds: 6, + comingUpNextOffsetSeconds: 30, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.comingUpNextWindows).toEqual([]); + }); + + test('skips coming-up-next when it would overlap closing window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + nextMetadata: { title: 'Next Song' }, + showForSeconds: 8, + showAtEndForSeconds: 10, + comingUpNextForSeconds: 6, + comingUpNextOffsetSeconds: 15, + startOffsetSeconds: 0, + remainingDurationSeconds: 60, + }); + + // coming-up-next would be 45-51, closing is 50-60 — overlap, so skipped + expect(overlay!.comingUpNextWindows).toEqual([]); + }); + + test('skips coming-up-next when it would overlap opening window', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + nextMetadata: { title: 'Next Song' }, + showForSeconds: 8, + comingUpNextForSeconds: 6, + comingUpNextOffsetSeconds: 10, + startOffsetSeconds: 0, + remainingDurationSeconds: 10, + }); + + expect(overlay!.comingUpNextWindows).toEqual([]); + }); + + test('defaults fadeDurationSeconds to 0.5', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 8, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.fadeDurationSeconds).toBe(0.5); + }); + + test('uses provided fadeDurationSeconds', () => { + const overlay = resolveNowPlayingOverlay({ + metadata: { title: 'Song Title' }, + showForSeconds: 8, + fadeDurationSeconds: 0.75, + startOffsetSeconds: 0, + remainingDurationSeconds: 180, + }); + + expect(overlay!.fadeDurationSeconds).toBe(0.75); + }); + }); +}); diff --git a/server/src/ffmpeg/NowPlayingOverlay.ts b/server/src/ffmpeg/NowPlayingOverlay.ts new file mode 100644 index 000000000..ab17999d2 --- /dev/null +++ b/server/src/ffmpeg/NowPlayingOverlay.ts @@ -0,0 +1,301 @@ +import type { ContentBackedStreamLineupItem } from '@/db/derived_types/StreamLineup.js'; +import type { FfprobeMediaInfo } from '@/types/ffmpeg.js'; +import { isNonEmptyString } from '@/util/index.js'; +import path from 'node:path'; + +export type NowPlayingOverlayPayload = { + title: string; + subtitle?: string; + nextTitle?: string; + nextSubtitle?: string; + windows: NowPlayingOverlayWindow[]; + comingUpNextWindows: NowPlayingOverlayWindow[]; + fadeDurationSeconds: number; + fontFile?: string; +}; + +export type NowPlayingOverlayWindow = { + startSeconds: number; + endSeconds: number; +}; + +export type NowPlayingMetadata = { + title?: string; + artist?: string; + album?: string; + year?: string; + filePath?: string; +}; + +export function getNowPlayingMetadata( + lineupItem: ContentBackedStreamLineupItem, +): NowPlayingMetadata { + const { program } = lineupItem; + return { + title: normalizeText(program.title), + artist: normalizeText(program.artistName), + album: normalizeText(program.albumName), + year: extractYear(program.year ?? program.originalAirDate), + }; +} + +export function getNowPlayingMetadataFromFfprobe( + probe: FfprobeMediaInfo, +): NowPlayingMetadata { + return { + title: findAnyTag(probe, ['title']), + artist: findAnyTag(probe, ['artist', 'album_artist', 'composer']), + album: findAnyTag(probe, ['album']), + year: extractYear( + findAnyTag(probe, ['year', 'date', 'creation_time', 'releasedate']), + ), + }; +} + +export function resolveNowPlayingOverlay(args: { + metadata: NowPlayingMetadata; + nextMetadata?: NowPlayingMetadata; + filePath?: string; + fontFile?: string; + showForSeconds: number; + showAtEndForSeconds?: number; + startPaddingSeconds?: number; + endPaddingSeconds?: number; + comingUpNextForSeconds?: number; + comingUpNextOffsetSeconds?: number; + fadeDurationSeconds?: number; + startOffsetSeconds: number; + remainingDurationSeconds: number; +}): NowPlayingOverlayPayload | undefined { + const fadeDuration = args.fadeDurationSeconds ?? 0.5; + const startPadding = args.startPaddingSeconds ?? 0; + const endPadding = args.endPaddingSeconds ?? 0; + const comingUpNextFor = args.comingUpNextForSeconds ?? 0; + const comingUpNextOffset = args.comingUpNextOffsetSeconds ?? 30; + + const windows = buildOverlayWindows({ + showForSeconds: args.showForSeconds, + showAtEndForSeconds: args.showAtEndForSeconds ?? 0, + startPaddingSeconds: startPadding, + endPaddingSeconds: endPadding, + startOffsetSeconds: args.startOffsetSeconds, + remainingDurationSeconds: args.remainingDurationSeconds, + }); + + const closingWindow = windows.length >= 2 ? windows[1] : undefined; + const comingUpNextWindows = buildComingUpNextWindow({ + comingUpNextForSeconds: comingUpNextFor, + comingUpNextOffsetSeconds: comingUpNextOffset, + startOffsetSeconds: args.startOffsetSeconds, + remainingDurationSeconds: args.remainingDurationSeconds, + openingWindow: windows[0], + closingWindow, + }); + + if (windows.length === 0 && comingUpNextWindows.length === 0) { + return; + } + + const title = + normalizeText(args.metadata.title) ?? + filenameToNowPlayingTitle(args.filePath); + if (!title) { + return; + } + + const subtitle = joinNonEmpty([ + args.metadata.artist, + args.metadata.album, + args.metadata.year, + ]); + + const nextTitle = normalizeText(args.nextMetadata?.title); + const resolvedNextTitle = + nextTitle ?? filenameToNowPlayingTitle(args.nextMetadata?.filePath); + const nextSubtitle = joinNonEmpty([ + args.nextMetadata?.artist, + args.nextMetadata?.album, + args.nextMetadata?.year, + ]); + + return { + title, + subtitle, + nextTitle: comingUpNextWindows.length > 0 ? resolvedNextTitle : undefined, + nextSubtitle: comingUpNextWindows.length > 0 ? nextSubtitle : undefined, + windows, + comingUpNextWindows: resolvedNextTitle ? comingUpNextWindows : [], + fadeDurationSeconds: fadeDuration, + fontFile: args.fontFile, + }; +} + +export function filenameToNowPlayingTitle( + filePath?: string, +): string | undefined { + if (!isNonEmptyString(filePath)) { + return; + } + + const parsed = path.parse(filePath); + const cleaned = normalizeText(parsed.name.replaceAll(/[._]+/g, ' ')); + return cleaned; +} + +function findAnyTag( + probe: FfprobeMediaInfo, + keys: readonly string[], +): string | undefined { + const tagSets = [ + probe.format.tags, + ...probe.streams.map((stream) => + 'tags' in stream ? stream.tags : undefined, + ), + ]; + + for (const tags of tagSets) { + if (!tags) { + continue; + } + + const normalized = new Map( + Object.entries(tags).map(([key, value]) => [key.toLowerCase(), value]), + ); + + for (const key of keys) { + const value = normalizeText(normalized.get(key.toLowerCase())); + if (value) { + return value; + } + } + } + + return; +} + +function extractYear( + input: number | string | null | undefined, +): string | undefined { + if (typeof input === 'number' && Number.isFinite(input) && input > 0) { + return Math.trunc(input).toString(); + } + + if (!isNonEmptyString(input)) { + return; + } + + const match = input.match(/\b(19|20)\d{2}\b/); + return match?.[0]; +} + +function joinNonEmpty(values: Array): string | undefined { + const filtered = values.flatMap((value) => (value ? [value] : [])); + return filtered.length > 0 ? filtered.join(' - ') : undefined; +} + +function normalizeText(value: string | null | undefined): string | undefined { + if (!isNonEmptyString(value)) { + return; + } + + const trimmed = value.replaceAll(/\s+/g, ' ').trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function roundSeconds(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function buildOverlayWindows(args: { + showForSeconds: number; + showAtEndForSeconds: number; + startPaddingSeconds: number; + endPaddingSeconds: number; + startOffsetSeconds: number; + remainingDurationSeconds: number; +}): NowPlayingOverlayWindow[] { + const windows: NowPlayingOverlayWindow[] = []; + const remaining = roundSeconds(Math.max(0, args.remainingDurationSeconds)); + + // Opening window: starts after startPadding, adjusted by startOffset + const openingStart = roundSeconds( + Math.max(0, args.startPaddingSeconds - args.startOffsetSeconds), + ); + const openingEnd = roundSeconds( + Math.min( + remaining, + Math.max( + 0, + args.showForSeconds + args.startPaddingSeconds - args.startOffsetSeconds, + ), + ), + ); + if (openingEnd > openingStart) { + windows.push({ + startSeconds: openingStart, + endSeconds: openingEnd, + }); + } + + // Closing window: ends at remaining - endPadding + const closingDuration = roundSeconds(Math.max(0, args.showAtEndForSeconds)); + const closingEnd = roundSeconds( + Math.max(0, remaining - args.endPaddingSeconds), + ); + const closingStart = roundSeconds( + Math.max(0, closingEnd - closingDuration), + ); + if (remaining > 0 && closingDuration > 0 && closingEnd > closingStart) { + // Skip if it would overlap with the opening window + const lastOpening = windows[windows.length - 1]; + if (!lastOpening || closingStart >= lastOpening.endSeconds) { + windows.push({ + startSeconds: closingStart, + endSeconds: closingEnd, + }); + } + } + + return windows; +} + +function buildComingUpNextWindow(args: { + comingUpNextForSeconds: number; + comingUpNextOffsetSeconds: number; + startOffsetSeconds: number; + remainingDurationSeconds: number; + openingWindow?: NowPlayingOverlayWindow; + closingWindow?: NowPlayingOverlayWindow; +}): NowPlayingOverlayWindow[] { + if (args.comingUpNextForSeconds <= 0) { + return []; + } + + const remaining = roundSeconds(Math.max(0, args.remainingDurationSeconds)); + if (remaining <= 0) { + return []; + } + + const cuStart = roundSeconds( + Math.max(0, remaining - args.comingUpNextOffsetSeconds), + ); + const cuEnd = roundSeconds( + Math.min(remaining, cuStart + args.comingUpNextForSeconds), + ); + + if (cuEnd <= cuStart) { + return []; + } + + // Skip if it overlaps with the opening window + if (args.openingWindow && cuStart < args.openingWindow.endSeconds) { + return []; + } + + // Skip if it overlaps with the closing window + if (args.closingWindow && cuEnd > args.closingWindow.startSeconds) { + return []; + } + + return [{ startSeconds: cuStart, endSeconds: cuEnd }]; +} diff --git a/server/src/ffmpeg/builder/filter/ComplexFilter.ts b/server/src/ffmpeg/builder/filter/ComplexFilter.ts index 567e04d38..e37cf8b3a 100644 --- a/server/src/ffmpeg/builder/filter/ComplexFilter.ts +++ b/server/src/ffmpeg/builder/filter/ComplexFilter.ts @@ -63,6 +63,7 @@ export class ComplexFilter implements FilterOptionPipelineStep { let audioFilterComplex = ''; let watermarkFilterComplex = ''; let watermarkOverlayFilterComplex = ''; + let nowPlayingOverlayFilterComplex = ''; let subtitleFilterComplex = ''; let subtitleOverlayFilterComplex = ''; @@ -151,6 +152,15 @@ export class ComplexFilter implements FilterOptionPipelineStep { watermarkOverlayFilterComplex += videoLabel; } + if (this.filterChain.nowPlayingOverlayFilterSteps.length > 0) { + const filterString = collectAndJoinSteps( + this.filterChain.nowPlayingOverlayFilterSteps, + ); + nowPlayingOverlayFilterComplex += `${formatLabel(videoLabel)}${filterString}`; + videoLabel = '[vtxt]'; + nowPlayingOverlayFilterComplex += videoLabel; + } + ifDefined(this.audioInputSource, (audioInput) => { const audioInputIndex = distinctPaths.indexOf(audioInput.path); if (audioInputIndex === -1) { @@ -188,6 +198,7 @@ export class ComplexFilter implements FilterOptionPipelineStep { watermarkFilterComplex, subtitleOverlayFilterComplex, watermarkOverlayFilterComplex, + nowPlayingOverlayFilterComplex, pixelFormatFilterComplex, ]; const filterComplex = filter(allFilters, isNonEmptyString).join(';'); diff --git a/server/src/ffmpeg/builder/filter/FilterChain.ts b/server/src/ffmpeg/builder/filter/FilterChain.ts index c538bc631..ae470c311 100644 --- a/server/src/ffmpeg/builder/filter/FilterChain.ts +++ b/server/src/ffmpeg/builder/filter/FilterChain.ts @@ -5,5 +5,6 @@ export class FilterChain { videoFilterSteps: HasFilterOption[] = []; subtitleOverlayFilterSteps: FilterOption[] = []; watermarkOverlayFilterSteps: FilterOption[] = []; + nowPlayingOverlayFilterSteps: FilterOption[] = []; pixelFormatFilterSteps: HasFilterOption[] = []; } diff --git a/server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.test.ts b/server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.test.ts new file mode 100644 index 000000000..dd50df169 --- /dev/null +++ b/server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.test.ts @@ -0,0 +1,163 @@ +import { FrameSize } from '@/ffmpeg/builder/types.js'; +import { isWindows } from '@/util/index.js'; +import { NowPlayingOverlayFilter } from './NowPlayingOverlayFilter.ts'; + +describe('NowPlayingOverlayFilter', () => { + test('renders a full-width lower-third with title and subtitle', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Song Title', + subtitle: 'Artist - Album', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain( + "drawbox=x=0:y=624:w=1280:h=96:color=black@0.58:t=fill:enable='between(t\\,0\\,8)'", + ); + expect(filter.filter).toContain('expansion=none:text=Song Title'); + expect(filter.filter).toContain('text=Artist - Album'); + }); + + test('escapes colons and brackets in text values', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Title: Part [2]', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain('text=Title\\\\: Part \\[2\\]'); + }); + + test('escapes apostrophes in text', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: "Don't Stop Me Now", + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain("text=Don\\\\\\'t Stop Me Now"); + }); + + test('escapes semicolons and hashes in text', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Track #1; Remix', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain('text=Track \\#1\\; Remix'); + }); + + test('preserves percent signs in text when expansion is disabled', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: '100% Pure', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain('expansion=none:text=100% Pure'); + }); + + test('escapes colon in Windows font path', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Song Title', + windows: [{ startSeconds: 0, endSeconds: 5.5 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + fontFile: 'C:\\Windows\\Fonts\\arial.ttf', + }); + + if (isWindows()) { + expect(filter.filter).toContain('fontfile=C\\\\:/Windows/Fonts/arial.ttf'); + } else { + expect(filter.filter).toContain('fontfile=C:/Windows/Fonts/arial.ttf'); + } + }); + + test('generates fade alpha expression when fadeDurationSeconds > 0', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Song Title', + windows: [{ startSeconds: 2, endSeconds: 10 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0.5, + }); + + expect(filter.filter).toContain( + "alpha='if(between(t\\,2\\,10)\\,min(1\\,(t-2)/0.5)*min(1\\,(10-t)/0.5)\\,0)'", + ); + }); + + test('omits alpha when fadeDurationSeconds is 0', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Song Title', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).not.toContain('alpha='); + }); + + test('renders coming-up-next card with its own enable window', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Current Song', + subtitle: 'Current Artist', + nextTitle: 'Next Song', + nextSubtitle: 'Next Artist', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [{ startSeconds: 150, endSeconds: 156 }], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain('text=Current Song'); + expect(filter.filter).toContain( + 'text=Coming Up Next', + ); + expect(filter.filter).toContain('text=Next Song - Next Artist'); + expect(filter.filter).toContain("enable='between(t\\,150\\,156)'"); + }); + + test('escapes commas in text values', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Artist, Live', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain('text=Artist\\, Live'); + }); + + test('does not render coming-up-next when comingUpNextWindows is empty', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Song Title', + nextTitle: 'Next Song', + windows: [{ startSeconds: 0, endSeconds: 8 }], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).not.toContain('Coming up'); + }); + + test('renders multiple enable windows for opening + closing', () => { + const filter = new NowPlayingOverlayFilter(FrameSize.SevenTwenty, { + title: 'Song Title', + windows: [ + { startSeconds: 0, endSeconds: 6 }, + { startSeconds: 172, endSeconds: 180 }, + ], + comingUpNextWindows: [], + fadeDurationSeconds: 0, + }); + + expect(filter.filter).toContain( + "enable='between(t\\,0\\,6)+between(t\\,172\\,180)'", + ); + }); +}); diff --git a/server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.ts b/server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.ts new file mode 100644 index 000000000..0ccbf80b9 --- /dev/null +++ b/server/src/ffmpeg/builder/filter/NowPlayingOverlayFilter.ts @@ -0,0 +1,207 @@ +import type { + NowPlayingOverlayPayload, + NowPlayingOverlayWindow, +} from '@/ffmpeg/NowPlayingOverlay.js'; +import type { FrameSize } from '@/ffmpeg/builder/types.js'; +import { isWindows } from '@/util/index.js'; +import { FilterOption } from './FilterOption.ts'; + +export class NowPlayingOverlayFilter extends FilterOption { + constructor( + private readonly size: FrameSize, + private readonly overlay: NowPlayingOverlayPayload, + ) { + super(); + } + + get filter(): string { + const filters: string[] = []; + + // Current program overlay (opening + closing windows) + if (this.overlay.windows.length > 0) { + filters.push( + ...this.buildCardFilters( + this.overlay.title, + this.overlay.subtitle, + this.overlay.windows, + ), + ); + } + + // Coming up next overlay + if ( + this.overlay.nextTitle && + this.overlay.comingUpNextWindows.length > 0 + ) { + const nextSubtitle = this.overlay.nextSubtitle + ? `${this.overlay.nextTitle} - ${this.overlay.nextSubtitle}` + : this.overlay.nextTitle; + filters.push( + ...this.buildCardFilters( + 'Coming Up Next', + nextSubtitle, + this.overlay.comingUpNextWindows, + ), + ); + } + + return filters.join(','); + } + + private buildCardFilters( + title: string, + subtitle: string | undefined, + windows: NowPlayingOverlayWindow[], + ): string[] { + const hasSubtitle = !!subtitle; + const boxWidth = this.size.width; + const boxHeight = hasSubtitle + ? Math.max(96, Math.round(this.size.height * 0.12)) + : Math.max(62, Math.round(this.size.height * 0.08)); + const y = this.size.height - boxHeight; + const titleFontSize = Math.max(24, Math.round(this.size.height / 30)); + const subtitleFontSize = Math.max(18, Math.round(this.size.height / 42)); + const titleY = y + Math.round(boxHeight * (hasSubtitle ? 0.34 : 0.58)); + const subtitleY = y + Math.round(boxHeight * 0.72); + const textX = 20; + const fade = this.overlay.fadeDurationSeconds; + + const enable = buildEnableExpr(windows); + const alpha = buildAlphaExpr(windows, fade); + const escapedEnable = escapeFilterExpression(enable); + const escapedAlpha = alpha ? escapeFilterExpression(alpha) : undefined; + + const filters: string[] = []; + + // Background box via drawbox (no fade on box, only on text) + filters.push( + `drawbox=x=0:y=${y}:w=${boxWidth}:h=${boxHeight}:color=black@0.58:t=fill:enable='${escapedEnable}'`, + ); + + // Title + filters.push( + formatDrawText({ + fontFile: this.overlay.fontFile, + text: title, + x: textX, + y: titleY, + fontSize: titleFontSize, + enable: escapedEnable, + alpha: escapedAlpha, + }), + ); + + // Subtitle + if (subtitle) { + filters.push( + formatDrawText({ + fontFile: this.overlay.fontFile, + text: subtitle, + x: textX, + y: subtitleY, + fontSize: subtitleFontSize, + enable: escapedEnable, + alpha: escapedAlpha, + }), + ); + } + + return filters; + } +} + +function buildEnableExpr(windows: NowPlayingOverlayWindow[]): string { + return windows + .map( + (w) => + `between(t,${formatSeconds(w.startSeconds)},${formatSeconds(w.endSeconds)})`, + ) + .join('+'); +} + +function buildAlphaExpr( + windows: NowPlayingOverlayWindow[], + fadeDuration: number, +): string | undefined { + if (fadeDuration <= 0) { + return; + } + + const f = formatSeconds(fadeDuration); + const parts = windows.map((w) => { + const s = formatSeconds(w.startSeconds); + const e = formatSeconds(w.endSeconds); + return `if(between(t,${s},${e}),min(1,(t-${s})/${f})*min(1,(${e}-t)/${f}),0)`; + }); + + // For multiple windows, sum the parts (only one is active at a time) + return parts.length === 1 ? parts[0] : parts.join('+'); +} + +// drawtext text=... has different parsing rules than fontfile=... and than the +// filter expressions used by enable=/alpha=. Keeping them separate is the only +// reliable way to preserve text like "Don't Speak" across platforms. +// +// FFmpeg filtergraphs require a second escaping layer for text values. In +// practice that means text=... needs filtergraph-safe escapes such as \\' +// for apostrophes and \\: for colons, while expressions and paths follow +// different rules. +function escapeDrawTextValue(text: string): string { + return text + .replaceAll('\\', '\\\\\\\\') + .replaceAll("'", "\\\\\\'") + .replaceAll(':', '\\\\:') + .replaceAll('[', '\\[') + .replaceAll(']', '\\]') + .replaceAll('(', '\\(') + .replaceAll(')', '\\)') + .replaceAll(';', '\\;') + .replaceAll('#', '\\#') + .replaceAll(',', '\\,'); +} + +// FFmpeg accepts forward slashes on all platforms. On Windows, drive-letter +// paths need the colon escaped as C\\:/... inside filter graphs; macOS/Linux +// paths do not have that prefix and should otherwise pass through untouched. +function escapeDrawTextPath(path: string): string { + let escaped = path.replaceAll('\\', '/'); + + if (isWindows()) { + escaped = escaped.replaceAll(':/', '\\\\:/'); + } + + return escaped + .replaceAll('[', '\\[') + .replaceAll(']', '\\]') + .replaceAll(';', '\\;') + .replaceAll('#', '\\#') + .replaceAll(',', '\\,'); +} + +function formatSeconds(seconds: number): string { + return `${Math.round(seconds * 1000) / 1000}`; +} + +function escapeFilterExpression(expr: string): string { + return expr.replaceAll(',', '\\,'); +} + +function formatDrawText(args: { + fontFile?: string; + text: string; + x: number; + y: number; + fontSize: number; + enable: string; + alpha?: string; +}): string { + // Convert Windows backslashes to forward slashes before escaping, + // since FFmpeg expects forward slashes in fontfile paths on all platforms. + const fontFilePrefix = args.fontFile + ? `fontfile=${escapeDrawTextPath(args.fontFile)}:` + : ''; + + const alphaSuffix = args.alpha ? `:alpha='${args.alpha}'` : ''; + + return `drawtext=${fontFilePrefix}expansion=none:text=${escapeDrawTextValue(args.text)}:x=${args.x}:y=${args.y}:fontsize=${args.fontSize}:fontcolor=white:enable='${args.enable}'${alphaSuffix}`; +} diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index 504641339..a21c760ed 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -2,6 +2,7 @@ import { HardwareAccelerationMode, TranscodeAudioOutputFormat, } from '@/db/schema/TranscodeConfig.js'; +import type { NowPlayingOverlayPayload } from '@/ffmpeg/NowPlayingOverlay.js'; import { SubtitleMethods, type AudioStream, @@ -55,7 +56,7 @@ import type { PipelineStep, } from '@/ffmpeg/builder/types/PipelineStep.js'; import type { DataProps, Nilable, Nullable } from '@/types/util.js'; -import { ifDefined, isNonEmptyString } from '@/util/index.js'; +import { ifDefined, isDefined, isNonEmptyString } from '@/util/index.js'; import type { Logger } from '@/util/logging/LoggerFactory.js'; import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { getTunarrVersion } from '@/util/version.js'; @@ -152,6 +153,7 @@ export class PipelineBuilderContext { videoStream?: VideoStream; audioStream?: AudioStream; subtitleStream?: SubtitleStream; + nowPlayingOverlay?: NowPlayingOverlayPayload; ffmpegState: FfmpegState; desiredState: FrameState; desiredAudioState?: AudioState; @@ -184,6 +186,10 @@ export class PipelineBuilderContext { false ); } + + hasNowPlayingOverlay() { + return isDefined(this.nowPlayingOverlay); + } } export type PipelineBuilderContextWithVideo = MarkRequired< @@ -216,6 +222,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { }); protected decoder: Nullable = null; protected context: PipelineBuilderContext; + protected nowPlayingOverlay: Nullable = null; constructor( protected nullableVideoInputSource: Nullable, @@ -231,6 +238,13 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return this.nullableVideoInputSource!; } + setNowPlayingOverlay( + nowPlayingOverlay: Nullable, + ): this { + this.nowPlayingOverlay = nowPlayingOverlay; + return this; + } + validate(): Nullable { return null; } @@ -336,6 +350,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { videoStream: first(this.nullableVideoInputSource?.streams), audioStream: first(this.audioInputSource?.streams), subtitleStream: first(this.subtitleInputSource?.streams), + nowPlayingOverlay: this.nowPlayingOverlay ?? undefined, ffmpegState, desiredState, desiredAudioState: this.audioInputSource?.desiredState, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts index a81a34a43..0a5233863 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts @@ -9,6 +9,7 @@ import type { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.j import type { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.js'; import type { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.js'; import type { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.js'; +import type { NowPlayingOverlayPayload } from '@/ffmpeg/NowPlayingOverlay.js'; import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js'; import type { Nullable } from '@/types/util.js'; import { ContainerModule } from 'inversify'; @@ -50,6 +51,7 @@ class PipelineBuilderFactory$Builder { private concatInputSource: Nullable = null; private watermarkInputSource: Nullable = null; private subtiitleInputSource: Nullable = null; + private nowPlayingOverlay: Nullable = null; private hardwareAccelerationMode: HardwareAccelerationMode = HardwareAccelerationMode.None; @@ -92,6 +94,13 @@ class PipelineBuilderFactory$Builder { return this; } + setNowPlayingOverlay( + nowPlayingOverlay: Nullable, + ): PipelineBuilderFactory$Builder { + this.nowPlayingOverlay = nowPlayingOverlay; + return this; + } + setHardwareAccelerationMode( hardwareAccelerationMode: HardwareAccelerationMode, ): PipelineBuilderFactory$Builder { @@ -122,7 +131,7 @@ class PipelineBuilderFactory$Builder { this.concatInputSource, this.watermarkInputSource, this.subtiitleInputSource, - ); + ).setNowPlayingOverlay(this.nowPlayingOverlay); case HardwareAccelerationMode.Qsv: return new QsvPipelineBuilder( hardwareCapabilities, @@ -132,7 +141,7 @@ class PipelineBuilderFactory$Builder { this.concatInputSource, this.watermarkInputSource, this.subtiitleInputSource, - ); + ).setNowPlayingOverlay(this.nowPlayingOverlay); case HardwareAccelerationMode.Vaapi: return new VaapiPipelineBuilder( hardwareCapabilities, @@ -142,7 +151,7 @@ class PipelineBuilderFactory$Builder { this.watermarkInputSource, this.subtiitleInputSource, this.concatInputSource, - ); + ).setNowPlayingOverlay(this.nowPlayingOverlay); case HardwareAccelerationMode.Videotoolbox: return new VideoToolboxPipelineBuilder( hardwareCapabilities, @@ -152,7 +161,7 @@ class PipelineBuilderFactory$Builder { this.concatInputSource, this.watermarkInputSource, this.subtiitleInputSource, - ); + ).setNowPlayingOverlay(this.nowPlayingOverlay); default: return new SoftwarePipelineBuilder( this.videoInputSource, @@ -161,7 +170,7 @@ class PipelineBuilderFactory$Builder { this.subtiitleInputSource, this.concatInputSource, binaryCapabilities, - ); + ).setNowPlayingOverlay(this.nowPlayingOverlay); } } } diff --git a/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.ts index 86c748dc8..904d20a94 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.ts @@ -206,6 +206,7 @@ export class QsvPipelineBuilder extends SoftwarePipelineBuilder { } currentState = this.setWatermark(currentState); + currentState = this.applyNowPlayingOverlay(currentState); const noEncoderSteps = every( this.getEncoderSteps(), diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts index 9dfc9f2bd..b59e41e9d 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts @@ -210,6 +210,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { const forceSoftwareOverlay = this.context.pipelineOptions?.disableHardwareFilters || (this.context.hasWatermark && this.context.hasSubtitleOverlay()) || + this.context.hasNowPlayingOverlay() || ffmpegState.vaapiDriver === 'radeonsi'; currentState.forceSoftwareOverlay = forceSoftwareOverlay; @@ -225,7 +226,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { } else if ( currentState.frameDataLocation === FrameDataLocation.Hardware && (!this.context.hasSubtitleOverlay() || forceSoftwareOverlay) && - this.context.hasWatermark + (this.context.hasWatermark || this.context.hasNowPlayingOverlay()) ) { // download for watermark (or forced software subtitle) const filter = new HardwareDownloadFilter(currentState); @@ -238,6 +239,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { // Watermark currentState = this.setWatermark(currentState); + currentState = this.applyNowPlayingOverlay(currentState); const noEncoderSteps = every( this.getEncoderSteps(), diff --git a/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts index 334df2b49..d78e30001 100644 --- a/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts @@ -251,6 +251,8 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { (isDefined(this.watermarkInputSource?.watermark.duration) && this.watermarkInputSource.watermark.duration > 0) || this.context.pipelineOptions?.disableHardwareFilters); + const needsSoftwareVideoOverlay = + this.context.hasNowPlayingOverlay() || needsSoftwareWatermarkOverlay; // If we're certain that we're about to use a hardware overlay of some sort // then ensure the video stream is uploaded to hardware. @@ -260,7 +262,8 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { !this.context.hasSubtitleTextContext() && !this.context.pipelineOptions.disableHardwareFilters && (this.context.hasSubtitleOverlay() || - (this.context.hasWatermark && !needsSoftwareWatermarkOverlay)) + ((this.context.hasWatermark || this.context.hasNowPlayingOverlay()) && + !needsSoftwareVideoOverlay)) ) { const filter = new HardwareUploadCudaFilter(currentState); currentState = filter.nextState(currentState); @@ -273,7 +276,7 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { // for the watermark, do a download if ( currentState.frameDataLocation === FrameDataLocation.Hardware && - needsSoftwareWatermarkOverlay + needsSoftwareVideoOverlay ) { const hwDownloadFilter = new HardwareDownloadCudaFilter( currentState, @@ -297,7 +300,7 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { } } else if ( currentState.frameDataLocation === FrameDataLocation.Software && - !needsSoftwareWatermarkOverlay + !needsSoftwareVideoOverlay ) { const hwUpload = new HardwareUploadCudaFilter(currentState); currentState = hwUpload.nextState(currentState); @@ -313,6 +316,7 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { } currentState = this.setWatermark(currentState); + currentState = this.applyNowPlayingOverlay(currentState); let encoder: Nullable = null; if (ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.Cuda) { diff --git a/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.ts index 185b852cc..6e154bc95 100644 --- a/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.ts @@ -6,6 +6,7 @@ import { Encoder } from '@/ffmpeg/builder/encoder/Encoder.js'; import { DeinterlaceFilter } from '@/ffmpeg/builder/filter/DeinterlaceFilter.js'; import type { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js'; import { PadFilter } from '@/ffmpeg/builder/filter/PadFilter.js'; +import { NowPlayingOverlayFilter } from '@/ffmpeg/builder/filter/NowPlayingOverlayFilter.js'; import { ScaleFilter } from '@/ffmpeg/builder/filter/ScaleFilter.js'; import { isHdrContent } from '@/ffmpeg/builder/filter/HdrDetection.js'; import { TonemapFilter } from '@/ffmpeg/builder/filter/TonemapFilter.js'; @@ -55,6 +56,7 @@ export class SoftwarePipelineBuilder extends BasePipelineBuilder { currentState = this.setPad(currentState); currentState = this.addSubtitles(currentState); currentState = this.setWatermark(currentState); + currentState = this.applyNowPlayingOverlay(currentState); } if (!this.hasVideoEncoderPipelineStep()) { @@ -175,6 +177,25 @@ export class SoftwarePipelineBuilder extends BasePipelineBuilder { return currentState; } + protected applyNowPlayingOverlay(currentState: FrameState): FrameState { + if (!isVideoPipelineContext(this.context)) { + return currentState; + } + + if (!this.context.nowPlayingOverlay) { + return currentState; + } + + this.context.filterChain.nowPlayingOverlayFilterSteps.push( + new NowPlayingOverlayFilter( + this.context.desiredState.paddedSize, + this.context.nowPlayingOverlay, + ), + ); + + return currentState.updateFrameLocation(FrameDataLocation.Software); + } + protected setPixelFormat(currentState: FrameState): FrameState { const steps: FilterOption[] = []; if (this.desiredState.pixelFormat) { diff --git a/server/src/ffmpeg/ffmpegBase.ts b/server/src/ffmpeg/ffmpegBase.ts index fa3f80ac7..894056d47 100644 --- a/server/src/ffmpeg/ffmpegBase.ts +++ b/server/src/ffmpeg/ffmpegBase.ts @@ -3,6 +3,7 @@ import type { ChannelStreamMode, Watermark } from '@tunarr/types'; import type { ChannelConcatStreamMode } from '@tunarr/types/schemas'; import type { Duration } from 'dayjs/plugin/duration.js'; import type { DeepReadonly, StrictExclude } from 'ts-essentials'; +import type { ChannelTranscodingSettings } from '../db/schema/base.ts'; import type { ContentBackedStreamLineupItem } from '../db/derived_types/StreamLineup.ts'; import type { StreamDetails, StreamSource } from '../stream/types.ts'; import type { OutputFormat } from './builder/constants.ts'; @@ -86,6 +87,7 @@ export type StreamOptions = { startTime: Duration; duration: Duration; watermark?: Watermark; + nowPlayingOverlay?: ChannelTranscodingSettings['nowPlayingOverlay']; realtime?: boolean; // = true, extraInputHeaders?: Record; outputFormat: OutputFormat; diff --git a/server/src/ffmpeg/ffmpegInfo.ts b/server/src/ffmpeg/ffmpegInfo.ts index 51f609276..57f239ada 100644 --- a/server/src/ffmpeg/ffmpegInfo.ts +++ b/server/src/ffmpeg/ffmpegInfo.ts @@ -35,6 +35,7 @@ const CacheKeys = { NVIDIA: 'nvidia', VAINFO: 'vainfo_%s_%s', FILTERS: 'filters', + PROBE: 'probe_%s', } as const; export type FfmpegVersionResult = { @@ -334,6 +335,14 @@ export class FfmpegInfo { return result.data; } + async probeFileWithCache(path: string, timeout?: number) { + return cacheGetOrSet( + FfmpegInfo.resultCache, + FfmpegInfo.makeCacheKey(this.ffprobePath, 'PROBE', path), + () => this.probeFile(path, timeout), + ); + } + private getFfmpegStdout( args: string[], opts: GetStdoutOptions = { swallowError: false }, diff --git a/server/src/stream/ProgramStream.ts b/server/src/stream/ProgramStream.ts index 1d2d87783..351ace29c 100644 --- a/server/src/stream/ProgramStream.ts +++ b/server/src/stream/ProgramStream.ts @@ -12,6 +12,7 @@ import dayjs from 'dayjs'; import { isUndefined } from 'lodash-es'; import events from 'node:events'; import { PassThrough } from 'node:stream'; +import type { ChannelTranscodingSettings } from '../db/schema/base.ts'; import type { FFmpegFactory } from '../ffmpeg/FFmpegModule.js'; import type { StreamOptions } from '../ffmpeg/ffmpegBase.ts'; import { @@ -207,4 +208,28 @@ export abstract class ProgramStream extends (events.EventEmitter as new () => Ty return; } + + protected getNowPlayingOverlay(): + | NonNullable + | undefined { + const channel = this.context.targetChannel; + + if (this.context.transcodeConfig.disableChannelOverlay) { + return; + } + + if ( + this.context.lineupItem.type === 'commercial' && + this.context.targetChannel.disableFillerOverlay + ) { + return; + } + + const overlay = channel.transcoding?.nowPlayingOverlay; + if (overlay?.enabled) { + return overlay; + } + + return; + } } diff --git a/server/src/stream/StreamProgramCalculator.test.ts b/server/src/stream/StreamProgramCalculator.test.ts index 4678d8fe0..c59cbd8e5 100644 --- a/server/src/stream/StreamProgramCalculator.test.ts +++ b/server/src/stream/StreamProgramCalculator.test.ts @@ -577,6 +577,233 @@ describe('StreamProgramCalculator', () => { }, ); + baseTest( + 'does not load next program metadata when coming up next is disabled', + async () => { + const fillerDB = mock(); + const channelDB = mock(); + const programDB = mock(); + const fillerPicker = mock(); + const playHistoryDB = mock(); + + const startTime = dayjs(new Date(2025, 8, 17, 8)); + const channelId = faker.string.uuid(); + const programId1 = faker.string.uuid(); + const programId2 = faker.string.uuid(); + + const lineup: LineupItem[] = [ + { + type: 'content', + durationMs: +dayjs.duration({ minutes: 22 }), + id: programId1, + }, + { + type: 'content', + durationMs: +dayjs.duration({ minutes: 22 }), + id: programId2, + }, + ]; + + when(programDB.getProgramById(programId1)).thenReturn( + Promise.resolve( + createFakeProgram({ + uuid: programId1, + duration: lineup[0].durationMs, + mediaSourceId: tag('mediasource-123'), + }), + ), + ); + + const channel = createChannelOrm({ + uuid: channelId, + number: 1, + startTime: +startTime.subtract(1, 'hour'), + duration: sumBy(lineup, ({ durationMs }) => durationMs), + transcoding: { + nowPlayingOverlay: { + enabled: true, + showForSeconds: 8, + comingUpNextForSeconds: 0, + }, + }, + }); + + when(channelDB.getChannelOrm(1)).thenReturn(Promise.resolve(channel)); + when(channelDB.loadLineup(channelId)).thenReturn( + Promise.resolve({ + version: 1, + items: lineup, + startTimeOffsets: calculateStartTimeOffsets(lineup), + lastUpdated: now(), + }), + ); + when( + playHistoryDB.isProgramCurrentlyPlaying( + anything(), + anything(), + anything(), + ), + ).thenReturn(Promise.resolve(false)); + when(playHistoryDB.create(anything())).thenReturn( + Promise.resolve(undefined), + ); + + const calc = new StreamProgramCalculator( + LoggerFactory.root, + instance(fillerDB), + instance(channelDB), + instance(programDB), + instance(fillerPicker), + instance(playHistoryDB), + ); + + await calc.getCurrentLineupItem({ + allowSkip: false, + channelId: 1, + startTime: +startTime, + }); + + verify(programDB.getProgramById(programId2)).never(); + }, + ); + + baseTest( + 'uses redirected lineup when resolving coming up next metadata', + async () => { + const fillerDB = mock(); + const channelDB = mock(); + const programDB = mock(); + const fillerPicker = mock(); + const playHistoryDB = mock(); + + const startTime = dayjs(new Date(2025, 8, 17, 8)); + const sourceChannelId = faker.string.uuid(); + const targetChannelId = faker.string.uuid(); + const targetProgram1 = faker.string.uuid(); + const targetProgram2 = faker.string.uuid(); + + const sourceLineupItems: LineupItem[] = [ + { + type: 'redirect', + durationMs: +dayjs.duration({ minutes: 44 }), + channel: targetChannelId, + }, + ]; + const targetLineupItems: LineupItem[] = [ + { + type: 'content', + durationMs: +dayjs.duration({ minutes: 22 }), + id: targetProgram1, + }, + { + type: 'content', + durationMs: +dayjs.duration({ minutes: 22 }), + id: targetProgram2, + }, + ]; + + const sourceChannel = createChannelOrm({ + uuid: sourceChannelId, + number: 1, + startTime: +startTime.subtract(1, 'hour'), + duration: sumBy(sourceLineupItems, ({ durationMs }) => durationMs), + }); + const targetChannel = createChannelOrm({ + uuid: targetChannelId, + number: 2, + startTime: +startTime.subtract(1, 'hour'), + duration: sumBy(targetLineupItems, ({ durationMs }) => durationMs), + transcoding: { + nowPlayingOverlay: { + enabled: true, + showForSeconds: 8, + comingUpNextForSeconds: 6, + }, + }, + }); + + when(channelDB.getChannelOrm(1)).thenReturn( + Promise.resolve(sourceChannel), + ); + when(channelDB.loadLineup(sourceChannelId)).thenReturn( + Promise.resolve({ + version: 1, + items: sourceLineupItems, + startTimeOffsets: calculateStartTimeOffsets(sourceLineupItems), + lastUpdated: now(), + }), + ); + when(channelDB.loadChannelAndLineupOrm(targetChannelId)).thenReturn( + Promise.resolve({ + channel: targetChannel, + lineup: { + version: 1, + items: targetLineupItems, + startTimeOffsets: calculateStartTimeOffsets(targetLineupItems), + lastUpdated: now(), + }, + }), + ); + + when(programDB.getProgramById(targetProgram1)).thenReturn( + Promise.resolve( + createFakeProgram({ + uuid: targetProgram1, + title: 'Current Redirected Song', + duration: targetLineupItems[0].durationMs, + mediaSourceId: tag('mediasource-123'), + }), + ), + ); + when(programDB.getProgramById(targetProgram2)).thenReturn( + Promise.resolve( + createFakeProgram({ + uuid: targetProgram2, + title: 'Next Redirected Song', + artistName: 'Next Artist', + duration: targetLineupItems[1].durationMs, + mediaSourceId: tag('mediasource-456'), + }), + ), + ); + + when( + playHistoryDB.isProgramCurrentlyPlaying( + anything(), + anything(), + anything(), + ), + ).thenReturn(Promise.resolve(false)); + when(playHistoryDB.create(anything())).thenReturn( + Promise.resolve(undefined), + ); + + const calc = new StreamProgramCalculator( + LoggerFactory.root, + instance(fillerDB), + instance(channelDB), + instance(programDB), + instance(fillerPicker), + instance(playHistoryDB), + ); + + const out = ( + await calc.getCurrentLineupItem({ + allowSkip: false, + channelId: 1, + startTime: +startTime, + }) + ).get(); + + expect(out.lineupItem).toMatchObject>({ + nextProgramMetadata: { + title: 'Next Redirected Song', + artist: 'Next Artist', + }, + }); + }, + ); + describe('calculateStreamDuration', () => { test('first channel cycle', () => { const lineupItems: LineupItem[] = [ diff --git a/server/src/stream/StreamProgramCalculator.ts b/server/src/stream/StreamProgramCalculator.ts index 6da8b6ccd..64a4abf08 100644 --- a/server/src/stream/StreamProgramCalculator.ts +++ b/server/src/stream/StreamProgramCalculator.ts @@ -9,7 +9,7 @@ import constants from '@tunarr/shared/constants'; import dayjs from 'dayjs'; import { inject, injectable } from 'inversify'; import { first, inRange, isEmpty, isNil, isNull, sumBy } from 'lodash-es'; -import { Lineup, LineupItem } from '../db/derived_types/Lineup.ts'; +import { isContentItem, Lineup, LineupItem } from '../db/derived_types/Lineup.ts'; import { CommercialStreamLineupItem, createOfflineStreamLineupItem, @@ -97,11 +97,11 @@ export class StreamProgramCalculator { ); } - const lineup = await this.channelDB.loadLineup(channel.uuid); + let activeLineup = await this.channelDB.loadLineup(channel.uuid); // Fix channel lineups if necessary if (channel.duration <= 0) { - const actualDuration = sumBy(lineup.items, (item) => item.durationMs); + const actualDuration = sumBy(activeLineup.items, (item) => item.durationMs); await this.channelDB.updateChannelDuration(channel.uuid, actualDuration); channel.duration = actualDuration; } @@ -114,7 +114,7 @@ export class StreamProgramCalculator { let currentProgram = await this.getCurrentProgramAndTimeElapsed( startTime, channel, - lineup, + activeLineup, ); // We cannot exceed this amount of time, since that is what was scheduled // on the channel. @@ -152,11 +152,12 @@ export class StreamProgramCalculator { } channelContext = newChannelAndLineup.channel; + activeLineup = newChannelAndLineup.lineup; currentProgram = await this.getCurrentProgramAndTimeElapsed( req.startTime, channelContext, - newChannelAndLineup.lineup, + activeLineup, ); const timeLeft = @@ -180,7 +181,7 @@ export class StreamProgramCalculator { if ( currentProgram.program.type === 'offline' && - lineup.items.length === 1 && + activeLineup.items.length === 1 && currentProgram.programIndex !== -1 ) { //there's only one program and it's offline. So really, the channel is @@ -280,6 +281,69 @@ export class StreamProgramCalculator { }; } + // Populate next program metadata for "coming up next" overlay. + // This is a lightweight DB lookup (one program by ID) and only + // runs when the next lineup item is a content item. + const shouldLoadNextProgramMetadata = + isNowPlayingOverlayEnabled(channelContext) && + (channelContext.transcoding?.nowPlayingOverlay?.comingUpNextForSeconds ?? + 0) > 0; + + this.logger.debug( + { + channelId: channelContext.uuid, + overlayEnabled: isNowPlayingOverlayEnabled(channelContext), + comingUpNextForSeconds: + channelContext.transcoding?.nowPlayingOverlay?.comingUpNextForSeconds ?? + 0, + shouldLoadNextProgramMetadata, + }, + 'Evaluated coming up next metadata load', + ); + + if ( + shouldLoadNextProgramMetadata && + lineupItem && + currentProgram.programIndex >= 0 && + activeLineup.items.length > 0 + ) { + const nextIndex = + (currentProgram.programIndex + 1) % activeLineup.items.length; + const nextItem = activeLineup.items[nextIndex]; + if (nextItem && isContentItem(nextItem)) { + try { + const nextProgram = await this.programDB.getProgramById( + nextItem.id, + ); + if (nextProgram) { + lineupItem.nextProgramMetadata = { + title: nextProgram.title ?? undefined, + artist: nextProgram.artistName ?? undefined, + album: nextProgram.albumName ?? undefined, + year: + nextProgram.year != null + ? String(nextProgram.year) + : undefined, + filePath: nextProgram.filePath ?? undefined, + }; + this.logger.debug( + { + channelId: channelContext.uuid, + nextProgramId: nextItem.id, + nextProgramMetadata: lineupItem.nextProgramMetadata, + }, + 'Loaded next program metadata for now playing overlay', + ); + } + } catch (err) { + this.logger.debug( + err, + 'Failed to load next program metadata for overlay', + ); + } + } + } + return Result.success({ lineupItem, channelContext, @@ -556,6 +620,10 @@ export class StreamProgramCalculator { } } +function isNowPlayingOverlayEnabled(channel: ChannelOrm): boolean { + return channel.transcoding?.nowPlayingOverlay?.enabled ?? false; +} + export function calculateStreamDuration( now: number, channelStartTime: number, diff --git a/server/src/stream/emby/EmbyProgramStream.ts b/server/src/stream/emby/EmbyProgramStream.ts index fd7d2d56b..bd7cb04fd 100644 --- a/server/src/stream/emby/EmbyProgramStream.ts +++ b/server/src/stream/emby/EmbyProgramStream.ts @@ -112,6 +112,7 @@ export class EmbyProgramStream extends ProgramStream { startTime: start, duration: dayjs.duration(lineupItem.streamDuration), watermark, + nowPlayingOverlay: this.getNowPlayingOverlay(), realtime: this.context.realtime, extraInputHeaders: {}, outputFormat: this.outputFormat, diff --git a/server/src/stream/hls/BaseHlsSession.ts b/server/src/stream/hls/BaseHlsSession.ts index 583adab43..0584df346 100644 --- a/server/src/stream/hls/BaseHlsSession.ts +++ b/server/src/stream/hls/BaseHlsSession.ts @@ -120,7 +120,7 @@ export abstract class BaseHlsSession< 'Cleaning up existing working directory: %s', this._workingDirectory, ); - await fs.rmdir(this._workingDirectory, { recursive: true }); + await fs.rm(this._workingDirectory, { recursive: true }); await fs.mkdir(this._workingDirectory); } catch (err) { return this.logger.error( diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index 9fcc0b515..064015a91 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -130,6 +130,7 @@ export class JellyfinProgramStream extends ProgramStream { startTime: start, duration: dayjs.duration(lineupItem.streamDuration), watermark, + nowPlayingOverlay: this.getNowPlayingOverlay(), realtime: this.context.realtime, extraInputHeaders: {}, outputFormat: this.outputFormat, diff --git a/server/src/stream/local/LocalProgramStream.ts b/server/src/stream/local/LocalProgramStream.ts index d1103552f..5d17b28e9 100644 --- a/server/src/stream/local/LocalProgramStream.ts +++ b/server/src/stream/local/LocalProgramStream.ts @@ -118,6 +118,7 @@ export class LocalProgramStream extends ProgramStream { startTime: start, duration: dayjs.duration(lineupItem.streamDuration), watermark: await this.getWatermark(), + nowPlayingOverlay: this.getNowPlayingOverlay(), realtime: this.context.realtime, extraInputHeaders: {}, outputFormat: this.outputFormat, diff --git a/server/src/stream/plex/PlexProgramStream.ts b/server/src/stream/plex/PlexProgramStream.ts index fd841bbe0..4a4651647 100644 --- a/server/src/stream/plex/PlexProgramStream.ts +++ b/server/src/stream/plex/PlexProgramStream.ts @@ -123,6 +123,7 @@ export class PlexProgramStream extends ProgramStream { startTime: start, duration: dayjs.duration(lineupItem.streamDuration), watermark, + nowPlayingOverlay: this.getNowPlayingOverlay(), realtime: this.context.realtime, outputFormat: this.outputFormat, streamMode: this.context.streamMode, diff --git a/server/src/testing/fakes/FakeChannelDB.ts b/server/src/testing/fakes/FakeChannelDB.ts index 6f77c6163..415200e50 100644 --- a/server/src/testing/fakes/FakeChannelDB.ts +++ b/server/src/testing/fakes/FakeChannelDB.ts @@ -205,6 +205,19 @@ export class FakeChannelDB implements IChannelDB { targetResolution?: { widthPx: number; heightPx: number } | undefined; videoBitrate?: number | undefined; videoBufferSize?: number | undefined; + nowPlayingOverlay?: + | { + enabled: boolean; + position?: 'bottom-left' | 'bottom-right' | undefined; + showForSeconds?: number | undefined; + showAtEndForSeconds?: number | undefined; + startPaddingSeconds?: number | undefined; + endPaddingSeconds?: number | undefined; + comingUpNextForSeconds?: number | undefined; + comingUpNextOffsetSeconds?: number | undefined; + fadeDurationSeconds?: number | undefined; + } + | undefined; } | null; transcodeConfigId: string; watermark: { diff --git a/server/src/types/ffmpeg.ts b/server/src/types/ffmpeg.ts index 996be4ea4..3084be19b 100644 --- a/server/src/types/ffmpeg.ts +++ b/server/src/types/ffmpeg.ts @@ -149,3 +149,5 @@ export const FfprobeMediaInfoSchema = z.object({ format: FfprobeMediaFormatSchema, chapters: z.array(FfprobeChapter).optional(), }); + +export type FfprobeMediaInfo = z.infer; diff --git a/types/src/schemas/channelSchema.ts b/types/src/schemas/channelSchema.ts index 3775547a5..9ecece877 100644 --- a/types/src/schemas/channelSchema.ts +++ b/types/src/schemas/channelSchema.ts @@ -55,6 +55,19 @@ export const ChannelTranscodingOptionsSchema = z.object({ targetResolution: ResolutionSchema.optional(), videoBitrate: z.number().optional(), videoBufferSize: z.number().optional(), + nowPlayingOverlay: z + .object({ + enabled: z.boolean().default(false).catch(false), + showForSeconds: z.number().positive().default(8).catch(8), + showAtEndForSeconds: z.number().nonnegative().default(0).catch(0), + startPaddingSeconds: z.number().nonnegative().default(0).catch(0), + endPaddingSeconds: z.number().nonnegative().default(0).catch(0), + comingUpNextForSeconds: z.number().nonnegative().default(0).catch(0), + comingUpNextOffsetSeconds: z.number().nonnegative().default(30).catch(30), + fadeDurationSeconds: z.number().nonnegative().default(0.5).catch(0.5), + }) + .optional() + .catch(undefined), }); export const HlsChannelStreamMode = 'hls'; @@ -155,6 +168,12 @@ export const SaveableChannelSchema = ChannelSchema.omit({ programCount: true, transcoding: true, sessions: true, +}).extend({ + transcoding: z + .object({ + nowPlayingOverlay: ChannelTranscodingOptionsSchema.shape.nowPlayingOverlay, + }) + .optional(), }).partial({ onDemand: true, }); diff --git a/web/src/components/channel_config/ChannelTranscodingConfig.tsx b/web/src/components/channel_config/ChannelTranscodingConfig.tsx index e8ba41c1e..476c5f14a 100644 --- a/web/src/components/channel_config/ChannelTranscodingConfig.tsx +++ b/web/src/components/channel_config/ChannelTranscodingConfig.tsx @@ -82,14 +82,31 @@ export default function ChannelTranscodingConfig() { subtitlesEnabled, fadePeriod, streamMode, + nowPlayingOverlayEnabled, + showAtEndForSeconds, + endPaddingSeconds, + comingUpNextForSeconds, + comingUpNextOffsetSeconds, ] = watch([ 'watermark', 'transcodeConfigId', 'subtitlesEnabled', 'watermark.fadeConfig.0.periodMins', 'streamMode', + 'transcoding.nowPlayingOverlay.enabled', + 'transcoding.nowPlayingOverlay.showAtEndForSeconds', + 'transcoding.nowPlayingOverlay.endPaddingSeconds', + 'transcoding.nowPlayingOverlay.comingUpNextForSeconds', + 'transcoding.nowPlayingOverlay.comingUpNextOffsetSeconds', ]); + const comingUpNextWouldOverlap = + (comingUpNextForSeconds ?? 0) > 0 && + (comingUpNextOffsetSeconds ?? 0) <= + (showAtEndForSeconds ?? 0) + + (endPaddingSeconds ?? 0) + + (comingUpNextForSeconds ?? 0); + const transcodeConfig = useMemo( () => find(transcodeConfigs.data, (conf) => conf.id === transcodeConfigId)!, [transcodeConfigId, transcodeConfigs.data], @@ -514,6 +531,149 @@ export default function ChannelTranscodingConfig() { )} + + Now Playing Overlay + + + } + label="Enable Now Playing Overlay" + /> + + Displays a lower-third overlay with the current program's + title, artist, album, and year. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Coming Up Next + + + + + + + + + {comingUpNextWouldOverlap && ( + + + The "coming up next" card will overlap with the closing + overlay. Increase the offset or reduce the end card / + padding durations. + + + )} + + + ) ); diff --git a/web/src/components/channel_config/EditChannelForm.tsx b/web/src/components/channel_config/EditChannelForm.tsx index 3f054e41f..d4d8c384f 100644 --- a/web/src/components/channel_config/EditChannelForm.tsx +++ b/web/src/components/channel_config/EditChannelForm.tsx @@ -53,6 +53,26 @@ function getDefaultFormValues(channel: Channel): DeepRequired { soundtrack: channel.offline.soundtrack ?? DefaultChannel.offline.soundtrack ?? '', }, + transcoding: { + nowPlayingOverlay: { + enabled: + channel.transcoding?.nowPlayingOverlay?.enabled ?? false, + showForSeconds: + channel.transcoding?.nowPlayingOverlay?.showForSeconds ?? 8, + showAtEndForSeconds: + channel.transcoding?.nowPlayingOverlay?.showAtEndForSeconds ?? 0, + startPaddingSeconds: + channel.transcoding?.nowPlayingOverlay?.startPaddingSeconds ?? 0, + endPaddingSeconds: + channel.transcoding?.nowPlayingOverlay?.endPaddingSeconds ?? 0, + comingUpNextForSeconds: + channel.transcoding?.nowPlayingOverlay?.comingUpNextForSeconds ?? 0, + comingUpNextOffsetSeconds: + channel.transcoding?.nowPlayingOverlay?.comingUpNextOffsetSeconds ?? 30, + fadeDurationSeconds: + channel.transcoding?.nowPlayingOverlay?.fadeDurationSeconds ?? 0.5, + }, + }, watermark: { ...(channel.watermark ?? {}), enabled: channel.watermark?.enabled ?? false, @@ -106,6 +126,7 @@ const EditChannelTabsProps: EditChannelTabProps[] = [ description: 'Streaming', fields: [ 'watermark', + 'transcoding', 'streamMode', 'subtitlesEnabled', 'subtitlePreferences', @@ -182,6 +203,10 @@ export function EditChannelForm({ priority: idx, })); + const shouldPersistNowPlayingOverlay = + !!channel.transcoding?.nowPlayingOverlay || + !!data.transcoding?.nowPlayingOverlay?.enabled; + const dataTransform = { ...data, // Transform this to milliseconds before we send it over @@ -200,6 +225,9 @@ export function EditChannelForm({ fadeConfig: isEmpty(fadeConfigs) ? undefined : fadeConfigs, } : undefined, + transcoding: shouldPersistNowPlayingOverlay + ? data.transcoding + : undefined, subtitlePreferences: preferences as NonEmptyArray, } satisfies SaveableChannel;