From 6f5ffcb1f871c07b0e4e3c264dd699ee85e77d19 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 29 Jul 2024 18:06:05 -0700 Subject: [PATCH 1/7] Add: workflow to close issues on release --- .github/workflows/close-issues-on-release.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/close-issues-on-release.yml diff --git a/.github/workflows/close-issues-on-release.yml b/.github/workflows/close-issues-on-release.yml new file mode 100644 index 0000000000..679d43f4c8 --- /dev/null +++ b/.github/workflows/close-issues-on-release.yml @@ -0,0 +1,20 @@ +name: Close fixed issues on release. +on: + release: + types: [published] + +permissions: + contents: read + issues: write + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - name: Close issues marked as fixed upon a release. + uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5 + with: + label: awaiting-release + removeLabel: true + applyToAll: true + message: Fixed in [${releaseTag}](${releaseUrl}). From 73e4293f042d65e40f6298afb324eede2b1ffd40 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 29 Jul 2024 18:08:40 -0700 Subject: [PATCH 2/7] Fix: label has space in name --- .github/workflows/close-issues-on-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-issues-on-release.yml b/.github/workflows/close-issues-on-release.yml index 679d43f4c8..9c59075891 100644 --- a/.github/workflows/close-issues-on-release.yml +++ b/.github/workflows/close-issues-on-release.yml @@ -14,7 +14,7 @@ jobs: - name: Close issues marked as fixed upon a release. uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5 with: - label: awaiting-release + label: 'awaiting release' removeLabel: true applyToAll: true message: Fixed in [${releaseTag}](${releaseUrl}). From 49054d5239380c36027b5f09375acfd474656afb Mon Sep 17 00:00:00 2001 From: Shaun Date: Wed, 31 Jul 2024 16:44:24 +1000 Subject: [PATCH 3/7] persist the advanced encoding options, show the encoding options used with in progress encodes --- client/pages/audiobook/_id/manage.vue | 32 ++++++++++++++++++++++----- server/managers/AbMergeManager.js | 3 ++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 464d7ce2e7..6be07349be 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -78,7 +78,7 @@
@@ -92,11 +92,11 @@
-
+
- - - + + +

Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.

