mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
Merge remote-tracking branch 'origin/dev' into media-scanner
This commit is contained in:
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
3035
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
server/src/ffmpeg/builder/filter/ResetPtsFilter.ts
Normal file
5
server/src/ffmpeg/builder/filter/ResetPtsFilter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { FilterOption } from './FilterOption.ts';
|
||||
|
||||
export class ResetPtsFilter extends FilterOption {
|
||||
public filter: string = 'setpts=PTS-STARTPTS';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(' '));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(' '));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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??
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user