Compare commits
4 Commits
e2dd40b06b
...
d4296e5c7b
| Author | SHA1 | Date | |
|---|---|---|---|
| d4296e5c7b | |||
| afbaebe4f1 | |||
| 80c444dd34 | |||
| 078738f502 |
@@ -1,4 +1,4 @@
|
||||
import { type BetterAuthOptions, betterAuth } from "better-auth";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { username } from "better-auth/plugins";
|
||||
import { database } from "../db";
|
||||
|
||||
@@ -18,7 +18,7 @@ function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
// Initialize services
|
||||
const serviceContainer = createServiceContainer();
|
||||
|
||||
@@ -33,32 +33,6 @@ export const CrewSchema = z.object({
|
||||
|
||||
export type Crew = z.infer<typeof CrewSchema>;
|
||||
|
||||
export const CreditsSchema = z.object({
|
||||
id: z.number(),
|
||||
cast: z.array(CastSchema),
|
||||
crew: z.array(CrewSchema),
|
||||
});
|
||||
|
||||
export type Credits = z.infer<typeof CreditsSchema>;
|
||||
|
||||
export const CreditInfoSchema = z.object({
|
||||
creditId: z.string(),
|
||||
});
|
||||
|
||||
export type CreditInfo = z.infer<typeof CreditInfoSchema>;
|
||||
|
||||
export const ResolvedCreditSchema = z.object({
|
||||
creditType: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
job: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
id: z.string(),
|
||||
personId: z.number().optional(),
|
||||
personName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ResolvedCredit = z.infer<typeof ResolvedCreditSchema>;
|
||||
|
||||
// Info for fetching credits by title
|
||||
export const TitleCreditsInfoSchema = z.object({
|
||||
titleType: z.enum(["movie", "series"]),
|
||||
@@ -74,3 +48,4 @@ export const ResolvedTitleCreditsSchema = z.object({
|
||||
});
|
||||
|
||||
export type ResolvedTitleCredits = z.infer<typeof ResolvedTitleCreditsSchema>;
|
||||
export type ResolvedTitleCreditsResult = ResolvedTitleCredits | null;
|
||||
|
||||
@@ -6,7 +6,7 @@ export const MovieInfo = z.object({
|
||||
/** File Name */
|
||||
fileName: z.string(),
|
||||
/** Duration playback in seconds */
|
||||
runtimeMinutes: z.number(),
|
||||
runtimeSeconds: z.number(),
|
||||
/** Title Name*/
|
||||
title: z.string(),
|
||||
/** Title Year */
|
||||
@@ -22,7 +22,7 @@ export const SeriesInfo = z.object({
|
||||
/** File Name */
|
||||
fileName: z.string(),
|
||||
/** Duration playback in seconds */
|
||||
runtimeMinutes: z.number(),
|
||||
runtimeSeconds: z.number(),
|
||||
/** Title Name*/
|
||||
title: z.string(),
|
||||
/** Title Year */
|
||||
@@ -49,7 +49,6 @@ export const ResolvedMovieMetadata = z.object({
|
||||
originalTitle: z.string(),
|
||||
tagline: z.string().optional(),
|
||||
releaseDate: z.string(),
|
||||
runtimeMinutes: z.number(),
|
||||
genres: z.string().array(),
|
||||
posterUrl: z.string(),
|
||||
backdropUrl: z.string(),
|
||||
@@ -62,6 +61,7 @@ export const ResolvedMovieMetadata = z.object({
|
||||
providerIds: ProviderId.array().optional(), // Array of external provider IDs
|
||||
});
|
||||
export type ResolvedMovieMetadata = z.infer<typeof ResolvedMovieMetadata>;
|
||||
export type ResolvedMovieMetadataResult = ResolvedMovieMetadata | null;
|
||||
|
||||
export const ResolvedSeriesMetadata = z.object({
|
||||
title: z.string(),
|
||||
@@ -92,9 +92,10 @@ export const ResolvedSeriesMetadata = z.object({
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
airDate: z.string().optional(), // Episode air date
|
||||
runtimeMinutes: z.number().optional(),
|
||||
runtimeSeconds: z.number().optional(),
|
||||
});
|
||||
export type ResolvedSeriesMetadata = z.infer<typeof ResolvedSeriesMetadata>;
|
||||
export type ResolvedSeriesMetadataResult = ResolvedSeriesMetadata | null;
|
||||
|
||||
export const MetadataSearchQuery = z.object({
|
||||
title: z.string(),
|
||||
|
||||
@@ -40,3 +40,4 @@ export const ResolvedPersonSchema = z.object({
|
||||
});
|
||||
|
||||
export type ResolvedPerson = z.infer<typeof ResolvedPersonSchema>;
|
||||
export type ResolvedPersonResult = ResolvedPerson | null;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { ConsolaInstance } from "consola";
|
||||
import type {
|
||||
CreditInfo,
|
||||
MetadataSearchQuery,
|
||||
MetadataSearchResult,
|
||||
MovieInfo,
|
||||
PersonInfo,
|
||||
ResolvedCredit,
|
||||
ResolvedMovieMetadata,
|
||||
ResolvedPerson,
|
||||
ResolvedSeriesMetadata,
|
||||
ResolvedTitleCredits,
|
||||
ResolvedMovieMetadataResult,
|
||||
ResolvedPersonResult,
|
||||
ResolvedSeriesMetadataResult,
|
||||
ResolvedTitleCreditsResult,
|
||||
SeriesInfo,
|
||||
TitleCreditsInfo,
|
||||
} from "@/models";
|
||||
@@ -26,13 +24,16 @@ export abstract class MetadataProvider extends Provider {
|
||||
public abstract search(
|
||||
query: MetadataSearchQuery,
|
||||
): Promise<MetadataSearchResult>;
|
||||
public abstract resolveMovie(info: MovieInfo): Promise<ResolvedMovieMetadata>;
|
||||
public abstract resolveMovie(
|
||||
info: MovieInfo,
|
||||
): Promise<ResolvedMovieMetadataResult>;
|
||||
public abstract resolveSeries(
|
||||
info: SeriesInfo,
|
||||
): Promise<ResolvedSeriesMetadata>;
|
||||
public abstract resolveCredits(info: CreditInfo): Promise<ResolvedCredit>;
|
||||
): Promise<ResolvedSeriesMetadataResult>;
|
||||
public abstract resolveTitleCredits(
|
||||
info: TitleCreditsInfo,
|
||||
): Promise<ResolvedTitleCredits>;
|
||||
public abstract resolvePeople(info: PersonInfo): Promise<ResolvedPerson>;
|
||||
): Promise<ResolvedTitleCreditsResult>;
|
||||
public abstract resolvePeople(
|
||||
info: PersonInfo,
|
||||
): Promise<ResolvedPersonResult>;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
import {
|
||||
type ConsolaInstance,
|
||||
type ConsolaReporter,
|
||||
createConsola,
|
||||
type LogObject,
|
||||
} from "consola";
|
||||
import type { ConsolaInstance } from "consola";
|
||||
import type { IDisposable, IMountable } from "@/interfaces/service";
|
||||
import { createLogger } from "@/utils/logger";
|
||||
|
||||
// const loggerReporter: ConsolaReporter = {
|
||||
// log(logObject: LogObject, _ctx) {
|
||||
// const date = new Date().toISOString().replace("T", " ").split(".")[0]; // 2025-09-19 23:59:59
|
||||
// const level = logObject.type.toUpperCase().padEnd(7); // INFO, ERROR, SUCCESS, dll
|
||||
// const message = logObject.args.join(" ");
|
||||
// const className = logObject.tag;
|
||||
|
||||
// // Warna sudah otomatis dari consola runtime options
|
||||
// console.log(`${date} [${level}] ${className}: ${message}`);
|
||||
// },
|
||||
// };
|
||||
|
||||
// const baseLogger = createConsola({
|
||||
// reporters: [loggerReporter],
|
||||
// });
|
||||
|
||||
export class ServiceError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { BaseService } from "../BaseService";
|
||||
import type { BaseExtension, Capabilites } from "./baseExtension";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
import { findBy } from "@nontara/language-codes";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import { database } from "@/db";
|
||||
import {
|
||||
@@ -81,34 +81,6 @@ export class LibraryScannerService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
private async countVideoFilesInDirectory(
|
||||
directoryPath: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Use Bun.Glob to match video files recursively
|
||||
const glob = new Bun.Glob(
|
||||
"**/*.{mp4,mkv,avi,mov,wmv,flv,webm,m4v,mpg,mpeg}",
|
||||
);
|
||||
|
||||
// Scan directory and collect matching files
|
||||
const files = await Array.fromAsync(
|
||||
glob.scan({
|
||||
cwd: directoryPath,
|
||||
onlyFiles: true,
|
||||
followSymlinks: false,
|
||||
}),
|
||||
);
|
||||
|
||||
return files.length;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to count video files in ${directoryPath}`,
|
||||
error,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async scanFolder(): Promise<void> {
|
||||
this.logger.info("Starting library scan process...");
|
||||
|
||||
@@ -119,7 +91,7 @@ export class LibraryScannerService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const startTime: number = Date.now();
|
||||
const folders = await database.select().from(libraryFolders);
|
||||
|
||||
if (folders.length === 0) {
|
||||
@@ -134,32 +106,12 @@ export class LibraryScannerService extends BaseService {
|
||||
await this.scanLibraryFolder(folder);
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
const duration: string = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
this.logger.success(`Library scan completed in ${duration} seconds`);
|
||||
}
|
||||
|
||||
private async scanLibraryFolder(folder: LibraryFolderRow): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check if scan is needed by comparing file count with database
|
||||
const fileCount = await this.countVideoFilesInDirectory(folder.path);
|
||||
const dbCountResult = await database
|
||||
.select({ count: count() })
|
||||
.from(titles)
|
||||
.where(eq(titles.libraryFolderId, folder.id));
|
||||
|
||||
const titlesCount = dbCountResult[0]?.count ?? 0;
|
||||
|
||||
this.logger.debug(
|
||||
`File count in folder: ${fileCount}, Titles in database: ${titlesCount}`,
|
||||
);
|
||||
|
||||
if (fileCount === titlesCount && titlesCount > 0) {
|
||||
this.logger.info(
|
||||
`Skipping scan for "${folder.name}" - file count matches database (${fileCount} files)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startTime: number = Date.now();
|
||||
|
||||
this.logger.info(
|
||||
`Scanning library folder "${folder.name}" (${folder.type}) at ${folder.path}`,
|
||||
@@ -180,7 +132,7 @@ export class LibraryScannerService extends BaseService {
|
||||
folder.id,
|
||||
);
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
const duration: string = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
this.logger.success(
|
||||
`Completed scanning "${folder.name}" in ${duration} seconds`,
|
||||
);
|
||||
@@ -193,7 +145,10 @@ export class LibraryScannerService extends BaseService {
|
||||
metadataLanguage: MetadataLanguage,
|
||||
libraryFolderId?: number,
|
||||
): Promise<{ files: number; directories: number }> {
|
||||
const stats = { files: 0, directories: 0 };
|
||||
const stats: { files: number; directories: number } = {
|
||||
files: 0,
|
||||
directories: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.debug(
|
||||
@@ -216,8 +171,24 @@ export class LibraryScannerService extends BaseService {
|
||||
})) {
|
||||
stats.files++;
|
||||
|
||||
const absolutePath = join(directoryPath, relativeFilePath);
|
||||
const fileName = relativeFilePath.split("/").pop() || relativeFilePath;
|
||||
const absolutePath: string = join(directoryPath, relativeFilePath);
|
||||
const fileName: string =
|
||||
relativeFilePath.split("/").pop() || relativeFilePath;
|
||||
|
||||
// Check if this file path already exists in the database
|
||||
const existingMedia = await database
|
||||
.select()
|
||||
.from(mediaItems)
|
||||
.where(eq(mediaItems.path, absolutePath))
|
||||
.limit(1);
|
||||
|
||||
if (existingMedia.length > 0) {
|
||||
this.logger.debug(
|
||||
`[${libraryName}] ✓ Already in database, skipping: ${fileName}`,
|
||||
);
|
||||
skippedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (libraryType) {
|
||||
case "MOVIE": {
|
||||
@@ -283,20 +254,6 @@ export class LibraryScannerService extends BaseService {
|
||||
`${indent}[${libraryName}] Processing movie file: ${filename}`,
|
||||
);
|
||||
|
||||
// Check if this file path already exists in the database
|
||||
const existingMedia = await database
|
||||
.select()
|
||||
.from(mediaItems)
|
||||
.where(eq(mediaItems.path, absolutePath))
|
||||
.limit(1);
|
||||
|
||||
if (existingMedia.length > 0) {
|
||||
this.logger.debug(
|
||||
`${indent}[${libraryName}] ✓ Already in database: ${filename}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = this.movieNamings.parseFilename(filename);
|
||||
if (!parsed) {
|
||||
this.logger.warn(
|
||||
@@ -320,16 +277,18 @@ export class LibraryScannerService extends BaseService {
|
||||
}
|
||||
|
||||
// Parse language from audio streams
|
||||
const detectedLanguage = this.detectLanguageFromStreams(mediaInfo.streams);
|
||||
const duration = Math.round((mediaInfo.format.duration || 0) / 60);
|
||||
const detectedLanguage: string | null = this.detectLanguageFromStreams(
|
||||
mediaInfo.streams,
|
||||
);
|
||||
const durationSeconds: number = Math.round(mediaInfo.format.duration || 0);
|
||||
|
||||
this.logger.debug(
|
||||
`${indent} → Duration: ${duration} minutes, Language: ${detectedLanguage || "unknown"}`,
|
||||
`${indent} → Duration: ${Math.round(durationSeconds / 60)} minutes, Language: ${detectedLanguage || "unknown"}`,
|
||||
);
|
||||
|
||||
const info: MovieInfo = {
|
||||
fileName: filename,
|
||||
runtimeMinutes: duration,
|
||||
runtimeSeconds: durationSeconds,
|
||||
title: parsed.title,
|
||||
year: parsed.year,
|
||||
language: detectedLanguage || "en", // fallback to English
|
||||
@@ -338,11 +297,27 @@ export class LibraryScannerService extends BaseService {
|
||||
|
||||
this.logger.debug(`${indent} → Fetching metadata from providers...`);
|
||||
const metadata = await this.metadataService.resolveMovie(info);
|
||||
|
||||
// Create placeholder if metadata not found
|
||||
const finalMetadata: ResolvedMovieMetadata = metadata || {
|
||||
title: parsed.title,
|
||||
originalTitle: parsed.title,
|
||||
year: parsed.year,
|
||||
description: "",
|
||||
releaseDate: "",
|
||||
genres: [],
|
||||
countries: [],
|
||||
posterUrl: "",
|
||||
backdropUrl: "",
|
||||
rating: 0,
|
||||
language: detectedLanguage || "en",
|
||||
providerIds: [],
|
||||
};
|
||||
|
||||
if (!metadata) {
|
||||
this.logger.warn(
|
||||
`${indent}[${libraryName}] ⚠ No metadata found for: "${parsed.title}"`,
|
||||
`${indent}[${libraryName}] ⚠ No metadata found for: "${parsed.title}", using placeholder data`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Insert movie and media item into database with proper relations
|
||||
@@ -365,33 +340,32 @@ export class LibraryScannerService extends BaseService {
|
||||
.values({
|
||||
libraryFolderId: libraryFolderId,
|
||||
type: "MOVIE" as const,
|
||||
title: metadata.title,
|
||||
originalTitle: metadata.originalTitle,
|
||||
tagline: metadata.tagline || null,
|
||||
overview: metadata.description,
|
||||
year: metadata.year,
|
||||
releaseDate: metadata.releaseDate,
|
||||
poster: metadata.posterUrl,
|
||||
backdrop: metadata.backdropUrl || null,
|
||||
rating: metadata.rating || null,
|
||||
originalLanguage: metadata.originalLanguage || null,
|
||||
homepage: metadata.homepage || null,
|
||||
title: finalMetadata.title,
|
||||
originalTitle: finalMetadata.originalTitle,
|
||||
tagline: finalMetadata.tagline || null,
|
||||
overview: finalMetadata.description,
|
||||
year: finalMetadata.year,
|
||||
releaseDate: finalMetadata.releaseDate,
|
||||
poster: finalMetadata.posterUrl,
|
||||
backdrop: finalMetadata.backdropUrl || null,
|
||||
rating: finalMetadata.rating || null,
|
||||
originalLanguage: finalMetadata.originalLanguage || null,
|
||||
homepage: finalMetadata.homepage || null,
|
||||
})
|
||||
.returning({ id: titles.id });
|
||||
|
||||
const titleId = insertedTitle[0].id;
|
||||
const titleId: string = insertedTitle[0].id;
|
||||
|
||||
// 2. Insert movie with reference to title
|
||||
const insertedMovie = await tx
|
||||
.insert(movies)
|
||||
.values({
|
||||
titleId: titleId,
|
||||
runtimeSeconds:
|
||||
(metadata.runtimeMinutes || info.runtimeMinutes) * 60, // Convert to seconds
|
||||
runtimeSeconds: durationSeconds,
|
||||
})
|
||||
.returning({ id: movies.id });
|
||||
|
||||
const movieId = insertedMovie[0].id;
|
||||
const movieId: string = insertedMovie[0].id;
|
||||
|
||||
// 3. Insert media item (file information) into mediaItems table
|
||||
const insertedMediaItem = await tx
|
||||
@@ -407,11 +381,11 @@ export class LibraryScannerService extends BaseService {
|
||||
})
|
||||
.returning({ id: mediaItems.id });
|
||||
|
||||
const mediaItemId = insertedMediaItem[0].id;
|
||||
const mediaItemId: number = insertedMediaItem[0].id;
|
||||
|
||||
// 4. Insert genres if available in metadata
|
||||
if (metadata.genres && metadata.genres.length > 0) {
|
||||
for (const genreName of metadata.genres) {
|
||||
if (finalMetadata.genres && finalMetadata.genres.length > 0) {
|
||||
for (const genreName of finalMetadata.genres) {
|
||||
// First, ensure the genre exists
|
||||
const existingGenre = await tx
|
||||
.select({ id: genres.id })
|
||||
@@ -444,13 +418,13 @@ export class LibraryScannerService extends BaseService {
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
this.logger.debug(
|
||||
`${indent} → Inserted ${metadata.genres.length} genres for title`,
|
||||
`${indent} → Inserted ${finalMetadata.genres.length} genres for title`,
|
||||
);
|
||||
}
|
||||
|
||||
// Insert external provider IDs (TMDB, IMDB, etc.)
|
||||
if (metadata.providerIds && metadata.providerIds.length > 0) {
|
||||
for (const providerId of metadata.providerIds) {
|
||||
if (finalMetadata.providerIds && finalMetadata.providerIds.length > 0) {
|
||||
for (const providerId of finalMetadata.providerIds) {
|
||||
await tx
|
||||
.insert(titleProviders)
|
||||
.values({
|
||||
@@ -529,7 +503,7 @@ export class LibraryScannerService extends BaseService {
|
||||
});
|
||||
|
||||
// 6. Resolve and insert credits (cast and crew) after movie is created
|
||||
if (createdMovieId) {
|
||||
if (createdMovieId && metadata) {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`${indent} → Fetching credits from metadata provider...`,
|
||||
@@ -555,7 +529,7 @@ export class LibraryScannerService extends BaseService {
|
||||
}
|
||||
|
||||
this.logger.success(
|
||||
`${indent}[${libraryName}] ✓ Added: "${metadata.title}" (${metadata.year}) - ${info.runtimeMinutes}min`,
|
||||
`${indent}[${libraryName}] ✓ Added: "${finalMetadata.title}" (${finalMetadata.year}) - ${Math.round(durationSeconds / 60)}min`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -584,13 +558,13 @@ export class LibraryScannerService extends BaseService {
|
||||
streams: ffmpeg.FfprobeStream[],
|
||||
): string | null {
|
||||
// Find audio streams and extract language
|
||||
const audioStreams = streams.filter(
|
||||
const audioStreams: ffmpeg.FfprobeStream[] = streams.filter(
|
||||
(stream) => stream.codec_type === "audio",
|
||||
);
|
||||
|
||||
for (const stream of audioStreams) {
|
||||
if (stream.tags?.language) {
|
||||
const langCode = stream.tags.language.toLowerCase();
|
||||
const langCode: string = stream.tags.language.toLowerCase();
|
||||
|
||||
// Try to find language using different code formats
|
||||
let language = findBy("1", langCode); // ISO 639-1 (2-letter)
|
||||
@@ -625,20 +599,6 @@ export class LibraryScannerService extends BaseService {
|
||||
`${indent}[${libraryName}] Processing series file: ${filename}`,
|
||||
);
|
||||
|
||||
// Check if this file path already exists in the database
|
||||
const existingMedia = await database
|
||||
.select()
|
||||
.from(mediaItems)
|
||||
.where(eq(mediaItems.path, absolutePath))
|
||||
.limit(1);
|
||||
|
||||
if (existingMedia.length > 0) {
|
||||
this.logger.debug(
|
||||
`${indent}[${libraryName}] ✓ Already in database: ${filename}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = await this.seriesNamings.parseFilename(filename);
|
||||
if (!parsed) {
|
||||
this.logger.warn(
|
||||
@@ -662,8 +622,10 @@ export class LibraryScannerService extends BaseService {
|
||||
}
|
||||
|
||||
// Parse language from audio streams and get duration
|
||||
const detectedLanguage = this.detectLanguageFromStreams(mediaInfo.streams);
|
||||
const durationSeconds = Math.round(mediaInfo.format.duration || 0);
|
||||
const detectedLanguage: string | null = this.detectLanguageFromStreams(
|
||||
mediaInfo.streams,
|
||||
);
|
||||
const durationSeconds: number = Math.round(mediaInfo.format.duration || 0);
|
||||
|
||||
this.logger.debug(
|
||||
`${indent} → Duration: ${Math.round(durationSeconds / 60)} minutes, Language: ${detectedLanguage || "unknown"}`,
|
||||
@@ -674,7 +636,7 @@ export class LibraryScannerService extends BaseService {
|
||||
try {
|
||||
const seriesInfo: SeriesInfo = {
|
||||
fileName: filename,
|
||||
runtimeMinutes: Math.round(durationSeconds / 60),
|
||||
runtimeSeconds: durationSeconds,
|
||||
title: parsed.title,
|
||||
year: new Date().getFullYear(), // Use current year as fallback
|
||||
language: detectedLanguage || "en",
|
||||
@@ -712,7 +674,7 @@ export class LibraryScannerService extends BaseService {
|
||||
// 1. Find or create series
|
||||
let seriesId: string;
|
||||
let titleId: string;
|
||||
const seriesTitle = resolvedMetadata?.title || parsed.title;
|
||||
const seriesTitle: string = resolvedMetadata?.title || parsed.title;
|
||||
|
||||
// Find existing title with this series title
|
||||
const existingTitle = await tx
|
||||
@@ -784,8 +746,9 @@ export class LibraryScannerService extends BaseService {
|
||||
|
||||
// 2. Find or create season
|
||||
let seasonId: string;
|
||||
const seasonNumber = resolvedMetadata?.season.number || parsed.season;
|
||||
const seasonName =
|
||||
const seasonNumber: number =
|
||||
resolvedMetadata?.season.number || parsed.season;
|
||||
const seasonName: string =
|
||||
resolvedMetadata?.season.name || `Season ${seasonNumber}`;
|
||||
const existingSeason = await tx
|
||||
.select({ id: seasons.id })
|
||||
@@ -813,15 +776,14 @@ export class LibraryScannerService extends BaseService {
|
||||
}
|
||||
|
||||
// 3. Create episode
|
||||
const episodeNumber =
|
||||
const episodeNumber: number =
|
||||
resolvedMetadata?.episode.number || parsed.episode;
|
||||
const episodeTitle =
|
||||
const episodeTitle: string =
|
||||
resolvedMetadata?.episode.title || `Episode ${episodeNumber}`;
|
||||
const episodeDescription =
|
||||
const episodeDescription: string | null =
|
||||
resolvedMetadata?.episode.description || null;
|
||||
const episodeRuntime = resolvedMetadata?.runtimeMinutes
|
||||
? resolvedMetadata.runtimeMinutes * 60
|
||||
: durationSeconds;
|
||||
const episodeRuntime: number =
|
||||
resolvedMetadata?.runtimeSeconds || durationSeconds;
|
||||
|
||||
const insertedEpisode = await tx
|
||||
.insert(episodes)
|
||||
@@ -837,7 +799,7 @@ export class LibraryScannerService extends BaseService {
|
||||
})
|
||||
.returning({ id: episodes.id });
|
||||
|
||||
const episodeId = insertedEpisode[0].id;
|
||||
const episodeId: string = insertedEpisode[0].id;
|
||||
|
||||
// 4. Insert media item
|
||||
const insertedMediaItem = await tx
|
||||
@@ -853,7 +815,7 @@ export class LibraryScannerService extends BaseService {
|
||||
})
|
||||
.returning({ id: mediaItems.id });
|
||||
|
||||
const mediaItemId = insertedMediaItem[0].id;
|
||||
const mediaItemId: number = insertedMediaItem[0].id;
|
||||
|
||||
// 5. Insert stream information (video, audio, subtitle tracks)
|
||||
if (mediaInfo.streams && mediaInfo.streams.length > 0) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
MovieInfo,
|
||||
PersonInfo,
|
||||
ResolvedMovieMetadata,
|
||||
ResolvedPerson,
|
||||
ResolvedSeriesMetadata,
|
||||
ResolvedTitleCredits,
|
||||
ResolvedMovieMetadataResult,
|
||||
ResolvedPersonResult,
|
||||
ResolvedSeriesMetadataResult,
|
||||
ResolvedTitleCreditsResult,
|
||||
SeriesInfo,
|
||||
TitleCreditsInfo,
|
||||
} from "@/models";
|
||||
@@ -56,7 +56,7 @@ export class MetadataService extends BaseService {
|
||||
|
||||
public async resolveMovie(
|
||||
info: MovieInfo,
|
||||
): Promise<ResolvedMovieMetadata | null> {
|
||||
): Promise<ResolvedMovieMetadataResult> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
return await provider.resolveMovie(info);
|
||||
@@ -72,7 +72,7 @@ export class MetadataService extends BaseService {
|
||||
|
||||
public async resolveSeries(
|
||||
info: SeriesInfo,
|
||||
): Promise<ResolvedSeriesMetadata | null> {
|
||||
): Promise<ResolvedSeriesMetadataResult> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
return await provider.resolveSeries(info);
|
||||
@@ -86,33 +86,9 @@ export class MetadataService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async resolveCredits(
|
||||
movieId: number,
|
||||
seriesId?: number,
|
||||
): Promise<null> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
// For now, we'll fetch credits by movie/series ID
|
||||
// Providers should implement this based on their API
|
||||
await provider.resolveCredits({
|
||||
creditId: String(movieId || seriesId),
|
||||
});
|
||||
// Transform single credit to Credits structure if needed
|
||||
// This is a placeholder - actual implementation depends on provider
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Provider ${provider.name} failed to resolve credits.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async resolveTitleCredits(
|
||||
info: TitleCreditsInfo,
|
||||
): Promise<ResolvedTitleCredits | null> {
|
||||
): Promise<ResolvedTitleCreditsResult> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
return await provider.resolveTitleCredits(info);
|
||||
@@ -126,7 +102,7 @@ export class MetadataService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async resolvePerson(info: PersonInfo): Promise<ResolvedPerson | null> {
|
||||
public async resolvePerson(info: PersonInfo): Promise<ResolvedPersonResult> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
return await provider.resolvePeople(info);
|
||||
|
||||
@@ -153,7 +153,7 @@ const RecentlyAddedSections: React.FC = () => {
|
||||
<div key={movie.id} className="w-27 flex-none sm:w-32 md:w-40">
|
||||
<MovieCard
|
||||
id={movie.id}
|
||||
imageUrl={movie.poster || "/placeholder-movie.jpg"}
|
||||
imageUrl={movie.poster ?? undefined}
|
||||
title={movie.title}
|
||||
description={movie.year || ""}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function TitleDetails({ data }: TitleDetailsProps) {
|
||||
{/* Backdrop with gradient overlay */}
|
||||
<div className="absolute inset-0 h-[70vh] lg:h-[80vh]">
|
||||
<img
|
||||
src={data.backdrop ?? "/placeholder.svg"}
|
||||
src={data.backdrop ?? "/movie-placeholder.svg"}
|
||||
alt={`${data.title} backdrop`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -108,9 +108,9 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
|
||||
public async resolveMovie(
|
||||
info: Models.MovieInfo,
|
||||
): Promise<Models.ResolvedMovieMetadata> {
|
||||
): Promise<Models.ResolvedMovieMetadataResult> {
|
||||
if (!this.client) {
|
||||
return this.buildPlaceholderMovie(info);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all pages of search results
|
||||
@@ -135,7 +135,7 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
} while (currentPage <= totalPages);
|
||||
|
||||
if (allMovies.length === 0) {
|
||||
return this.buildPlaceholderMovie(info);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter by original_language matching info.language
|
||||
@@ -180,9 +180,9 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
|
||||
// Calculate runtime difference (normalize to 0-100 scale)
|
||||
let runtimeScore = 50; // default if no runtime available
|
||||
if (details.runtime && info.runtimeMinutes) {
|
||||
if (details.runtime && info.runtimeSeconds) {
|
||||
const runtimeDifference = Math.abs(
|
||||
details.runtime - info.runtimeMinutes,
|
||||
details.runtime * 60 - info.runtimeSeconds,
|
||||
);
|
||||
runtimeScore = Math.min(runtimeDifference, 100);
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
|
||||
// Get detailed info for the best match
|
||||
if (!bestMatch) {
|
||||
return this.buildPlaceholderMovie(info);
|
||||
return null;
|
||||
}
|
||||
|
||||
const movieDetails = await this.client.movies.details(bestMatch.id, [
|
||||
@@ -244,7 +244,6 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
? localizedDetails.overview
|
||||
: movieDetails.overview,
|
||||
releaseDate: localizedDetails.release_date,
|
||||
runtimeMinutes: localizedDetails.runtime,
|
||||
genres: localizedDetails.genres?.map((g) => g.name) || [],
|
||||
countries:
|
||||
localizedDetails.production_countries?.map((c) => c.name) || [],
|
||||
@@ -283,7 +282,6 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
? localizedDetails.overview
|
||||
: movieDetails.overview,
|
||||
releaseDate: localizedDetails.release_date,
|
||||
runtimeMinutes: localizedDetails.runtime,
|
||||
genres: movieDetails.genres?.map((g) => g.name) || [],
|
||||
countries:
|
||||
localizedDetails.production_countries?.map((c) => c.name) || [],
|
||||
@@ -325,7 +323,6 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
: info.year,
|
||||
description: movieDetails.overview,
|
||||
releaseDate: movieDetails.release_date,
|
||||
runtimeMinutes: movieDetails.runtime,
|
||||
genres: movieDetails.genres?.map((g) => g.name) || [],
|
||||
countries: movieDetails.production_countries?.map((c) => c.name) || [],
|
||||
posterUrl: `https://image.tmdb.org/t/p/w500${movieDetails.poster_path}`,
|
||||
@@ -341,56 +338,23 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
}
|
||||
|
||||
public async resolveSeries(
|
||||
info: Models.SeriesInfo,
|
||||
): Promise<Models.ResolvedSeriesMetadata> {
|
||||
_info: Models.SeriesInfo,
|
||||
): Promise<Models.ResolvedSeriesMetadataResult> {
|
||||
if (!this.client) {
|
||||
return this.buildPlaceholderSeries(info);
|
||||
return null;
|
||||
}
|
||||
// TODO: Implement TMDB Series resolution
|
||||
throw new ExtensionError("TMDB series resolution not implemented.");
|
||||
}
|
||||
|
||||
public async resolveCredits(
|
||||
info: Models.CreditInfo,
|
||||
): Promise<Models.ResolvedCredit> {
|
||||
if (!this.client) {
|
||||
this.logger.debug(
|
||||
"Skipping TMDB credit resolution because client is not configured.",
|
||||
);
|
||||
return {
|
||||
id: info.creditId,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const creditDetails = await this.client.credits.getById(info.creditId);
|
||||
|
||||
return {
|
||||
creditType: creditDetails.credit_type,
|
||||
department: creditDetails.department,
|
||||
job: creditDetails.job,
|
||||
mediaType: creditDetails.media_type,
|
||||
id: creditDetails.id || info.creditId,
|
||||
personId: creditDetails.person?.id,
|
||||
personName: creditDetails.person?.name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Error resolving TMDB credit:", error);
|
||||
throw new ExtensionError(`TMDB credit resolution failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async resolveTitleCredits(
|
||||
info: Models.TitleCreditsInfo,
|
||||
): Promise<Models.ResolvedTitleCredits> {
|
||||
): Promise<Models.ResolvedTitleCreditsResult> {
|
||||
if (!this.client) {
|
||||
this.logger.debug(
|
||||
"Skipping TMDB title credits resolution because client is not configured.",
|
||||
);
|
||||
return {
|
||||
cast: [],
|
||||
crew: [],
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -448,24 +412,12 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
|
||||
public async resolvePeople(
|
||||
info: Models.PersonInfo,
|
||||
): Promise<Models.ResolvedPerson> {
|
||||
): Promise<Models.ResolvedPersonResult> {
|
||||
if (!this.client) {
|
||||
this.logger.debug(
|
||||
"Skipping TMDB person resolution because client is not configured.",
|
||||
);
|
||||
return {
|
||||
id: info.personId,
|
||||
name: "",
|
||||
biography: "",
|
||||
birthday: null,
|
||||
deathday: null,
|
||||
placeOfBirth: null,
|
||||
profilePath: null,
|
||||
knownForDepartment: "",
|
||||
popularity: 0,
|
||||
imdbId: null,
|
||||
homepage: null,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -491,33 +443,4 @@ export default class TMDBMetadataProviderExtension extends MetadataProvider {
|
||||
throw new ExtensionError(`TMDB person resolution failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildPlaceholderMovie(
|
||||
info: Models.MovieInfo,
|
||||
): Models.ResolvedMovieMetadata {
|
||||
const parsedYear = info.year;
|
||||
return {
|
||||
description: "",
|
||||
title: info.title || info.fileName,
|
||||
originalTitle:
|
||||
info.title && info.title !== info.fileName ? info.fileName : "",
|
||||
year: Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear,
|
||||
releaseDate: "",
|
||||
runtimeMinutes: info.runtimeMinutes ?? 0,
|
||||
genres: [],
|
||||
countries: [],
|
||||
posterUrl: "",
|
||||
backdropUrl: "",
|
||||
rating: 0,
|
||||
language: "",
|
||||
providerIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
private buildPlaceholderSeries(
|
||||
_info: Models.SeriesInfo,
|
||||
): Models.ResolvedSeriesMetadata {
|
||||
// TODO: Implement build placeholder based information on arguments
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
hasLanguage,
|
||||
type Language,
|
||||
type LanguageCodeType,
|
||||
type LanguageCriteria,
|
||||
langs, // Legacy API
|
||||
} from "./index";
|
||||
|
||||
@@ -99,7 +98,7 @@ function runTests() {
|
||||
const name: string = language.name;
|
||||
const local: string = language.local;
|
||||
const iso1: string = language["1"];
|
||||
const iso2: string = language["2"];
|
||||
const _iso2: string = language["2"];
|
||||
console.log(` ✅ Type-safe access: ${name} (${iso1}) - ${local}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user