diff --git a/server/src/cache/import-utils.ts b/server/src/cache/import-utils.ts index e410c40..825661e 100755 --- a/server/src/cache/import-utils.ts +++ b/server/src/cache/import-utils.ts @@ -1,4 +1,5 @@ import ChildProcess from 'child_process'; +import FFMpeg from 'fluent-ffmpeg'; import FS from 'fs'; import GM from 'gm'; import Path from 'path'; @@ -6,7 +7,7 @@ import Rimraf from 'rimraf'; import Stream from 'stream'; import Util from 'util'; -import { BaseMedia, Media, MediaType, SegmentMetadata } from '../types'; +import { BaseMedia, Media, MediaType, Metadata, SegmentMetadata } from '../types'; import Config from '../config'; export interface Quality { @@ -36,6 +37,53 @@ export class ImportUtils { return Math.round(creationDate.getTime() / 1000); } + public static async getVideoMetadata(absolutePath: string): Promise { + // The ffprobe typings are broken with promisify. + const data = await Util.promisify(FFMpeg.ffprobe as any)(absolutePath); + + const mediaData = data.streams.find((stream: any) => stream.codec_type === 'video'); + if (!mediaData) { + throw new Error('No video streams found to extract metadata from'); + } + + const metadata: Metadata = { + length: Math.ceil(data.format.duration), + qualityCache: [], + width: mediaData.width || mediaData.coded_width, + height: mediaData.height || mediaData.coded_height, + codec: mediaData.codec_name, + ...(data.format.tags + ? { + artist: data.format.tags.artist || data.format.tags.album_artist, + album: data.format.tags.album, + title: data.format.tags.title, + } + : {}), + }; + + // Delete them so they're not passed around as undefined. + if (!metadata.artist) { + delete metadata.artist; + } + if (!metadata.album) { + delete metadata.album; + } + if (!metadata.title) { + delete metadata.title; + } + + return metadata; + } + + public static async getImageMetadata(absolutePath: string): Promise { + const gm = GM.subClass({ imageMagick: true })(absolutePath); + const size: Record = (await Util.promisify(gm.size.bind(gm))()) as any; + return { + width: size.width, + height: size.height, + }; + } + public static getType(filename: string): MediaType { const ext = Path.extname(filename || '').split('.'); switch (ext[ext.length - 1].toLowerCase()) { diff --git a/server/src/tasks/indexer.ts b/server/src/tasks/indexer.ts index 77fa91f..da4bf3a 100755 --- a/server/src/tasks/indexer.ts +++ b/server/src/tasks/indexer.ts @@ -1,10 +1,7 @@ import { ExecutorPromise, execute } from 'proper-job'; -import FFMpeg from 'fluent-ffmpeg'; -import GM from 'gm'; import Path from 'path'; -import Util from 'util'; -import { Database, Media, Metadata, RouterTask, TaskRunnerCallback } from '../types'; +import { Database, Media, RouterTask, TaskRunnerCallback } from '../types'; import { ImportUtils } from '../cache/import-utils'; import { Scanner } from './scanner'; import { createHash } from '../cache/hash'; @@ -13,53 +10,6 @@ import Config from '../config'; export class Indexer { private database: Database; - public static async getVideoMetadata(absolutePath: string): Promise { - // The ffprobe typings are broken with promisify. - const data = await Util.promisify(FFMpeg.ffprobe as any)(absolutePath); - - const mediaData = data.streams.find((stream: any) => stream.codec_type === 'video'); - if (!mediaData) { - throw new Error('No video streams found to extract metadata from'); - } - - const metadata: Metadata = { - length: Math.ceil(data.format.duration), - qualityCache: [], - width: mediaData.width || mediaData.coded_width, - height: mediaData.height || mediaData.coded_height, - codec: mediaData.codec_name, - ...(data.format.tags - ? { - artist: data.format.tags.artist || data.format.tags.album_artist, - album: data.format.tags.album, - title: data.format.tags.title, - } - : {}), - }; - - // Delete them so they're not passed around as undefined. - if (!metadata.artist) { - delete metadata.artist; - } - if (!metadata.artist) { - delete metadata.album; - } - if (!metadata.artist) { - delete metadata.title; - } - - return metadata; - } - - private static async getImageMetadata(absolutePath: string): Promise { - const gm = GM.subClass({ imageMagick: true })(absolutePath); - const size: Record = (await Util.promisify(gm.size.bind(gm))()) as any; - return { - width: size.width, - height: size.height, - }; - } - public constructor(database: Database) { this.database = database; } @@ -72,8 +22,8 @@ export class Indexer { hash: await createHash(absolutePath), metadata: { ...(type === 'video' - ? await Indexer.getVideoMetadata(absolutePath) - : await Indexer.getImageMetadata(absolutePath)), + ? await ImportUtils.getVideoMetadata(absolutePath) + : await ImportUtils.getImageMetadata(absolutePath)), createdAt: await ImportUtils.getFileCreationTime(absolutePath), }, path: file, diff --git a/server/src/tasks/rescan-video-metadata.ts b/server/src/tasks/rescan-video-metadata.ts new file mode 100755 index 0000000..e43e924 --- /dev/null +++ b/server/src/tasks/rescan-video-metadata.ts @@ -0,0 +1,70 @@ +import { execute } from 'proper-job'; + +import { Database, RouterTask, TaskRunnerCallback } from '../types'; +import { ImportUtils } from '../cache/import-utils'; + +const BATCH_SIZE = 4; + +export function getTask(db: Database): RouterTask { + return { + id: 'RESCAN-VIDEO-METADATA', + description: 'Rescan video files for missing metadata', + runner: (updateStatus: TaskRunnerCallback) => { + return execute( + async () => { + const videos = await db.subset({ + corrupted: false, + type: { equalsAll: ['video'] }, + }); + updateStatus(0, videos.length); + + return { + iterable: videos, + init: { + current: 0, + max: videos.length, + }, + }; + }, + async (hash, init) => { + if (!init) { + throw new Error('init not defined'); + } + + try { + const media = await db.getMedia(hash); + if (!media) { + throw new Error(`Couldn't find media to update metadata: ${hash}`); + } + if (media.metadata?.album && media.metadata?.artist && media.metadata?.title) { + return; + } + const fileMetadata = await ImportUtils.getVideoMetadata(media.absolutePath); + if (!fileMetadata.album && !fileMetadata.artist && !fileMetadata.title) { + return; + } + + try { + await db.saveMedia(media.hash, { + metadata: { + ...(media.metadata?.album ? {} : { album: fileMetadata.album }), + ...(media.metadata?.artist ? {} : { artist: fileMetadata.artist }), + ...(media.metadata?.title ? {} : { title: fileMetadata.title }), + }, + }); + } catch (err) { + console.log('Failed to save media preview state.', err, media); + } + } catch (err) { + console.log(`Error getting metadata for ${hash}.`, err); + await db.saveMedia(hash, { corrupted: true }); + throw new Error(`Error getting metadata for ${hash}.`); + } finally { + updateStatus(init.current++, init.max); + } + }, + { parallel: BATCH_SIZE }, + ); + }, + }; +} diff --git a/server/src/utils/import-json.ts b/server/src/utils/import-json.ts index e8f247b..38a35fd 100755 --- a/server/src/utils/import-json.ts +++ b/server/src/utils/import-json.ts @@ -5,7 +5,6 @@ import Util from 'util'; import { BaseMedia, Database, DumpFile } from '../types'; import { ImportUtils } from '../cache/import-utils'; -import { Indexer } from '../tasks/indexer'; import { createHash } from '../cache/hash'; import { setup as setupDb } from '../database'; import Config from '../config'; @@ -59,7 +58,7 @@ async function importMedia(db: Database, media: BaseMedia, version?: number): Pr if (media.type === 'video') { // In older versions, maxCopy was the default. media.metadata.maxCopy = true; - const metadata = await Indexer.getVideoMetadata(Path.resolve(libraryDir, media.path)); + const metadata = await ImportUtils.getVideoMetadata(Path.resolve(libraryDir, media.path)); // Some videos in older versions didn't correctly get the width/height. media.metadata.width = metadata.width; media.metadata.height = metadata.height;