Merge remote-tracking branch 'origin/dev' into media-scanner

This commit is contained in:
Christian Benincasa
2025-10-18 10:51:33 -04:00
17 changed files with 1647 additions and 2024 deletions

View File

@@ -158,6 +158,47 @@
* allow selecting parent Jellyfin items in list view ([9756e35](https://github.com/chrisbenincasa/tunarr/commit/9756e35008e36b0acb95f726bebd04f6ed706c78))
* **streaming:** change how QSV is initialized on Windows ([1dbec53](https://github.com/chrisbenincasa/tunarr/commit/1dbec5357bed545869a685b02bfa5440a3649243))
## [0.22.9](https://github.com/chrisbenincasa/tunarr/compare/v0.22.8...v0.22.9) (2025-10-18)
### Bug Fixes
* **streaming:** potential fix for QSV audio sync issues ([f5a96ae](https://github.com/chrisbenincasa/tunarr/commit/f5a96aef831b7212d5331aa24abdad885cac27c8))
## [0.22.8](https://github.com/chrisbenincasa/tunarr/compare/v0.22.7...v0.22.8) (2025-10-15)
### Bug Fixes
* do not let stream cache hard fail streams ([e5e5b15](https://github.com/chrisbenincasa/tunarr/commit/e5e5b1510660149ebd6404ba6bc87ad6b500403a))
## [0.22.7](https://github.com/chrisbenincasa/tunarr/compare/v0.22.6...v0.22.7) (2025-10-15)
### Bug Fixes
* ensure program summaries are escaped for xml ([9f3cad5](https://github.com/chrisbenincasa/tunarr/commit/9f3cad52c959864a7c4863ea1167f2ebb852fb42))
* handle some weird cases of expected exit in ffmpeg ([8106d34](https://github.com/chrisbenincasa/tunarr/commit/8106d3491affef35a95ca93e210a15d4de71f74f))
* **streaming:** apply sc_threshold after hw accel is decided ([1037ca3](https://github.com/chrisbenincasa/tunarr/commit/1037ca30ed30a30c330d447815799546582ac12b))
* **streaming:** fix audio only streams for vaapi ([c0691cc](https://github.com/chrisbenincasa/tunarr/commit/c0691ccb03dd8f5ef3fa7b4cf3c83a14d304d965)), closes [#1365](https://github.com/chrisbenincasa/tunarr/issues/1365)
## [0.22.6](https://github.com/chrisbenincasa/tunarr/compare/v0.22.5...v0.22.6) (2025-10-08)
### Bug Fixes
* add subtitle and description to xmltv ([f056790](https://github.com/chrisbenincasa/tunarr/commit/f0567902d2bb89963c8040ea3712bec72384347b))
* **streaming:** convert to proper pixel format before cuda upload + scale ([091e7bd](https://github.com/chrisbenincasa/tunarr/commit/091e7bd290bcc25114db6b789db9b86decebbd0d))
* **streaming:** do not set sc_threshold to 0 for mpeg2video out ([949efda](https://github.com/chrisbenincasa/tunarr/commit/949efda0ff028a0888c3aa52e294e9ae11a6a49f))
* **streaming:** properly pass disable hw decode/encode/filter to pipeline ([70b3757](https://github.com/chrisbenincasa/tunarr/commit/70b37577fd13c2a322a1cdac81e2639a6550f225))
* **streaming:** use bitstream filter in CUDA pipeline to workaround green line ([ff61f62](https://github.com/chrisbenincasa/tunarr/commit/ff61f62286e49245bc86f21f2252feabd614bcf1)), closes [#1390](https://github.com/chrisbenincasa/tunarr/issues/1390)
* **ui:** allow viewing stream details of custom / filler programs ([af87a17](https://github.com/chrisbenincasa/tunarr/commit/af87a17b43bdc4d567a1f130d784f0c25cea5f36))
### UI Changes
* add season/episode to Tunarr guide page ([eeb3f6d](https://github.com/chrisbenincasa/tunarr/commit/eeb3f6dcec9cc4bcc0c3179a2eabc991ce14b3c0)), closes [#1398](https://github.com/chrisbenincasa/tunarr/issues/1398)
## [0.22.5](https://github.com/chrisbenincasa/tunarr/compare/v0.22.4...v0.22.5) (2025-09-23)

View File

@@ -15,7 +15,6 @@
"preinstall": "npx only-allow pnpm"
},
"devDependencies": {
"@changesets/cli": "^2.27.7",
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"@commitlint/types": "^19.0.3",

3035
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"async-retry": "^1.3.3",
"axios": "^1.11.0",
"axios": ">=1.12.0",
"base32": "^0.0.7",
"better-sqlite3": "11.8.1",
"blurhash": "^2.0.5",
@@ -54,7 +54,7 @@
"dayjs": "^1.11.14",
"drizzle-orm": "^0.39.3",
"fast-xml-parser": "^4.5.3",
"fastify": "^5.5.0",
"fastify": "^5.6.1",
"fastify-graceful-shutdown": "^4.0.1",
"fastify-plugin": "^5.0.1",
"fastify-print-routes": "^3.2.0",

View File

@@ -113,7 +113,7 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
this.#processHandle.stderr.pipe(bufferedOut);
if (this.#processKilled) {
this.#logger.trace('Sending SIGKILL to ffmpeg');
this.#logger.debug('Sending SIGKILL to ffmpeg');
this.#processHandle.kill('SIGKILL');
return;
}
@@ -132,7 +132,10 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
const expected =
code === 0 ||
(this.#processKilled &&
(code === null || signal === 'SIGTERM' || signal === 'SIGKILL')) ||
(code === null ||
code === 255 ||
signal === 'SIGTERM' ||
signal === 'SIGKILL')) ||
(this.#processKilled && isWindows() && code === 1);
this.#logger.info(
@@ -177,7 +180,7 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
);
});
if (code === 255) {
if (code === 255 && !this.#processKilled) {
if (this.#processHandle) {
return;
}

View File

@@ -534,7 +534,7 @@ export class FfmpegStreamFactory extends IFFMPEG {
paddedSize, // TODO
videoBitrate: playbackParams.videoBitrate,
videoBufferSize: playbackParams.videoBufferSize,
pixelFormat: playbackParams.pixelFormat, //match(), TODO: Make this customizable...
pixelFormat: playbackParams.pixelFormat ?? new PixelFormatYuv420P(), //match(), TODO: Make this customizable...
bitDepth: 8, // TODO: Make this customizable
frameRate: playbackParams.frameRate,
videoTrackTimescale: playbackParams.videoTrackTimeScale,

View File

@@ -0,0 +1,5 @@
import { FilterOption } from './FilterOption.ts';
export class ResetPtsFilter extends FilterOption {
public filter: string = 'setpts=PTS-STARTPTS';
}

View File

@@ -20,6 +20,20 @@ export class AudioInputSource<
) {
super(source, continuity);
}
static withStream<StreamType extends AudioStream = AudioStream>(
source: StreamSource,
audioStream: StreamType,
desiredState: AudioState,
continuity: InputSourceContinuity = 'discrete',
): AudioInputSource<StreamType> {
return new AudioInputSource(
source,
[audioStream],
desiredState,
continuity,
);
}
}
export class NullAudioInputSource extends AudioInputSource {

View File

@@ -371,7 +371,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
this.pipelineSteps.push(NoAutoScaleOutputOption());
}
this.setSceneDetect();
this.setStreamSeek();
if (this.ffmpegState.duration && +this.ffmpegState.duration > 0) {
@@ -411,6 +410,8 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
this.buildVideoPipeline();
}
this.setSceneDetect();
if (isNull(this.audioInputSource)) {
this.context.pipelineSteps.push(new CopyAudioEncoder());
} else if (this.audioInputSource.streams.length > 0) {
@@ -707,7 +708,9 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
HardwareAccelerationMode.Videotoolbox
) {
this.pipelineSteps.push(NoSceneDetectOutputOption(1_000_000_000));
} else {
} else if (
this.ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.None
) {
this.pipelineSteps.push(NoSceneDetectOutputOption(0));
}
}

View File

@@ -0,0 +1,355 @@
import { FileStreamSource } from '../../../../stream/types.ts';
import { FfmpegCapabilities } from '../../capabilities/FfmpegCapabilities.ts';
import {
VaapiEntrypoint,
VaapiHardwareCapabilities,
VaapiProfileEntrypoint,
VaapiProfiles,
} from '../../capabilities/VaapiHardwareCapabilities.ts';
import { PixelFormatYuv420P } from '../../format/PixelFormat.ts';
import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts';
import { VideoInputSource } from '../../input/VideoInputSource.ts';
import { WatermarkInputSource } from '../../input/WatermarkInputSource.ts';
import {
EmbeddedSubtitleStream,
StillImageStream,
SubtitleMethods,
VideoStream,
} from '../../MediaStream.ts';
import {
DefaultPipelineOptions,
FfmpegState,
} from '../../state/FfmpegState.ts';
import { FrameState } from '../../state/FrameState.ts';
import { FrameSize } from '../../types.ts';
import { QsvPipelineBuilder } from './QsvPipelineBuilder.ts';
describe('QsvPipelineBuilder', () => {
test('should work', () => {
const capabilities = new VaapiHardwareCapabilities([]);
const binaryCapabilities = new FfmpegCapabilities(
new Set(),
new Map(),
new Set(),
);
const video = VideoInputSource.withStream(
new FileStreamSource('/path/to/video.mkv'),
VideoStream.create({
codec: 'h264',
displayAspectRatio: '16:9',
frameSize: FrameSize.withDimensions(1920, 900),
index: 0,
pixelFormat: new PixelFormatYuv420P(),
sampleAspectRatio: null,
}),
);
const watermark = new WatermarkInputSource(
new FileStreamSource('/path/to/watermark.jpg'),
StillImageStream.create({
frameSize: FrameSize.withDimensions(800, 600),
index: 0,
}),
{
duration: 5,
enabled: true,
horizontalMargin: 5,
opacity: 100,
position: 'bottom-right',
verticalMargin: 5,
width: 10,
},
);
const builder = new QsvPipelineBuilder(
capabilities,
binaryCapabilities,
video,
null,
null,
watermark,
new SubtitlesInputSource(
new FileStreamSource('/path/to/video.mkv'),
[new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)],
SubtitleMethods.Burn,
),
);
const state = FfmpegState.create({
version: {
versionString: 'n7.0.2-15-g0458a86656-20240904',
majorVersion: 7,
minorVersion: 0,
patchVersion: 2,
isUnknown: false,
},
// start: +dayjs.duration(0),
});
const out = builder.build(
state,
new FrameState({
isAnamorphic: false,
scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD),
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
}),
DefaultPipelineOptions,
);
console.log(out.getCommandArgs().join(' '));
});
test('should work, decoding disabled', () => {
const capabilities = new VaapiHardwareCapabilities([
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Decode,
),
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Encode,
),
]);
const binaryCapabilities = new FfmpegCapabilities(
new Set(),
new Map(),
new Set(),
);
const video = VideoInputSource.withStream(
new FileStreamSource('/path/to/video.mkv'),
VideoStream.create({
codec: 'h264',
displayAspectRatio: '16:9',
frameSize: FrameSize.withDimensions(1920, 900),
index: 0,
pixelFormat: new PixelFormatYuv420P(),
sampleAspectRatio: null,
}),
);
const watermark = new WatermarkInputSource(
new FileStreamSource('/path/to/watermark.jpg'),
StillImageStream.create({
frameSize: FrameSize.withDimensions(800, 600),
index: 0,
}),
{
duration: 5,
enabled: true,
horizontalMargin: 5,
opacity: 100,
position: 'bottom-right',
verticalMargin: 5,
width: 10,
},
);
const builder = new QsvPipelineBuilder(
capabilities,
binaryCapabilities,
video,
null,
null,
watermark,
new SubtitlesInputSource(
new FileStreamSource('/path/to/video.mkv'),
[new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)],
SubtitleMethods.Burn,
),
);
const state = FfmpegState.create({
version: {
versionString: 'n7.0.2-15-g0458a86656-20240904',
majorVersion: 7,
minorVersion: 0,
patchVersion: 2,
isUnknown: false,
},
// start: +dayjs.duration(0),
});
const out = builder.build(
state,
new FrameState({
isAnamorphic: false,
scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD),
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
videoFormat: 'h264',
}),
{ ...DefaultPipelineOptions, disableHardwareDecoding: true },
);
console.log(out.getCommandArgs().join(' '));
});
test('should work, encoding disabled', () => {
const capabilities = new VaapiHardwareCapabilities([
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Decode,
),
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Encode,
),
]);
const binaryCapabilities = new FfmpegCapabilities(
new Set(),
new Map(),
new Set(),
);
const video = VideoInputSource.withStream(
new FileStreamSource('/path/to/video.mkv'),
VideoStream.create({
codec: 'h264',
profile: 'main',
displayAspectRatio: '16:9',
frameSize: FrameSize.withDimensions(1920, 900),
index: 0,
pixelFormat: new PixelFormatYuv420P(),
sampleAspectRatio: null,
}),
);
const watermark = new WatermarkInputSource(
new FileStreamSource('/path/to/watermark.jpg'),
StillImageStream.create({
frameSize: FrameSize.withDimensions(800, 600),
index: 0,
}),
{
duration: 0,
enabled: true,
horizontalMargin: 5,
opacity: 100,
position: 'bottom-right',
verticalMargin: 5,
width: 10,
},
);
const builder = new QsvPipelineBuilder(
capabilities,
binaryCapabilities,
video,
null,
null,
watermark,
null,
);
const state = FfmpegState.create({
version: {
versionString: 'n7.0.2-15-g0458a86656-20240904',
majorVersion: 7,
minorVersion: 0,
patchVersion: 2,
isUnknown: false,
},
// start: +dayjs.duration(0),
});
const out = builder.build(
state,
new FrameState({
isAnamorphic: false,
scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD),
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
videoFormat: 'h264',
}),
{ ...DefaultPipelineOptions, disableHardwareEncoding: true },
);
console.log(out.getCommandArgs().join(' '));
});
test('should work, filters disabled', () => {
const capabilities = new VaapiHardwareCapabilities([
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Decode,
),
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Encode,
),
]);
const binaryCapabilities = new FfmpegCapabilities(
new Set(),
new Map(),
new Set(),
);
const video = VideoInputSource.withStream(
new FileStreamSource('/path/to/video.mkv'),
VideoStream.create({
codec: 'h264',
profile: 'main',
displayAspectRatio: '16:9',
frameSize: FrameSize.withDimensions(1920, 900),
index: 0,
pixelFormat: new PixelFormatYuv420P(),
sampleAspectRatio: null,
}),
);
const watermark = new WatermarkInputSource(
new FileStreamSource('/path/to/watermark.jpg'),
StillImageStream.create({
frameSize: FrameSize.withDimensions(800, 600),
index: 0,
}),
{
duration: 5,
enabled: true,
horizontalMargin: 5,
opacity: 100,
position: 'bottom-right',
verticalMargin: 5,
width: 10,
},
);
const builder = new QsvPipelineBuilder(
capabilities,
binaryCapabilities,
video,
null,
null,
watermark,
new SubtitlesInputSource(
new FileStreamSource('/path/to/video.mkv'),
[new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)],
SubtitleMethods.Burn,
),
);
const state = FfmpegState.create({
version: {
versionString: 'n7.0.2-15-g0458a86656-20240904',
majorVersion: 7,
minorVersion: 0,
patchVersion: 2,
isUnknown: false,
},
// start: +dayjs.duration(0),
});
const out = builder.build(
state,
new FrameState({
isAnamorphic: false,
scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD),
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
videoFormat: 'h264',
}),
{ ...DefaultPipelineOptions, disableHardwareFilters: true },
);
console.log(out.getCommandArgs().join(' '));
});
});

View File

@@ -38,12 +38,12 @@ import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
import { FrameDataLocation } from '@/ffmpeg/builder/types.js';
import type { Nullable } from '@/types/util.js';
import { isDefined, isNonEmptyString } from '@/util/index.js';
import dayjs from 'dayjs';
import { every, head, inRange, isNull, some } from 'lodash-es';
import { H264QsvEncoder } from '../../encoder/qsv/H264QsvEncoder.ts';
import { HevcQsvEncoder } from '../../encoder/qsv/HevcQsvEncoder.ts';
import { Mpeg2QsvEncoder } from '../../encoder/qsv/Mpeg2QsvEncoder.ts';
import { ImageScaleFilter } from '../../filter/ImageScaleFilter.ts';
import { ResetPtsFilter } from '../../filter/ResetPtsFilter.ts';
import { SubtitleFilter } from '../../filter/SubtitleFilter.ts';
import { SubtitleOverlayFilter } from '../../filter/SubtitleOverlayFilter.ts';
import type { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts';
@@ -99,11 +99,6 @@ export class QsvPipelineBuilder extends SoftwarePipelineBuilder {
canDecode = false;
}
// Use software decode when seeking with QSV to prevent some sync issues.
if (canDecode && +(this.ffmpegState.start ?? dayjs.duration(0)) > 0) {
canDecode = false;
}
this.ffmpegState.decoderHwAccelMode = canDecode
? HardwareAccelerationMode.Qsv
: HardwareAccelerationMode.None;
@@ -166,6 +161,11 @@ export class QsvPipelineBuilder extends SoftwarePipelineBuilder {
currentState = this.decoder.nextState(currentState);
}
currentState = this.addFilterToVideoChain(
currentState,
new ResetPtsFilter(),
);
currentState = this.setDeinterlace(currentState);
currentState = this.setScale(currentState);
currentState = this.setPad(currentState);

View File

@@ -6,16 +6,22 @@ import {
VaapiProfileEntrypoint,
VaapiProfiles,
} from '../../capabilities/VaapiHardwareCapabilities.ts';
import { PixelFormatYuv420P } from '../../format/PixelFormat.ts';
import {
PixelFormatRgba,
PixelFormatYuv420P,
} from '../../format/PixelFormat.ts';
import { AudioInputSource } from '../../input/AudioInputSource.ts';
import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts';
import { VideoInputSource } from '../../input/VideoInputSource.ts';
import { WatermarkInputSource } from '../../input/WatermarkInputSource.ts';
import {
AudioStream,
EmbeddedSubtitleStream,
StillImageStream,
SubtitleMethods,
VideoStream,
} from '../../MediaStream.ts';
import { AudioState } from '../../state/AudioState.ts';
import {
DefaultPipelineOptions,
FfmpegState,
@@ -357,4 +363,80 @@ describe('VaapiPipelineBuilder', () => {
console.log(out.getCommandArgs().join(' '));
});
test('basic audio-only stream', () => {
const capabilities = new VaapiHardwareCapabilities([
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Decode,
),
new VaapiProfileEntrypoint(
VaapiProfiles.H264Main,
VaapiEntrypoint.Encode,
),
]);
const binaryCapabilities = new FfmpegCapabilities(
new Set(),
new Map(),
new Set(),
);
const video = VideoInputSource.withStream(
new FileStreamSource('/path/to/image.png'),
StillImageStream.create({
frameSize: FrameSize.withDimensions(800, 600),
index: 0,
pixelFormat: new PixelFormatRgba(),
}),
);
const audio = AudioInputSource.withStream(
new FileStreamSource('/path/to/song.flac'),
AudioStream.create({
channels: 2,
codec: 'flac',
index: 0,
}),
AudioState.create({
audioBitrate: 192,
audioBufferSize: 192 * 2,
audioChannels: 2,
}),
);
const builder = new VaapiPipelineBuilder(
capabilities,
binaryCapabilities,
video,
audio,
null,
null,
null,
);
const state = FfmpegState.create({
version: {
versionString: 'n7.0.2-15-g0458a86656-20240904',
majorVersion: 7,
minorVersion: 0,
patchVersion: 2,
isUnknown: false,
},
// start: +dayjs.duration(0),
});
const out = builder.build(
state,
new FrameState({
isAnamorphic: false,
scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD),
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
videoFormat: 'h264',
}),
{ ...DefaultPipelineOptions, disableHardwareFilters: true },
);
console.log(out.getCommandArgs().join(' '));
});
});

View File

@@ -285,6 +285,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
}
}
let needsVaapiSetFormat = true;
if (currentState.pixelFormat?.name !== pixelFormat.name) {
// Pixel formats
if (
@@ -296,15 +297,14 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
if (currentState.frameDataLocation === FrameDataLocation.Hardware) {
steps.push(new VaapiFormatFilter(pixelFormat));
needsVaapiSetFormat = false;
} else if (
this.ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.Vaapi
) {
steps.push(new PixelFormatFilter(pixelFormat));
needsVaapiSetFormat = false;
} else {
if (
this.ffmpegState.encoderHwAccelMode ===
HardwareAccelerationMode.Vaapi
) {
steps.push(new PixelFormatFilter(pixelFormat));
} else {
this.pipelineSteps.push(new PixelFormatOutputOption(pixelFormat));
}
this.pipelineSteps.push(new PixelFormatOutputOption(pixelFormat));
}
}
@@ -313,14 +313,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
HardwareAccelerationMode.Vaapi &&
currentState.frameDataLocation === FrameDataLocation.Software
) {
// Figure this out... it consistently sets false and doesn't work
// const setFormat = every(
// steps,
// (step) =>
// !(step instanceof VaapiFormatFilter) &&
// !(step instanceof PixelFormatFilter),
// );
steps.push(new HardwareUploadVaapiFilter(true, 64));
steps.push(new HardwareUploadVaapiFilter(needsVaapiSetFormat, 64));
}
}

View File

@@ -9,13 +9,13 @@ import {
type XmltvChannel,
type XmltvProgramme,
} from '@iptv/xmltv';
import { TvGuideProgram, isContentProgram } from '@tunarr/types';
import { isContentProgram, TvGuideProgram } from '@tunarr/types';
import { Mutex } from 'async-mutex';
import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
import { escape, flatMap, isNil, map, round } from 'lodash-es';
import { writeFile } from 'node:fs/promises';
import { match } from 'ts-pattern';
import { match, P } from 'ts-pattern';
const lock = new Mutex();
@@ -121,6 +121,17 @@ export class XmlTvWriter {
.with({ type: 'redirect' }, (c) => `Redirect to Channel ${c.channel}`)
.exhaustive();
const subTitle = match(program)
.with(
{ type: 'content', subtype: P.union('track', 'episode') },
(p) => p.title,
)
.with(
{ type: 'custom', program: { subtype: P.union('track', 'episode') } },
(p) => p.program?.title,
)
.otherwise(() => undefined);
const partial: XmltvProgramme = {
start: new Date(program.start),
stop: new Date(program.stop),
@@ -129,6 +140,19 @@ export class XmlTvWriter {
channel: xmlChannelId,
};
if (subTitle) {
partial.subTitle = [{ _value: escape(subTitle) }];
}
if (program.type === 'content' && isNonEmptyString(program.summary)) {
partial.desc = [{ _value: escape(program.summary) }];
} else if (
program.type === 'custom' &&
isNonEmptyString(program.program?.summary)
) {
partial.desc = [{ _value: escape(program.program.summary) }];
}
if (isContentProgram(program)) {
// TODO: Use grouping mappings here.
if (program.subtype !== 'movie' && title !== program.title) {

View File

@@ -12,6 +12,7 @@ import {
} from '../db/derived_types/StreamLineup.ts';
import { IStreamLineupCache } from '../interfaces/IStreamLineupCache.ts';
import { KEYS } from '../types/inject.ts';
import { Logger } from '../util/logging/LoggerFactory.ts';
const channelCacheSchema = z.object({
fillerPlayTimeCache: z.record(z.string(), z.number()).default({}),
@@ -75,8 +76,15 @@ export class ChannelCache implements IStreamLineupCache {
constructor(
@inject(PersistentChannelCache)
private persistentChannelCache: PersistentChannelCache,
@inject(KEYS.Logger) private logger: Logger,
) {}
getCurrentLineupItem(): StreamLineupItem | undefined {
// TODO: Remove this entirely. Just return undefined for now since this is essentially
// useless.
return;
}
private getKey(channelId: string, programId: string) {
return `${channelId}|${programId}`;
}
@@ -127,7 +135,24 @@ export class ChannelCache implements IStreamLineupCache {
t0: number,
lineupItem: StreamLineupItem,
) {
await this.recordProgramPlayTime(channelId, lineupItem, t0);
try {
await this.recordProgramPlayTime(channelId, lineupItem, t0);
// await this.persistentChannelCache.setStreamPlayItem(channelId, {
// timestamp: t0,
// lineupItem: lineupItem,
// });
} catch (e) {
this.logger.warn(
e,
'Error while setting stream cache for lineup item: %O at %d',
lineupItem,
t0,
);
}
}
async clearPlayback() {
// return await this.persistentChannelCache.clearStreamPlayItem(channelId);
}
// Is this necessary??

View File

@@ -31,7 +31,7 @@
"@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*",
"@uidotdev/usehooks": "^2.4.1",
"axios": "^1.11.0",
"axios": ">=1.12.0",
"bowser": "^2.11.0",
"color": "^5.0.0",
"colorjs.io": "^0.5.2",
@@ -62,7 +62,6 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "0.80.16",
"@hey-api/vite-plugin": "^0.2.0",
"@tanstack/react-table": "8.19.3",
"@tanstack/router-cli": "^1.35.4",
"@tanstack/router-devtools": "^1.36.0",

View File

@@ -186,6 +186,7 @@ export function TvGuide({ channelId, start, end, showStealth = true }: Props) {
index: number,
lineup: TvGuideProgram[],
) => {
console.log(program);
const title = match(program)
.with(
{ type: 'content', grandparent: { title: P.nonNullable } },
@@ -209,15 +210,25 @@ export function TvGuide({ channelId, start, end, showStealth = true }: Props) {
',',
),
)
.with({ type: 'content', subtype: 'episode' }, (p) => {
const epTitle = p.title;
console.log(p);
if (isUndefined(p.parent?.index) || isUndefined(p.index)) {
return epTitle;
}
const season = p.parent.index.toString().padStart(2, '0');
const epIndex = p.index.toString().padStart(2, '0');
return `S${season}E${epIndex} - ${epTitle}`;
})
.with({ type: 'content', subtype: 'movie' }, (p) =>
compact([p.date ? dayjs(p.date).year() : null]).join(','),
)
.with({ type: 'content' }, (p) => p.title)
.with(
{ type: 'custom', program: P.nonNullable },
({ program }) => program.title,
)
.with({ type: 'custom' }, () => '')
.with({ type: 'content', subtype: 'movie' }, (p) =>
compact([p.date ? dayjs(p.date).year() : null]).join(','),
)
.with({ type: 'content' }, (p) => p.title)
.otherwise(() => '');
const key = `${title}_${program.start}_${program.stop}`;