@@ -308,12 +308,24 @@ export default { }, isMetadataEmbedQueued() { return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId) + }, + usingCustomEncodeOptions() { + return this.isM4BTool && this.encodeTask && this.encodeTask.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0 } }, methods: { toggleBackupAudioFiles(val) { localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0) }, + bitrateChanged(val) { + localStorage.setItem('embedMetadataBitrate', val) + }, + channelsChanged(val) { + localStorage.setItem('embedMetadataChannels', val) + }, + codecChanged(val) { + localStorage.setItem('embedMetadataCodec', val) + }, cancelEncodeClick() { this.isCancelingEncode = true this.$axios @@ -398,6 +410,16 @@ export default { const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup') this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0 + + if (this.usingCustomEncodeOptions) { + if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate + if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels + if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec + } else { + this.encodingOptions.bitrate = localStorage.getItem('embedMetadataBitrate') || '128k' + this.encodingOptions.channels = localStorage.getItem('embedMetadataChannels') || '2' + this.encodingOptions.codec = localStorage.getItem('embedMetadataCodec') || 'aac' + } }, fetchMetadataEmbedObject() { this.$axios diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 61c1d500e1..5d3fea8fb9 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -48,7 +48,8 @@ class AbMergeManager { chapters: libraryItem.media.chapters?.map((c) => ({ ...c })), coverPath: libraryItem.media.coverPath, ffmetadataPath, - duration: libraryItem.media.duration + duration: libraryItem.media.duration, + encodeOptions: options } const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.` task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData) From 9eb0ec76fe4c017a73d7dae95d1ecad235e8b546 Mon Sep 17 00:00:00 2001 From: ic1415 <63030270+ic1415@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:48:41 -0400 Subject: [PATCH 4/7] Update LibraryItemController.js update library item controller to log downloaded ebooks to fix #3215 --- server/controllers/LibraryItemController.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5442097886..60d41b23b0 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -738,6 +738,8 @@ class LibraryItemController { return res.sendStatus(404) } const ebookFilePath = ebookFile.metadata.path + + Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${ebookFile.metadata.title}" at "${ebookFilePath}"`) if (global.XAccel) { const encodedURI = encodeUriPath(global.XAccel + ebookFilePath) From 1e6dd0e3e00d364e6fe2d3d1adb125faea5b01ea Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 31 Jul 2024 17:32:51 -0500 Subject: [PATCH 5/7] Add jsdocs for Ffmpeg and tools controller --- server/controllers/ToolsController.js | 52 ++- server/libs/fluentFfmpeg/index.d.ts | 498 ++++++++++++++++++++++++++ server/managers/AbMergeManager.js | 38 +- server/objects/Stream.js | 63 +--- server/routers/ApiRouter.js | 2 + server/utils/ffmpegHelpers.js | 9 +- 6 files changed, 607 insertions(+), 55 deletions(-) create mode 100644 server/libs/fluentFfmpeg/index.d.ts diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index 3f81d116a7..102dd030d5 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -2,9 +2,17 @@ const Logger = require('../Logger') const Database = require('../Database') class ToolsController { - constructor() { } - - // POST: api/tools/item/:id/encode-m4b + constructor() {} + + /** + * POST: /api/tools/item/:id/encode-m4b + * Start an audiobook merge to m4b task + * + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async encodeM4b(req, res) { if (req.libraryItem.isMissing || req.libraryItem.isInvalid) { Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`) @@ -27,7 +35,15 @@ class ToolsController { res.sendStatus(200) } - // DELETE: api/tools/item/:id/encode-m4b + /** + * DELETE: /api/tools/item/:id/encode-m4b + * Cancel a running m4b merge task + * + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async cancelM4bEncode(req, res) { const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id) if (!workerTask) return res.sendStatus(404) @@ -37,7 +53,15 @@ class ToolsController { res.sendStatus(200) } - // POST: api/tools/item/:id/embed-metadata + /** + * POST: /api/tools/item/:id/embed-metadata + * Start audiobook embed task + * + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async embedAudioFileMetadata(req, res) { if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { Logger.error(`[ToolsController] Invalid library item`) @@ -57,7 +81,15 @@ class ToolsController { res.sendStatus(200) } - // POST: api/tools/batch/embed-metadata + /** + * POST: /api/tools/batch/embed-metadata + * Start batch audiobook embed task + * + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async batchEmbedMetadata(req, res) { const libraryItemIds = req.body.libraryItemIds || [] if (!libraryItemIds.length) { @@ -99,6 +131,12 @@ class ToolsController { res.sendStatus(200) } + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ async middleware(req, res, next) { if (!req.user.isAdminOrUp) { Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user) @@ -120,4 +158,4 @@ class ToolsController { next() } } -module.exports = new ToolsController() \ No newline at end of file +module.exports = new ToolsController() diff --git a/server/libs/fluentFfmpeg/index.d.ts b/server/libs/fluentFfmpeg/index.d.ts new file mode 100644 index 0000000000..ee9f49232a --- /dev/null +++ b/server/libs/fluentFfmpeg/index.d.ts @@ -0,0 +1,498 @@ +/// + +import * as events from "events"; +import * as stream from "stream"; + +declare namespace Ffmpeg { + interface FfmpegCommandLogger { + error(...data: any[]): void; + warn(...data: any[]): void; + info(...data: any[]): void; + debug(...data: any[]): void; + } + + interface FfmpegCommandOptions { + logger?: FfmpegCommandLogger | undefined; + niceness?: number | undefined; + priority?: number | undefined; + presets?: string | undefined; + preset?: string | undefined; + stdoutLines?: number | undefined; + timeout?: number | undefined; + source?: string | stream.Readable | undefined; + cwd?: string | undefined; + } + + interface FilterSpecification { + filter: string; + inputs?: string | string[] | undefined; + outputs?: string | string[] | undefined; + options?: any | string | any[] | undefined; + } + + type PresetFunction = (command: FfmpegCommand) => void; + + interface Filter { + description: string; + input: string; + multipleInputs: boolean; + output: string; + multipleOutputs: boolean; + } + interface Filters { + [key: string]: Filter; + } + type FiltersCallback = (err: Error, filters: Filters) => void; + + interface Codec { + type: string; + description: string; + canDecode: boolean; + canEncode: boolean; + drawHorizBand?: boolean | undefined; + directRendering?: boolean | undefined; + weirdFrameTruncation?: boolean | undefined; + intraFrameOnly?: boolean | undefined; + isLossy?: boolean | undefined; + isLossless?: boolean | undefined; + } + interface Codecs { + [key: string]: Codec; + } + type CodecsCallback = (err: Error, codecs: Codecs) => void; + + interface Encoder { + type: string; + description: string; + frameMT: boolean; + sliceMT: boolean; + experimental: boolean; + drawHorizBand: boolean; + directRendering: boolean; + } + interface Encoders { + [key: string]: Encoder; + } + type EncodersCallback = (err: Error, encoders: Encoders) => void; + + interface Format { + description: string; + canDemux: boolean; + canMux: boolean; + } + interface Formats { + [key: string]: Format; + } + type FormatsCallback = (err: Error, formats: Formats) => void; + + interface FfprobeData { + streams: FfprobeStream[]; + format: FfprobeFormat; + chapters: any[]; + } + + interface FfprobeStream { + [key: string]: any; + index: number; + codec_name?: string | undefined; + codec_long_name?: string | undefined; + profile?: number | undefined; + codec_type?: string | undefined; + codec_time_base?: string | undefined; + codec_tag_string?: string | undefined; + codec_tag?: string | undefined; + width?: number | undefined; + height?: number | undefined; + coded_width?: number | undefined; + coded_height?: number | undefined; + has_b_frames?: number | undefined; + sample_aspect_ratio?: string | undefined; + display_aspect_ratio?: string | undefined; + pix_fmt?: string | undefined; + level?: string | undefined; + color_range?: string | undefined; + color_space?: string | undefined; + color_transfer?: string | undefined; + color_primaries?: string | undefined; + chroma_location?: string | undefined; + field_order?: string | undefined; + timecode?: string | undefined; + refs?: number | undefined; + id?: string | undefined; + r_frame_rate?: string | undefined; + avg_frame_rate?: string | undefined; + time_base?: string | undefined; + start_pts?: number | undefined; + start_time?: number | undefined; + duration_ts?: string | undefined; + duration?: string | undefined; + bit_rate?: string | undefined; + max_bit_rate?: string | undefined; + bits_per_raw_sample?: string | undefined; + nb_frames?: string | undefined; + nb_read_frames?: string | undefined; + nb_read_packets?: string | undefined; + sample_fmt?: string | undefined; + sample_rate?: number | undefined; + channels?: number | undefined; + channel_layout?: string | undefined; + bits_per_sample?: number | undefined; + disposition?: FfprobeStreamDisposition | undefined; + rotation?: string | number | undefined; + } + + interface FfprobeStreamDisposition { + [key: string]: any; + default?: number | undefined; + dub?: number | undefined; + original?: number | undefined; + comment?: number | undefined; + lyrics?: number | undefined; + karaoke?: number | undefined; + forced?: number | undefined; + hearing_impaired?: number | undefined; + visual_impaired?: number | undefined; + clean_effects?: number | undefined; + attached_pic?: number | undefined; + timed_thumbnails?: number | undefined; + } + + interface FfprobeFormat { + [key: string]: any; + filename?: string | undefined; + nb_streams?: number | undefined; + nb_programs?: number | undefined; + format_name?: string | undefined; + format_long_name?: string | undefined; + start_time?: number | undefined; + duration?: number | undefined; + size?: number | undefined; + bit_rate?: number | undefined; + probe_score?: number | undefined; + tags?: Record | undefined; + } + + interface ScreenshotsConfig { + count?: number | undefined; + folder?: string | undefined; + filename?: string | undefined; + timemarks?: number[] | string[] | undefined; + timestamps?: number[] | string[] | undefined; + fastSeek?: boolean | undefined; + size?: string | undefined; + } + + interface AudioVideoFilter { + filter: string; + options: string | string[] | {}; + } + + // static methods + function setFfmpegPath(path: string): FfmpegCommand; + function setFfprobePath(path: string): FfmpegCommand; + function setFlvtoolPath(path: string): FfmpegCommand; + function availableFilters(callback: FiltersCallback): void; + function getAvailableFilters(callback: FiltersCallback): void; + function availableCodecs(callback: CodecsCallback): void; + function getAvailableCodecs(callback: CodecsCallback): void; + function availableEncoders(callback: EncodersCallback): void; + function getAvailableEncoders(callback: EncodersCallback): void; + function availableFormats(callback: FormatsCallback): void; + function getAvailableFormats(callback: FormatsCallback): void; + + class FfmpegCommand extends events.EventEmitter { + constructor(options?: FfmpegCommandOptions); + constructor(input?: string | stream.Readable, options?: FfmpegCommandOptions); + + // options/inputs + mergeAdd(source: string | stream.Readable): FfmpegCommand; + addInput(source: string | stream.Readable): FfmpegCommand; + input(source: string | stream.Readable): FfmpegCommand; + withInputFormat(format: string): FfmpegCommand; + inputFormat(format: string): FfmpegCommand; + fromFormat(format: string): FfmpegCommand; + withInputFps(fps: number): FfmpegCommand; + withInputFPS(fps: number): FfmpegCommand; + withFpsInput(fps: number): FfmpegCommand; + withFPSInput(fps: number): FfmpegCommand; + inputFPS(fps: number): FfmpegCommand; + inputFps(fps: number): FfmpegCommand; + fpsInput(fps: number): FfmpegCommand; + FPSInput(fps: number): FfmpegCommand; + nativeFramerate(): FfmpegCommand; + withNativeFramerate(): FfmpegCommand; + native(): FfmpegCommand; + setStartTime(seek: string | number): FfmpegCommand; + seekInput(seek: string | number): FfmpegCommand; + loop(duration?: string | number): FfmpegCommand; + + // options/audio + withNoAudio(): FfmpegCommand; + noAudio(): FfmpegCommand; + withAudioCodec(codec: string): FfmpegCommand; + audioCodec(codec: string): FfmpegCommand; + withAudioBitrate(bitrate: string | number): FfmpegCommand; + audioBitrate(bitrate: string | number): FfmpegCommand; + withAudioChannels(channels: number): FfmpegCommand; + audioChannels(channels: number): FfmpegCommand; + withAudioFrequency(freq: number): FfmpegCommand; + audioFrequency(freq: number): FfmpegCommand; + withAudioQuality(quality: number): FfmpegCommand; + audioQuality(quality: number): FfmpegCommand; + withAudioFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + withAudioFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + audioFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + audioFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + + // options/video; + withNoVideo(): FfmpegCommand; + noVideo(): FfmpegCommand; + withVideoCodec(codec: string): FfmpegCommand; + videoCodec(codec: string): FfmpegCommand; + withVideoBitrate(bitrate: string | number, constant?: boolean): FfmpegCommand; + videoBitrate(bitrate: string | number, constant?: boolean): FfmpegCommand; + withVideoFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + withVideoFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + videoFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + videoFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand; + withOutputFps(fps: number): FfmpegCommand; + withOutputFPS(fps: number): FfmpegCommand; + withFpsOutput(fps: number): FfmpegCommand; + withFPSOutput(fps: number): FfmpegCommand; + withFps(fps: number): FfmpegCommand; + withFPS(fps: number): FfmpegCommand; + outputFPS(fps: number): FfmpegCommand; + outputFps(fps: number): FfmpegCommand; + fpsOutput(fps: number): FfmpegCommand; + FPSOutput(fps: number): FfmpegCommand; + fps(fps: number): FfmpegCommand; + FPS(fps: number): FfmpegCommand; + takeFrames(frames: number): FfmpegCommand; + withFrames(frames: number): FfmpegCommand; + frames(frames: number): FfmpegCommand; + + // options/videosize + keepPixelAspect(): FfmpegCommand; + keepDisplayAspect(): FfmpegCommand; + keepDisplayAspectRatio(): FfmpegCommand; + keepDAR(): FfmpegCommand; + withSize(size: string): FfmpegCommand; + setSize(size: string): FfmpegCommand; + size(size: string): FfmpegCommand; + withAspect(aspect: string | number): FfmpegCommand; + withAspectRatio(aspect: string | number): FfmpegCommand; + setAspect(aspect: string | number): FfmpegCommand; + setAspectRatio(aspect: string | number): FfmpegCommand; + aspect(aspect: string | number): FfmpegCommand; + aspectRatio(aspect: string | number): FfmpegCommand; + applyAutopadding(pad?: boolean, color?: string): FfmpegCommand; + applyAutoPadding(pad?: boolean, color?: string): FfmpegCommand; + applyAutopad(pad?: boolean, color?: string): FfmpegCommand; + applyAutoPad(pad?: boolean, color?: string): FfmpegCommand; + withAutopadding(pad?: boolean, color?: string): FfmpegCommand; + withAutoPadding(pad?: boolean, color?: string): FfmpegCommand; + withAutopad(pad?: boolean, color?: string): FfmpegCommand; + withAutoPad(pad?: boolean, color?: string): FfmpegCommand; + autoPad(pad?: boolean, color?: string): FfmpegCommand; + autopad(pad?: boolean, color?: string): FfmpegCommand; + + // options/output + addOutput(target: string | stream.Writable, pipeopts?: { end?: boolean | undefined }): FfmpegCommand; + output(target: string | stream.Writable, pipeopts?: { end?: boolean | undefined }): FfmpegCommand; + seekOutput(seek: string | number): FfmpegCommand; + seek(seek: string | number): FfmpegCommand; + withDuration(duration: string | number): FfmpegCommand; + setDuration(duration: string | number): FfmpegCommand; + duration(duration: string | number): FfmpegCommand; + toFormat(format: string): FfmpegCommand; + withOutputFormat(format: string): FfmpegCommand; + outputFormat(format: string): FfmpegCommand; + format(format: string): FfmpegCommand; + map(spec: string): FfmpegCommand; + updateFlvMetadata(): FfmpegCommand; + flvmeta(): FfmpegCommand; + + // options/custom + addInputOption(options: string[]): FfmpegCommand; + addInputOption(...options: string[]): FfmpegCommand; + addInputOptions(options: string[]): FfmpegCommand; + addInputOptions(...options: string[]): FfmpegCommand; + withInputOption(options: string[]): FfmpegCommand; + withInputOption(...options: string[]): FfmpegCommand; + withInputOptions(options: string[]): FfmpegCommand; + withInputOptions(...options: string[]): FfmpegCommand; + inputOption(options: string[]): FfmpegCommand; + inputOption(...options: string[]): FfmpegCommand; + inputOptions(options: string[]): FfmpegCommand; + inputOptions(...options: string[]): FfmpegCommand; + addOutputOption(options: string[]): FfmpegCommand; + addOutputOption(...options: string[]): FfmpegCommand; + addOutputOptions(options: string[]): FfmpegCommand; + addOutputOptions(...options: string[]): FfmpegCommand; + addOption(options: string[]): FfmpegCommand; + addOption(...options: string[]): FfmpegCommand; + addOptions(options: string[]): FfmpegCommand; + addOptions(...options: string[]): FfmpegCommand; + withOutputOption(options: string[]): FfmpegCommand; + withOutputOption(...options: string[]): FfmpegCommand; + withOutputOptions(options: string[]): FfmpegCommand; + withOutputOptions(...options: string[]): FfmpegCommand; + withOption(options: string[]): FfmpegCommand; + withOption(...options: string[]): FfmpegCommand; + withOptions(options: string[]): FfmpegCommand; + withOptions(...options: string[]): FfmpegCommand; + outputOption(options: string[]): FfmpegCommand; + outputOption(...options: string[]): FfmpegCommand; + outputOptions(options: string[]): FfmpegCommand; + outputOptions(...options: string[]): FfmpegCommand; + filterGraph( + spec: string | FilterSpecification | Array, + map?: string[] | string, + ): FfmpegCommand; + complexFilter( + spec: string | FilterSpecification | Array, + map?: string[] | string, + ): FfmpegCommand; + + // options/misc + usingPreset(preset: string | PresetFunction): FfmpegCommand; + preset(preset: string | PresetFunction): FfmpegCommand; + + // processor + renice(niceness: number): FfmpegCommand; + kill(signal: string): FfmpegCommand; + _getArguments(): string[]; + + // capabilities + setFfmpegPath(path: string): FfmpegCommand; + setFfprobePath(path: string): FfmpegCommand; + setFlvtoolPath(path: string): FfmpegCommand; + availableFilters(callback: FiltersCallback): void; + getAvailableFilters(callback: FiltersCallback): void; + availableCodecs(callback: CodecsCallback): void; + getAvailableCodecs(callback: CodecsCallback): void; + availableEncoders(callback: EncodersCallback): void; + getAvailableEncoders(callback: EncodersCallback): void; + availableFormats(callback: FormatsCallback): void; + getAvailableFormats(callback: FormatsCallback): void; + + // ffprobe + ffprobe(callback: (err: any, data: FfprobeData) => void): void; + ffprobe(index: number, callback: (err: any, data: FfprobeData) => void): void; + ffprobe(options: string[], callback: (err: any, data: FfprobeData) => void): void; // tslint:disable-line unified-signatures + ffprobe(index: number, options: string[], callback: (err: any, data: FfprobeData) => void): void; + + // event listeners + /** + * Emitted just after ffmpeg has been spawned. + * + * @event FfmpegCommand#start + * @param {String} command ffmpeg command line + */ + on(event: "start", listener: (command: string) => void): this; + + /** + * Emitted when ffmpeg reports progress information + * + * @event FfmpegCommand#progress + * @param {Object} progress progress object + * @param {Number} progress.frames number of frames transcoded + * @param {Number} progress.currentFps current processing speed in frames per second + * @param {Number} progress.currentKbps current output generation speed in kilobytes per second + * @param {Number} progress.targetSize current output file size + * @param {String} progress.timemark current video timemark + * @param {Number} [progress.percent] processing progress (may not be available depending on input) + */ + on( + event: "progress", + listener: (progress: { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number | undefined; + }) => void, + ): this; + + /** + * Emitted when ffmpeg outputs to stderr + * + * @event FfmpegCommand#stderr + * @param {String} line stderr output line + */ + on(event: "stderr", listener: (line: string) => void): this; + + /** + * Emitted when ffmpeg reports input codec data + * + * @event FfmpegCommand#codecData + * @param {Object} codecData codec data object + * @param {String} codecData.format input format name + * @param {String} codecData.audio input audio codec name + * @param {String} codecData.audio_details input audio codec parameters + * @param {String} codecData.video input video codec name + * @param {String} codecData.video_details input video codec parameters + */ + on( + event: "codecData", + listener: (codecData: { + format: string; + audio: string; + audio_details: string; + video: string; + video_details: string; + }) => void, + ): this; + + /** + * Emitted when an error happens when preparing or running a command + * + * @event FfmpegCommand#error + * @param {Error} error error object, with optional properties 'inputStreamError' / 'outputStreamError' for errors on their respective streams + * @param {String|null} stdout ffmpeg stdout, unless outputting to a stream + * @param {String|null} stderr ffmpeg stderr + */ + on(event: "error", listener: (error: Error, stdout: string | null, stderr: string | null) => void): this; + + /** + * Emitted when a command finishes processing + * + * @event FfmpegCommand#end + * @param {Array|String|null} [filenames|stdout] generated filenames when taking screenshots, ffmpeg stdout when not outputting to a stream, null otherwise + * @param {String|null} stderr ffmpeg stderr + */ + on(event: "end", listener: (filenames: string[] | string | null, stderr: string | null) => void): this; + + // recipes + saveToFile(output: string): FfmpegCommand; + save(output: string): FfmpegCommand; + writeToStream(stream: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable; + pipe(stream?: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable | stream.PassThrough; + stream(stream: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable; + takeScreenshots(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand; + thumbnail(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand; + thumbnails(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand; + screenshot(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand; + screenshots(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand; + mergeToFile(target: string | stream.Writable, tmpFolder: string): FfmpegCommand; + concatenate(target: string | stream.Writable, options?: { end?: boolean | undefined }): FfmpegCommand; + concat(target: string | stream.Writable, options?: { end?: boolean | undefined }): FfmpegCommand; + clone(): FfmpegCommand; + run(): void; + } + + function ffprobe(file: string, callback: (err: any, data: FfprobeData) => void): void; + function ffprobe(file: string, index: number, callback: (err: any, data: FfprobeData) => void): void; + function ffprobe(file: string, options: string[], callback: (err: any, data: FfprobeData) => void): void; // tslint:disable-line unified-signatures + function ffprobe( + file: string, + index: number, + options: string[], + callback: (err: any, data: FfprobeData) => void, + ): void; +} +declare function Ffmpeg(options?: Ffmpeg.FfmpegCommandOptions): Ffmpeg.FfmpegCommand; +declare function Ffmpeg(input?: string | stream.Readable, options?: Ffmpeg.FfmpegCommandOptions): Ffmpeg.FfmpegCommand; + +export = Ffmpeg; diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 5d3fea8fb9..e0780cc4d9 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -3,29 +3,53 @@ const fs = require('../libs/fsExtra') const Logger = require('../Logger') const TaskManager = require('./TaskManager') const Task = require('../objects/Task') -const { writeConcatFile } = require('../utils/ffmpegHelpers') const ffmpegHelpers = require('../utils/ffmpegHelpers') const Ffmpeg = require('../libs/fluentFfmpeg') const SocketAuthority = require('../SocketAuthority') const { isWritable, copyToExisting } = require('../utils/fileUtils') const TrackProgressMonitor = require('../objects/TrackProgressMonitor') +/** + * @typedef AbMergeEncodeOptions + * @property {string} codec + * @property {string} channels + * @property {string} bitrate + */ + class AbMergeManager { constructor() { this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') + /** @type {Task[]} */ this.pendingTasks = [] } + /** + * + * @param {string} libraryItemId + * @returns {Task|null} + */ getPendingTaskByLibraryItemId(libraryItemId) { return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId) } + /** + * Cancel and fail running task + * + * @param {Task} task + * @returns {Promise} + */ cancelEncode(task) { task.setFailed('Task canceled by user') return this.removeTask(task, true) } + /** + * + * @param {import('../objects/user/User')} user + * @param {import('../objects/LibraryItem')} libraryItem + * @param {AbMergeEncodeOptions} [options={}] + */ async startAudiobookMerge(user, libraryItem, options = {}) { const task = new Task() @@ -63,6 +87,12 @@ class AbMergeManager { this.runAudiobookMerge(libraryItem, task, options || {}) } + /** + * + * @param {import('../objects/LibraryItem')} libraryItem + * @param {Task} task + * @param {AbMergeEncodeOptions} encodingOptions + */ async runAudiobookMerge(libraryItem, task, encodingOptions) { // Make sure the target directory is writable if (!(await isWritable(libraryItem.path))) { @@ -178,6 +208,12 @@ class AbMergeManager { Logger.info(`[AbMergeManager] Ab task finished ${task.id}`) } + /** + * Remove ab merge task + * + * @param {Task} task + * @param {boolean} [removeTempFilepath=false] + */ async removeTask(task, removeTempFilepath = false) { Logger.info('[AbMergeManager] Removing task ' + task.id) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index c49372b0c7..2ab6f50362 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -1,4 +1,3 @@ - const EventEmitter = require('events') const Path = require('path') const Logger = require('../Logger') @@ -46,7 +45,7 @@ class Stream extends EventEmitter { } get episode() { if (!this.isPodcast) return null - return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId) + return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId) } get libraryItemId() { return this.libraryItem.id @@ -76,21 +75,10 @@ class Stream extends EventEmitter { return this.tracks[0].codec } get mimeTypesToForceAAC() { - return [ - AudioMimeType.FLAC, - AudioMimeType.OPUS, - AudioMimeType.WMA, - AudioMimeType.AIFF, - AudioMimeType.WEBM, - AudioMimeType.WEBMA, - AudioMimeType.AWB, - AudioMimeType.CAF - ] + return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF] } get codecsToForceAAC() { - return [ - 'alac' - ] + return ['alac'] } get userToken() { return this.user.token @@ -109,7 +97,7 @@ class Stream extends EventEmitter { } get numSegments() { var numSegs = Math.floor(this.totalDuration / this.segmentLength) - if (this.totalDuration - (numSegs * this.segmentLength) > 0) { + if (this.totalDuration - numSegs * this.segmentLength > 0) { numSegs++ } return numSegs @@ -135,7 +123,7 @@ class Stream extends EventEmitter { clientPlaylistUri: this.clientPlaylistUri, startTime: this.startTime, segmentStartNumber: this.segmentStartNumber, - isTranscodeComplete: this.isTranscodeComplete, + isTranscodeComplete: this.isTranscodeComplete } } @@ -143,7 +131,7 @@ class Stream extends EventEmitter { const segStartTime = segNum * this.segmentLength if (this.segmentStartNumber > segNum) { Logger.warn(`[STREAM] Segment #${segNum} Request is before starting segment number #${this.segmentStartNumber} - Reset Transcode`) - await this.reset(segStartTime - (this.segmentLength * 5)) + await this.reset(segStartTime - this.segmentLength * 5) return segStartTime } else if (this.isTranscodeComplete) { return false @@ -153,7 +141,7 @@ class Stream extends EventEmitter { const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated if (distanceFromFurthestSegment > 10) { Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`) - await this.reset(segStartTime - (this.segmentLength * 5)) + await this.reset(segStartTime - this.segmentLength * 5) return segStartTime } } @@ -217,7 +205,7 @@ class Stream extends EventEmitter { else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`) } - var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%' + var perc = ((this.segmentsCreated.size * 100) / this.numSegments).toFixed(2) + '%' Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`) // Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', ')) @@ -251,6 +239,7 @@ class Stream extends EventEmitter { async start() { Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`) + /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */ this.ffmpeg = Ffmpeg() this.furthestSegmentCreated = 0 @@ -289,24 +278,8 @@ class Stream extends EventEmitter { audioCodec = 'aac' } - this.ffmpeg.addOption([ - `-loglevel ${logLevel}`, - '-map 0:a', - `-c:a ${audioCodec}` - ]) - const hlsOptions = [ - '-f hls', - "-copyts", - "-avoid_negative_ts make_non_negative", - "-max_delay 5000000", - "-max_muxing_queue_size 2048", - `-hls_time 6`, - `-hls_segment_type ${this.hlsSegmentType}`, - `-start_number ${this.segmentStartNumber}`, - "-hls_playlist_type vod", - "-hls_list_size 0", - "-hls_allow_cache 0" - ] + this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`]) + const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0'] if (this.hlsSegmentType === 'fmp4') { hlsOptions.push('-strict -2') var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4') @@ -369,7 +342,6 @@ class Stream extends EventEmitter { Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`) this.clientEmit('stream_open', this.toJSON()) - } this.isTranscodeComplete = true this.ffmpeg = null @@ -387,11 +359,14 @@ class Stream extends EventEmitter { this.ffmpeg.kill('SIGKILL') } - await fs.remove(this.streamPath).then(() => { - Logger.info('Deleted session data', this.streamPath) - }).catch((err) => { - Logger.error('Failed to delete session data', err) - }) + await fs + .remove(this.streamPath) + .then(() => { + Logger.info('Deleted session data', this.streamPath) + }) + .catch((err) => { + Logger.error('Failed to delete session data', err) + }) if (errorMessage) this.clientEmit('stream_error', { id: this.id, error: (errorMessage || '').trim() }) else this.clientEmit('stream_closed', this.id) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index b66df03081..c22f24ff22 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -40,6 +40,7 @@ class ApiRouter { /** @type {import('../Auth')} */ this.auth = Server.auth this.playbackSessionManager = Server.playbackSessionManager + /** @type {import('../managers/AbMergeManager')} */ this.abMergeManager = Server.abMergeManager /** @type {import('../managers/BackupManager')} */ this.backupManager = Server.backupManager @@ -47,6 +48,7 @@ class ApiRouter { this.watcher = Server.watcher /** @type {import('../managers/PodcastManager')} */ this.podcastManager = Server.podcastManager + /** @type {import('../managers/AudioMetadataManager')} */ this.audioMetadataManager = Server.audioMetadataManager this.rssFeedManager = Server.rssFeedManager this.cronManager = Server.cronManager diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index f3c40bf649..60cd0f301e 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -53,6 +53,7 @@ async function extractCoverArt(filepath, outputpath) { await fs.ensureDir(dirname) return new Promise((resolve) => { + /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */ var ffmpeg = Ffmpeg(filepath) ffmpeg.addOption(['-map 0:v', '-frames:v 1']) ffmpeg.output(outputpath) @@ -76,6 +77,7 @@ module.exports.extractCoverArt = extractCoverArt //This should convert based on the output file extension as well async function resizeImage(filePath, outputPath, width, height) { return new Promise((resolve) => { + /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */ var ffmpeg = Ffmpeg(filePath) ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`]) ffmpeg.addOutput(outputPath) @@ -111,6 +113,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { }) if (!response) return resolve(false) + /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */ const ffmpeg = Ffmpeg(response.data) ffmpeg.addOption('-loglevel debug') // Debug logs printed on error ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1') @@ -251,7 +254,7 @@ module.exports.writeFFMetadataFile = writeFFMetadataFile * @param {number} track - The track number to embed in the audio file. * @param {string} mimeType - The MIME type of the audio file. * @param {function(number): void|null} progressCB - A callback function to report progress. - * @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests. + * @param {import('../libs/fluentFfmpeg/index').FfmpegCommand} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests. * @param {function(string, string): Promise} copyFunc - The function to use for copying files (optional). Used for dependency injection in tests. * @returns {Promise} A promise that resolves if the operation is successful, rejects otherwise. */ @@ -392,9 +395,9 @@ module.exports.getFFMetadataObject = getFFMetadataObject * @param {number} duration - The total duration of the audio tracks. * @param {string} itemCachePath - The path to the item cache. * @param {string} outputFilePath - The path to the output file. - * @param {Object} encodingOptions - The options for encoding the audio. + * @param {import('../managers/AbMergeManager').AbMergeEncodeOptions} encodingOptions - The options for encoding the audio. * @param {Function} [progressCB=null] - The callback function to track the progress of the merge. - * @param {Object} [ffmpeg=Ffmpeg()] - The FFmpeg instance to use for merging. + * @param {import('../libs/fluentFfmpeg/index').FfmpegCommand} [ffmpeg=Ffmpeg()] - The FFmpeg instance to use for merging. * @returns {Promise} A promise that resolves when the audio files are merged successfully. */ async function mergeAudioFiles(audioTracks, duration, itemCachePath, outputFilePath, encodingOptions, progressCB = null, ffmpeg = Ffmpeg()) { From 4a5345dd5d0b4da9dff8addde118916a383c56ba Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 1 Aug 2024 14:25:57 -0500 Subject: [PATCH 6/7] Update:devcontainer dev.js default to not skip binaries check, fail gracefully if required binary env variables are not set when skipping --- .devcontainer/dev.js | 4 ++-- server/managers/BinaryManager.js | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.devcontainer/dev.js b/.devcontainer/dev.js index 28c0074b44..054690d4f1 100644 --- a/.devcontainer/dev.js +++ b/.devcontainer/dev.js @@ -6,5 +6,5 @@ module.exports.config = { MetadataPath: Path.resolve('metadata'), FFmpegPath: '/usr/bin/ffmpeg', FFProbePath: '/usr/bin/ffprobe', - SkipBinariesCheck: true -} \ No newline at end of file + SkipBinariesCheck: false +} diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js index 63c87e7bef..c7db420468 100644 --- a/server/managers/BinaryManager.js +++ b/server/managers/BinaryManager.js @@ -275,6 +275,12 @@ class BinaryManager { async init() { // Optional skip binaries check if (process.env.SKIP_BINARIES_CHECK === '1') { + for (const binary of this.requiredBinaries) { + if (!process.env[binary.envVariable]) { + await Logger.fatal(`[BinaryManager] Environment variable ${binary.envVariable} must be set`) + process.exit(1) + } + } Logger.info('[BinaryManager] Skipping check for binaries') return } From 2a69955cc1adfd79756668acc35ca899ff52c485 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 2 Aug 2024 16:30:21 -0500 Subject: [PATCH 7/7] Update server/controllers/LibraryItemController.js --- server/controllers/LibraryItemController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 60d41b23b0..af26415466 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -739,7 +739,7 @@ class LibraryItemController { } const ebookFilePath = ebookFile.metadata.path - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${ebookFile.metadata.title}" at "${ebookFilePath}"`) + Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`) if (global.XAccel) { const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)