Compare commits

...

4 Commits

Author SHA1 Message Date
d4296e5c7b refactor(server): update metadata resolution to handle missing data with null returns
Updated metadata provider interfaces and implementations to return null instead of placeholder objects when data cannot be resolved. This improves error handling and reduces unnecessary object creation.

- Modified schema types to include null unions (e.g., ResolvedMovieMetadataResult)
- Changed runtime fields from minutes to seconds for consistency
- Removed placeholder builders in TMDB provider, returning null directly
- Optimized library scanner by removing redundant file counting and adding early exits for existing media
- Ensured type safety across services and providers

BREAKING CHANGE: Metadata resolution methods now return null for unresolved data, requiring consumers to handle null cases explicitly.
2025-10-03 12:46:59 +00:00
afbaebe4f1 fix(ui): update placeholder image fallbacks in nontara components
Update fallback image URLs in RecentlyAddedSections and TitleDetails components for consistency and correct placeholder usage.
2025-10-03 12:36:00 +00:00
80c444dd34 refactor(server): update imports and remove unused code
- Remove unused type import in auth.ts
- Clean up commented code in BaseService.ts
- Update fs and path imports to node: prefixed versions in extensions/index.ts
- Improve string formatting in main.ts and fix unused variable in language-codes test
2025-10-03 10:55:16 +00:00
078738f502 refactor(metadata): remove credit resolution functionality
Removed unused credit-related schemas, types, and methods from models, providers, services, and TMDB extension. This simplifies the codebase by eliminating deprecated individual credit resolution in favor of title-based credits fetching.

BREAKING CHANGE: Removed resolveCredits method and associated schemas, which may affect integrations relying on individual credit resolution.
2025-10-03 09:56:26 +00:00
14 changed files with 139 additions and 322 deletions

View File

@@ -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";

View File

@@ -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();

View File

@@ -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;

View File

@@ -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(),

View File

@@ -40,3 +40,4 @@ export const ResolvedPersonSchema = z.object({
});
export type ResolvedPerson = z.infer<typeof ResolvedPersonSchema>;
export type ResolvedPersonResult = ResolvedPerson | null;

View File

@@ -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>;
}

View File

@@ -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);

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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 || ""}
/>

View File

@@ -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"
/>

View File

@@ -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.");
}
}

View File

@@ -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}`);
}