Compare commits
3 Commits
fdcb245997
...
124705ca84
| Author | SHA1 | Date | |
|---|---|---|---|
| 124705ca84 | |||
| a23b25c953 | |||
| 78f1855ce4 |
@@ -51,6 +51,13 @@
|
||||
|
||||
## Code Quality and Linting
|
||||
|
||||
### IDE Diagnostics - REQUIRED FIRST STEP
|
||||
- **MUST check IDE diagnostics BEFORE running the linter**
|
||||
- Use `getIdeDiagnostics` tool to check for TypeScript errors and warnings
|
||||
- Resolve ALL diagnostic problems (type errors, unused imports, etc.) first
|
||||
- Only after fixing IDE diagnostics should you proceed to run the linter
|
||||
- This prevents linting cosmetic issues while type errors remain unfixed
|
||||
|
||||
### Biome Linter - MANDATORY
|
||||
- **MUST run Biome linter after creating or editing any file**
|
||||
- Run linter command: `bun run check` (from project root)
|
||||
|
||||
@@ -27,26 +27,16 @@ export const STREAM_CODEC_TYPES = [
|
||||
] as const;
|
||||
export type StreamCodecType = (typeof STREAM_CODEC_TYPES)[number];
|
||||
|
||||
// people roles (actor, director, writer, etc.)
|
||||
export const PEOPLE_ROLE_TYPES = [
|
||||
"ACTOR",
|
||||
"DIRECTOR",
|
||||
"WRITER",
|
||||
"PRODUCER",
|
||||
"EXECUTIVE_PRODUCER",
|
||||
"CINEMATOGRAPHER",
|
||||
"EDITOR",
|
||||
"COMPOSER",
|
||||
"SOUND_DESIGNER",
|
||||
"PRODUCTION_DESIGNER",
|
||||
"COSTUME_DESIGNER",
|
||||
"MAKEUP_ARTIST",
|
||||
"VISUAL_EFFECTS_SUPERVISOR",
|
||||
"CASTING_DIRECTOR",
|
||||
"STUNT_COORDINATOR",
|
||||
"NARRATOR",
|
||||
"GUEST_STAR",
|
||||
"CREATOR",
|
||||
"SHOWRUNNER",
|
||||
] as const;
|
||||
// people roles (cast or crew)
|
||||
export const PEOPLE_ROLE_TYPES = ["CAST", "CREW"] as const;
|
||||
export type PeopleRoleType = (typeof PEOPLE_ROLE_TYPES)[number];
|
||||
|
||||
// metadata provider types (external services for metadata)
|
||||
export const METADATA_PROVIDER_TYPES = [
|
||||
"TMDB", // The Movie Database
|
||||
"IMDB", // Internet Movie Database
|
||||
"OMDB", // Open Movie Database
|
||||
"TVDB", // The TV Database
|
||||
"TRAKT", // Trakt
|
||||
] as const;
|
||||
export type MetadataProviderType = (typeof METADATA_PROVIDER_TYPES)[number];
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import type { PeopleRoleType } from "../enums";
|
||||
import { movies } from "./movies";
|
||||
import { people } from "./people";
|
||||
@@ -16,7 +22,9 @@ export const credits = sqliteTable(
|
||||
personId: text("person_id")
|
||||
.notNull()
|
||||
.references(() => people.id, { onDelete: "cascade" }),
|
||||
|
||||
// Reference to content (movie, series, or episode)
|
||||
// Only one of these should be set per credit
|
||||
movieId: text("movie_id").references(() => movies.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
@@ -26,22 +34,60 @@ export const credits = sqliteTable(
|
||||
episodeId: text("episode_id").references(() => episodes.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
// Role information
|
||||
role: text("role").notNull().$type<PeopleRoleType>(),
|
||||
character: text("character"), // For actors - character name they play
|
||||
job: text("job"), // For crew - specific job title (e.g., "Director of Photography")
|
||||
department: text("department"), // e.g., "Acting", "Directing", "Writing", "Production"
|
||||
order: integer("order"), // For billing order/credits order
|
||||
|
||||
// Role type: CAST or CREW
|
||||
role: text("role").notNull().$type<PeopleRoleType>(), // "CAST" | "CREW"
|
||||
|
||||
// CAST-specific fields
|
||||
character: text("character"), // Character name for cast members
|
||||
|
||||
// CREW-specific fields
|
||||
job: text("job"), // Specific job title (e.g., "Director", "Producer", "Director of Photography")
|
||||
department: text("department"), // Department (e.g., "Directing", "Writing", "Production", "Camera")
|
||||
|
||||
// Display order
|
||||
order: integer("order"), // Billing/credits display order (lower numbers appear first)
|
||||
|
||||
// Timestamps
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.$defaultFn(() => new Date())
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// Indexes for efficient lookups
|
||||
index("idx_credits_person").on(t.personId),
|
||||
index("idx_credits_movie").on(t.movieId),
|
||||
index("idx_credits_series").on(t.seriesId),
|
||||
index("idx_credits_episode").on(t.episodeId),
|
||||
index("idx_credits_role").on(t.role),
|
||||
index("idx_credits_department").on(t.department),
|
||||
index("idx_credits_order").on(t.order),
|
||||
|
||||
// Unique constraints to prevent duplicate credits
|
||||
uniqueIndex("ux_credits_movie_person_role").on(
|
||||
t.movieId,
|
||||
t.personId,
|
||||
t.role,
|
||||
t.character,
|
||||
t.job,
|
||||
),
|
||||
uniqueIndex("ux_credits_series_person_role").on(
|
||||
t.seriesId,
|
||||
t.personId,
|
||||
t.role,
|
||||
t.character,
|
||||
t.job,
|
||||
),
|
||||
uniqueIndex("ux_credits_episode_person_role").on(
|
||||
t.episodeId,
|
||||
t.personId,
|
||||
t.role,
|
||||
t.character,
|
||||
t.job,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
index,
|
||||
integer,
|
||||
primaryKey,
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import type { LibraryType, MetadataProviderType } from "../enums";
|
||||
import { libraryFolders } from "../libraries";
|
||||
import { genres } from "./genres";
|
||||
|
||||
@@ -20,12 +22,33 @@ export const titles = sqliteTable(
|
||||
libraryFolderId: integer("library_folder_id")
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull().$type<LibraryType>(), // "MOVIE" | "SERIES"
|
||||
|
||||
// Basic information
|
||||
title: text("title").notNull(),
|
||||
originalTitle: text("original_title"),
|
||||
tagline: text("tagline"),
|
||||
overview: text("overview"),
|
||||
|
||||
// Date information
|
||||
year: integer("year"),
|
||||
releaseDate: text("release_date"), // "YYYY-MM-DD"
|
||||
|
||||
// Media assets
|
||||
poster: text("poster"),
|
||||
backdrop: text("backdrop"),
|
||||
|
||||
// Ratings and popularity
|
||||
voteAverage: real("vote_average"), // e.g., 7.5
|
||||
voteCount: integer("vote_count"),
|
||||
popularity: real("popularity"),
|
||||
|
||||
// Language
|
||||
originalLanguage: text("original_language"), // ISO 639-1 code (e.g., "en", "id")
|
||||
|
||||
// Additional metadata
|
||||
homepage: text("homepage"),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
@@ -36,7 +59,10 @@ export const titles = sqliteTable(
|
||||
},
|
||||
(t) => [
|
||||
index("idx_titles_library").on(t.libraryFolderId),
|
||||
index("idx_titles_type").on(t.type),
|
||||
index("idx_titles_title").on(t.title),
|
||||
index("idx_titles_year").on(t.year),
|
||||
index("idx_titles_popularity").on(t.popularity),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -60,3 +86,28 @@ export const titleGenres = sqliteTable(
|
||||
index("idx_title_genres_genre").on(t.genreId),
|
||||
],
|
||||
);
|
||||
|
||||
// ----- TITLE_PROVIDERS (junction table for external metadata provider IDs) -----
|
||||
export const titleProviders = sqliteTable(
|
||||
"title_providers",
|
||||
{
|
||||
titleId: text("title_id")
|
||||
.notNull()
|
||||
.references(() => titles.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull().$type<MetadataProviderType>(), // "TMDB" | "IMDB" | "OMDB" | etc.
|
||||
providerId: text("provider_id").notNull(), // The ID from the provider (e.g., "tt1234567" for IMDB, "550" for TMDB)
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.$defaultFn(() => new Date())
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.titleId, t.provider] }),
|
||||
index("idx_title_providers_title").on(t.titleId),
|
||||
index("idx_title_providers_provider").on(t.provider),
|
||||
index("idx_title_providers_provider_id").on(t.provider, t.providerId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -58,3 +58,19 @@ export const ResolvedCreditSchema = z.object({
|
||||
});
|
||||
|
||||
export type ResolvedCredit = z.infer<typeof ResolvedCreditSchema>;
|
||||
|
||||
// Info for fetching credits by title
|
||||
export const TitleCreditsInfoSchema = z.object({
|
||||
titleType: z.enum(["movie", "series"]),
|
||||
providerId: z.string(),
|
||||
});
|
||||
|
||||
export type TitleCreditsInfo = z.infer<typeof TitleCreditsInfoSchema>;
|
||||
|
||||
// Resolved credits for a title (cast + crew)
|
||||
export const ResolvedTitleCreditsSchema = z.object({
|
||||
cast: z.array(CastSchema),
|
||||
crew: z.array(CrewSchema),
|
||||
});
|
||||
|
||||
export type ResolvedTitleCredits = z.infer<typeof ResolvedTitleCreditsSchema>;
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// CAST member (actor)
|
||||
export const Casting = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string(),
|
||||
character: z.string(),
|
||||
profileUrl: z.string(),
|
||||
order: z.number().optional(), // Billing order
|
||||
profileUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Casting = z.infer<typeof Casting>;
|
||||
|
||||
// CREW member (director, writer, producer, etc.)
|
||||
export const Crew = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
profileUrl: z.string(),
|
||||
job: z.string(), // e.g., "Director", "Producer", "Writer"
|
||||
department: z.string().optional(), // e.g., "Directing", "Production", "Writing"
|
||||
profileUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Crew = z.infer<typeof Crew>;
|
||||
|
||||
// Combined credits
|
||||
export const Credits = z.object({
|
||||
cast: Casting.array(),
|
||||
crew: Crew.array(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { METADATA_PROVIDER_TYPES } from "@/db/schema/enums";
|
||||
import { MetadataLanguage } from "./language";
|
||||
|
||||
export const MovieInfo = z.object({
|
||||
@@ -34,19 +35,33 @@ export const SeriesInfo = z.object({
|
||||
});
|
||||
export type SeriesInfo = z.infer<typeof SeriesInfo>;
|
||||
|
||||
// Provider ID schema matching titleProviders table
|
||||
export const ProviderId = z.object({
|
||||
provider: z.enum(METADATA_PROVIDER_TYPES), // "TMDB" | "IMDB" | "OMDB" | "TVDB" | "TRAKT"
|
||||
providerId: z.string(), // The ID from the provider
|
||||
});
|
||||
export type ProviderId = z.infer<typeof ProviderId>;
|
||||
|
||||
export const ResolvedMovieMetadata = z.object({
|
||||
title: z.string(),
|
||||
year: z.number(),
|
||||
description: z.string(),
|
||||
originalTitle: z.string(),
|
||||
tagline: z.string().optional(),
|
||||
releaseDate: z.string(),
|
||||
runtimeMinutes: z.number(),
|
||||
genres: z.string().array(),
|
||||
posterUrl: z.string(),
|
||||
backdropUrl: z.string(),
|
||||
rating: z.number(),
|
||||
voteAverage: z.number().optional(),
|
||||
voteCount: z.number().optional(),
|
||||
popularity: z.number().optional(),
|
||||
language: z.string(),
|
||||
originalLanguage: z.string().optional(),
|
||||
countries: z.string().array(),
|
||||
homepage: z.string().optional(),
|
||||
providerIds: ProviderId.array().optional(), // Array of external provider IDs
|
||||
});
|
||||
export type ResolvedMovieMetadata = z.infer<typeof ResolvedMovieMetadata>;
|
||||
|
||||
@@ -54,14 +69,22 @@ export const ResolvedSeriesMetadata = z.object({
|
||||
title: z.string(),
|
||||
originalTitle: z.string(),
|
||||
description: z.string(),
|
||||
tagline: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
status: z.string().optional(), // "RETURNING" | "ENDED" | ...
|
||||
genres: z.string().array(),
|
||||
posterUrl: z.string().optional(),
|
||||
backdropUrl: z.string().optional(),
|
||||
rating: z.number().optional(),
|
||||
voteAverage: z.number().optional(),
|
||||
voteCount: z.number().optional(),
|
||||
popularity: z.number().optional(),
|
||||
language: z.string(),
|
||||
originalLanguage: z.string().optional(),
|
||||
countries: z.string().array(),
|
||||
homepage: z.string().optional(),
|
||||
firstAirDate: z.string().optional(), // Series first air date
|
||||
providerIds: ProviderId.array().optional(), // Array of external provider IDs
|
||||
// Season and episode objects
|
||||
season: z.object({
|
||||
number: z.number(),
|
||||
@@ -72,7 +95,7 @@ export const ResolvedSeriesMetadata = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
airDate: z.string().optional(),
|
||||
airDate: z.string().optional(), // Episode air date
|
||||
runtimeMinutes: z.number().optional(),
|
||||
});
|
||||
export type ResolvedSeriesMetadata = z.infer<typeof ResolvedSeriesMetadata>;
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
ResolvedMovieMetadata,
|
||||
ResolvedPerson,
|
||||
ResolvedSeriesMetadata,
|
||||
ResolvedTitleCredits,
|
||||
SeriesInfo,
|
||||
TitleCreditsInfo,
|
||||
} from "@/models";
|
||||
import { Capabilites } from "@/services/extensions/baseExtension";
|
||||
import { createLogger } from "@/utils/logger";
|
||||
@@ -29,5 +31,8 @@ export abstract class MetadataProvider extends Provider {
|
||||
info: SeriesInfo,
|
||||
): Promise<ResolvedSeriesMetadata>;
|
||||
public abstract resolveCredits(info: CreditInfo): Promise<ResolvedCredit>;
|
||||
public abstract resolveTitleCredits(
|
||||
info: TitleCreditsInfo,
|
||||
): Promise<ResolvedTitleCredits>;
|
||||
public abstract resolvePeople(info: PersonInfo): Promise<ResolvedPerson>;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,18 @@ import { and, eq } from "drizzle-orm";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import { database } from "@/db";
|
||||
import {
|
||||
credits,
|
||||
episodes,
|
||||
genres,
|
||||
libraryFolders,
|
||||
mediaItems,
|
||||
mediaStreams,
|
||||
movies,
|
||||
people,
|
||||
seasons,
|
||||
series,
|
||||
titleGenres,
|
||||
titleProviders,
|
||||
titles,
|
||||
} from "@/db/schema";
|
||||
import type {
|
||||
@@ -25,7 +28,9 @@ import type {
|
||||
import type {
|
||||
MetadataLanguage,
|
||||
MovieInfo,
|
||||
ResolvedMovieMetadata,
|
||||
ResolvedSeriesMetadata,
|
||||
ResolvedTitleCredits,
|
||||
SeriesInfo,
|
||||
} from "@/models";
|
||||
import { MovieNamings } from "@/namings/movies";
|
||||
@@ -356,6 +361,9 @@ export class LibraryScannerService extends BaseService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Variable to store movieId outside transaction scope
|
||||
let createdMovieId: string | null = null;
|
||||
|
||||
// Start a transaction to ensure data consistency
|
||||
await database.transaction(async (tx) => {
|
||||
// 1. Insert title into titles table
|
||||
@@ -363,11 +371,20 @@ export class LibraryScannerService extends BaseService {
|
||||
.insert(titles)
|
||||
.values({
|
||||
libraryFolderId: libraryFolderId,
|
||||
type: "MOVIE" as const,
|
||||
title: metadata.title,
|
||||
originalTitle: metadata.originalTitle || null,
|
||||
tagline: metadata.tagline || null,
|
||||
overview: metadata.description,
|
||||
year: metadata.year,
|
||||
releaseDate: metadata.releaseDate,
|
||||
poster: metadata.posterUrl,
|
||||
backdrop: metadata.backdropUrl || null,
|
||||
voteAverage: metadata.voteAverage || null,
|
||||
voteCount: metadata.voteCount || null,
|
||||
popularity: metadata.popularity || null,
|
||||
originalLanguage: metadata.originalLanguage || null,
|
||||
homepage: metadata.homepage || null,
|
||||
})
|
||||
.returning({ id: titles.id });
|
||||
|
||||
@@ -440,6 +457,23 @@ export class LibraryScannerService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
// Insert external provider IDs (TMDB, IMDB, etc.)
|
||||
if (metadata.providerIds && metadata.providerIds.length > 0) {
|
||||
for (const providerId of metadata.providerIds) {
|
||||
await tx
|
||||
.insert(titleProviders)
|
||||
.values({
|
||||
titleId: titleId,
|
||||
provider: providerId.provider,
|
||||
providerId: providerId.providerId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
this.logger.debug(
|
||||
`${indent} → Linked ${providerId.provider} ID: ${providerId.providerId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Insert stream information (video, audio, subtitle tracks)
|
||||
if (mediaInfo.streams && mediaInfo.streams.length > 0) {
|
||||
const streamsToInsert = mediaInfo.streams.map((stream) => {
|
||||
@@ -455,8 +489,8 @@ export class LibraryScannerService extends BaseService {
|
||||
let frameRate: number | null = null;
|
||||
if (stream.r_frame_rate) {
|
||||
const [num, den] = stream.r_frame_rate.split("/");
|
||||
if (num && den && Number.parseInt(den) !== 0) {
|
||||
frameRate = Number.parseInt(num) / Number.parseInt(den);
|
||||
if (num && den && Number.parseInt(den, 10) !== 0) {
|
||||
frameRate = Number.parseInt(num, 10) / Number.parseInt(den, 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,11 +529,40 @@ export class LibraryScannerService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
// Store movieId for use after transaction
|
||||
createdMovieId = movieId;
|
||||
|
||||
this.logger.debug(
|
||||
`${indent} → Successfully inserted movie ID ${movieId} with media item ID ${mediaItemId}`,
|
||||
);
|
||||
});
|
||||
|
||||
// 6. Resolve and insert credits (cast and crew) after movie is created
|
||||
if (createdMovieId) {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`${indent} → Fetching credits from metadata provider...`,
|
||||
);
|
||||
const creditsData = await this.resolveMovieCredits(
|
||||
createdMovieId,
|
||||
metadata,
|
||||
);
|
||||
|
||||
if (creditsData) {
|
||||
this.logger.debug(`Credits found for file ${filename}`);
|
||||
await this.insertCreditsForMovie(
|
||||
createdMovieId,
|
||||
creditsData,
|
||||
indent,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(`Credits not found on file ${filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`${indent} ⚠ Failed to resolve credits: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.success(
|
||||
`${indent}[${libraryName}] ✓ Added: "${metadata.title}" (${metadata.year}) - ${info.runtimeMinutes}min`,
|
||||
);
|
||||
@@ -701,12 +764,20 @@ export class LibraryScannerService extends BaseService {
|
||||
.insert(titles)
|
||||
.values({
|
||||
libraryFolderId: libraryFolderId,
|
||||
type: "SERIES" as const,
|
||||
title: seriesTitle,
|
||||
originalTitle: resolvedMetadata?.originalTitle || parsed.title,
|
||||
tagline: resolvedMetadata?.tagline || null,
|
||||
overview: resolvedMetadata?.description || null,
|
||||
poster: resolvedMetadata?.posterUrl || null,
|
||||
year: null,
|
||||
releaseDate: null,
|
||||
backdrop: resolvedMetadata?.backdropUrl || null,
|
||||
voteAverage: resolvedMetadata?.voteAverage || null,
|
||||
voteCount: resolvedMetadata?.voteCount || null,
|
||||
popularity: resolvedMetadata?.popularity || null,
|
||||
originalLanguage: resolvedMetadata?.originalLanguage || null,
|
||||
homepage: resolvedMetadata?.homepage || null,
|
||||
year: resolvedMetadata?.year || null,
|
||||
releaseDate: resolvedMetadata?.firstAirDate || null,
|
||||
})
|
||||
.returning({ id: titles.id });
|
||||
titleId = insertedTitle[0].id;
|
||||
@@ -810,8 +881,8 @@ export class LibraryScannerService extends BaseService {
|
||||
let frameRate: number | null = null;
|
||||
if (stream.r_frame_rate) {
|
||||
const [num, den] = stream.r_frame_rate.split("/");
|
||||
if (num && den && Number.parseInt(den) !== 0) {
|
||||
frameRate = Number.parseInt(num) / Number.parseInt(den);
|
||||
if (num && den && Number.parseInt(den, 10) !== 0) {
|
||||
frameRate = Number.parseInt(num, 10) / Number.parseInt(den, 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,6 +974,26 @@ export class LibraryScannerService extends BaseService {
|
||||
`${indent} → Inserted ${genresToInsert.length} genres for series`,
|
||||
);
|
||||
|
||||
// 7. Insert external provider IDs (TMDB, IMDB, etc.) for series
|
||||
if (
|
||||
resolvedMetadata?.providerIds &&
|
||||
resolvedMetadata.providerIds.length > 0
|
||||
) {
|
||||
for (const providerId of resolvedMetadata.providerIds) {
|
||||
await tx
|
||||
.insert(titleProviders)
|
||||
.values({
|
||||
titleId: titleId,
|
||||
provider: providerId.provider,
|
||||
providerId: providerId.providerId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
this.logger.debug(
|
||||
`${indent} → Linked ${providerId.provider} ID: ${providerId.providerId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`${indent} → Successfully inserted series "${parsed.title}" S${String(parsed.season).padStart(2, "0")}E${String(parsed.episode).padStart(2, "0")}`,
|
||||
);
|
||||
@@ -919,4 +1010,125 @@ export class LibraryScannerService extends BaseService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveMovieCredits(
|
||||
_movieId: string,
|
||||
metadata: ResolvedMovieMetadata,
|
||||
): Promise<ResolvedTitleCredits | null> {
|
||||
// Try to get credits from metadata providerIds
|
||||
if (metadata.providerIds && metadata.providerIds.length > 0) {
|
||||
// Use the first available provider ID
|
||||
const provider = metadata.providerIds[0];
|
||||
|
||||
try {
|
||||
const credits = await this.metadataService.resolveTitleCredits({
|
||||
titleType: "movie",
|
||||
providerId: provider.providerId,
|
||||
});
|
||||
|
||||
return credits;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to resolve credits using ${provider.provider} ID ${provider.providerId}: ${error}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async insertCreditsForMovie(
|
||||
movieId: string,
|
||||
creditsData: ResolvedTitleCredits,
|
||||
indent: string,
|
||||
): Promise<void> {
|
||||
let castInserted = 0;
|
||||
let crewInserted = 0;
|
||||
|
||||
await database.transaction(async (tx) => {
|
||||
// Insert cast members
|
||||
for (const castMember of creditsData.cast) {
|
||||
// 1. Find or create person
|
||||
let personId: string;
|
||||
const existingPerson = await tx
|
||||
.select({ id: people.id })
|
||||
.from(people)
|
||||
.where(eq(people.tmdbId, castMember.id))
|
||||
.limit(1);
|
||||
|
||||
if (existingPerson.length > 0) {
|
||||
personId = existingPerson[0].id;
|
||||
} else {
|
||||
// Create new person
|
||||
const insertedPerson = await tx
|
||||
.insert(people)
|
||||
.values({
|
||||
name: castMember.name,
|
||||
tmdbId: castMember.id,
|
||||
profilePicture: castMember.profilePath,
|
||||
})
|
||||
.returning({ id: people.id });
|
||||
personId = insertedPerson[0].id;
|
||||
}
|
||||
|
||||
// 2. Insert credit
|
||||
await tx
|
||||
.insert(credits)
|
||||
.values({
|
||||
personId: personId,
|
||||
movieId: movieId,
|
||||
role: "CAST" as const,
|
||||
character: castMember.character,
|
||||
department: "Acting",
|
||||
order: castMember.order,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
castInserted++;
|
||||
}
|
||||
|
||||
// Insert crew members
|
||||
for (const crewMember of creditsData.crew) {
|
||||
// 1. Find or create person
|
||||
let personId: string;
|
||||
const existingPerson = await tx
|
||||
.select({ id: people.id })
|
||||
.from(people)
|
||||
.where(eq(people.tmdbId, crewMember.id))
|
||||
.limit(1);
|
||||
|
||||
if (existingPerson.length > 0) {
|
||||
personId = existingPerson[0].id;
|
||||
} else {
|
||||
// Create new person
|
||||
const insertedPerson = await tx
|
||||
.insert(people)
|
||||
.values({
|
||||
name: crewMember.name,
|
||||
tmdbId: crewMember.id,
|
||||
profilePicture: crewMember.profilePath,
|
||||
})
|
||||
.returning({ id: people.id });
|
||||
personId = insertedPerson[0].id;
|
||||
}
|
||||
|
||||
// 2. Insert credit
|
||||
await tx
|
||||
.insert(credits)
|
||||
.values({
|
||||
personId: personId,
|
||||
movieId: movieId,
|
||||
role: "CREW" as const,
|
||||
job: crewMember.job,
|
||||
department: crewMember.department,
|
||||
order: null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
crewInserted++;
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`${indent} → Inserted ${castInserted} cast and ${crewInserted} crew members`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type {
|
||||
MovieInfo,
|
||||
PersonInfo,
|
||||
ResolvedMovieMetadata,
|
||||
ResolvedPerson,
|
||||
ResolvedSeriesMetadata,
|
||||
ResolvedTitleCredits,
|
||||
SeriesInfo,
|
||||
TitleCreditsInfo,
|
||||
} from "@/models";
|
||||
import type { MetadataProvider } from "@/providers/metadata";
|
||||
import { BaseService, ServiceError } from "../BaseService";
|
||||
@@ -82,6 +86,60 @@ 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> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
return await provider.resolveTitleCredits(info);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Provider ${provider.name} failed to resolve title credits.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async resolvePerson(info: PersonInfo): Promise<ResolvedPerson | null> {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
return await provider.resolvePeople(info);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Provider ${provider.name} failed to resolve person details.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private refreshProviders(): void {
|
||||
this.providers = this.extensions.getByCapability<MetadataProvider>(
|
||||
Capabilites.MetadataProvider,
|
||||
|
||||
@@ -44,11 +44,7 @@
|
||||
"level": "warn",
|
||||
"fix": "safe",
|
||||
"options": {
|
||||
"functions": [
|
||||
"clsx",
|
||||
"cva",
|
||||
"cn"
|
||||
]
|
||||
"functions": ["clsx", "cva", "cn"]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -71,4 +67,4 @@
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
bts.jsonc
39
bts.jsonc
@@ -2,25 +2,20 @@
|
||||
// safe to delete
|
||||
|
||||
{
|
||||
"$schema": "https://r2.better-t-stack.dev/schema.json",
|
||||
"version": "2.46.0",
|
||||
"createdAt": "2025-09-16T19:14:46.247Z",
|
||||
"database": "sqlite",
|
||||
"orm": "drizzle",
|
||||
"backend": "hono",
|
||||
"runtime": "bun",
|
||||
"frontend": [
|
||||
"tanstack-start"
|
||||
],
|
||||
"addons": [
|
||||
"biome",
|
||||
"turborepo"
|
||||
],
|
||||
"examples": [],
|
||||
"auth": "better-auth",
|
||||
"packageManager": "bun",
|
||||
"dbSetup": "none",
|
||||
"api": "trpc",
|
||||
"webDeploy": "none",
|
||||
"serverDeploy": "none"
|
||||
}
|
||||
"$schema": "https://r2.better-t-stack.dev/schema.json",
|
||||
"version": "2.46.0",
|
||||
"createdAt": "2025-09-16T19:14:46.247Z",
|
||||
"database": "sqlite",
|
||||
"orm": "drizzle",
|
||||
"backend": "hono",
|
||||
"runtime": "bun",
|
||||
"frontend": ["tanstack-start"],
|
||||
"addons": ["biome", "turborepo"],
|
||||
"examples": [],
|
||||
"auth": "better-auth",
|
||||
"packageManager": "bun",
|
||||
"dbSetup": "none",
|
||||
"api": "trpc",
|
||||
"webDeploy": "none",
|
||||
"serverDeploy": "none"
|
||||
}
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -67,8 +67,8 @@
|
||||
"react-dom": "19.1.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"zod": "^4.0.2",
|
||||
},
|
||||
@@ -1830,7 +1830,7 @@
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.8", "", {}, "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw=="],
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -14,11 +14,11 @@
|
||||
"check-types": "turbo check-types",
|
||||
"dev:native": "turbo -F native dev",
|
||||
"dev:web": "turbo -F web dev",
|
||||
"dev:server": "turbo -F server dev",
|
||||
"db:push": "turbo -F server db:push",
|
||||
"db:studio": "turbo -F server db:studio",
|
||||
"db:generate": "turbo -F server db:generate",
|
||||
"db:migrate": "turbo -F server db:migrate"
|
||||
"dev:server": "turbo -F @nontara/server dev",
|
||||
"db:push": "turbo -F @nontara/server db:push",
|
||||
"db:studio": "turbo -F @nontara/server db:studio",
|
||||
"db:generate": "turbo -F @nontara/server db:generate",
|
||||
"db:migrate": "turbo -F @nontara/server db:migrate"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user