import { sqliteTable, integer, text, real, primaryKey, index } from "drizzle-orm/sqlite-core"; import { relations, sql } from "drizzle-orm"; /** * The asset table — each row is one image on disk. For "front cover" rows the * cover-metadata columns are populated; for back covers / extra stills, the * row points at its parent via parent_image_id and its own metadata is null. */ export const images = sqliteTable("images", { id: integer("id").primaryKey({ autoIncrement: true }), filename: text("filename").notNull(), relPath: text("rel_path").notNull().unique(), thumbPath: text("thumb_path").notNull(), sha256: text("sha256").notNull().unique(), width: integer("width").notNull(), height: integer("height").notNull(), bytes: integer("bytes").notNull(), phash: text("phash"), rawMetadata: text("raw_metadata"), createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`), importedAt: integer("imported_at").notNull().default(sql`(unixepoch() * 1000)`), deletedAt: integer("deleted_at"), // Non-null = this is an attached image (e.g. back cover / still) of another row. parentImageId: integer("parent_image_id").references((): any => images.id, { onDelete: "cascade" }), // Cover metadata — only meaningful when parentImageId IS NULL. code: text("code"), title: text("title"), releaseDate: text("release_date"), // ISO yyyy-mm-dd runtimeMin: integer("runtime_min"), director: text("director"), studioId: integer("studio_id").references((): any => studios.id, { onDelete: "set null" }), labelId: integer("label_id").references((): any => labels.id, { onDelete: "set null" }), seriesId: integer("series_id").references((): any => series.id, { onDelete: "set null" }), rating: integer("rating"), // 0..5 watched: integer("watched", { mode: "boolean" }).notNull().default(false), isVip: integer("is_vip", { mode: "boolean" }).notNull().default(false), isFavorite: integer("is_favorite", { mode: "boolean" }).notNull().default(false), isOwned: integer("is_owned", { mode: "boolean" }).notNull().default(false), hasVideo: integer("has_video", { mode: "boolean" }).notNull().default(false), hasSubtitle: integer("has_subtitle", { mode: "boolean" }).notNull().default(false), /** Source video codec, populated by lazy ffprobe on first playback. */ videoCodec: text("video_codec"), /** Source video has_b_frames count (0/1/2+) — used by the auto-predicate * playback mode to decide whether transcoding is needed. */ videoBFrames: integer("video_b_frames"), /** Cached playback decision from auto-runtime mode: 'direct' / 'transcode'. * Null means "not yet measured". */ playbackMode: text("playback_mode"), notes: text("notes"), }, (t) => ({ byCreated: index("images_created_idx").on(t.createdAt), byDeleted: index("images_deleted_idx").on(t.deletedAt), byParent: index("images_parent_idx").on(t.parentImageId), byCode: index("images_code_idx").on(t.code), byStudio: index("images_studio_idx").on(t.studioId), byLabel: index("images_label_idx").on(t.labelId), bySeries: index("images_series_idx").on(t.seriesId), byHasVideo: index("images_has_video_idx").on(t.hasVideo), byHasSubtitle: index("images_has_subtitle_idx").on(t.hasSubtitle), })); export const videoMetadata = sqliteTable("video_metadata", { absPath: text("abs_path").primaryKey(), relPath: text("rel_path").notNull(), code: text("code").notNull(), sizeBytes: integer("size_bytes").notNull(), mtimeMs: real("mtime_ms").notNull(), probedAt: integer("probed_at"), probeError: text("probe_error"), durationSec: real("duration_sec"), videoCodec: text("video_codec"), videoBFrames: integer("video_b_frames"), width: integer("width"), height: integer("height"), videoBitrate: integer("video_bitrate"), playbackMode: text("playback_mode"), /** Suffix-pattern classification: "part" (sequential), "variant" (alt * encode of the same content), or "single" (lone file). */ partKind: text("part_kind"), /** Sort key for parts: 1, 2, ... — null for variants and singles. */ partIndex: integer("part_index"), /** Stem with matched suffix stripped — variants of the same part share * this group key. Null for singles. */ variantGroup: text("variant_group"), }, (t) => ({ byCode: index("video_metadata_code_idx").on(t.code), })); export const studios = sqliteTable("studios", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), notes: text("notes"), }); export const labels = sqliteTable("labels", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), notes: text("notes"), }); export const series = sqliteTable("series", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), notes: text("notes"), }); export const actresses = sqliteTable("actresses", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), altNames: text("alt_names"), notes: text("notes"), portraitPath: text("portrait_path"), portraitZoom: real("portrait_zoom").notNull().default(1), portraitOffsetX: real("portrait_offset_x").notNull().default(0), portraitOffsetY: real("portrait_offset_y").notNull().default(0), }); export const genres = sqliteTable("genres", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), }); export const imageActresses = sqliteTable("image_actresses", { imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }), actressId: integer("actress_id").notNull().references(() => actresses.id, { onDelete: "cascade" }), }, (t) => ({ pk: primaryKey({ columns: [t.imageId, t.actressId] }), byActress: index("image_actresses_actress_idx").on(t.actressId), })); export const imageGenres = sqliteTable("image_genres", { imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }), genreId: integer("genre_id").notNull().references(() => genres.id, { onDelete: "cascade" }), }, (t) => ({ pk: primaryKey({ columns: [t.imageId, t.genreId] }), byGenre: index("image_genres_genre_idx").on(t.genreId), })); export const tagCategories = sqliteTable("tag_categories", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), color: text("color"), description: text("description"), coverPortraitPath: text("cover_portrait_path"), coverPortraitZoom: real("cover_portrait_zoom").notNull().default(1), coverPortraitOffsetX: real("cover_portrait_offset_x").notNull().default(0), coverPortraitOffsetY: real("cover_portrait_offset_y").notNull().default(0), coverLandscapePath: text("cover_landscape_path"), coverLandscapeZoom: real("cover_landscape_zoom").notNull().default(1), coverLandscapeOffsetX: real("cover_landscape_offset_x").notNull().default(0), coverLandscapeOffsetY: real("cover_landscape_offset_y").notNull().default(0), }); export const tags = sqliteTable("tags", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), color: text("color"), categoryId: integer("category_id").references((): any => tagCategories.id, { onDelete: "set null" }), lastUsedAt: integer("last_used_at").notNull().default(0), }, (t) => ({ byCategory: index("tags_category_idx").on(t.categoryId), })); export const imageTags = sqliteTable("image_tags", { imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }), tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }), }, (t) => ({ pk: primaryKey({ columns: [t.imageId, t.tagId] }), byTag: index("image_tags_tag_idx").on(t.tagId), })); export const collections = sqliteTable("collections", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull(), slug: text("slug").notNull().unique(), description: text("description"), coverImageId: integer("cover_image_id").references(() => images.id, { onDelete: "set null" }), createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`), position: integer("position").notNull().default(0), lastUsedAt: integer("last_used_at").notNull().default(0), coverPortraitPath: text("cover_portrait_path"), coverPortraitZoom: real("cover_portrait_zoom").notNull().default(1), coverPortraitOffsetX: real("cover_portrait_offset_x").notNull().default(0), coverPortraitOffsetY: real("cover_portrait_offset_y").notNull().default(0), coverLandscapePath: text("cover_landscape_path"), coverLandscapeZoom: real("cover_landscape_zoom").notNull().default(1), coverLandscapeOffsetX: real("cover_landscape_offset_x").notNull().default(0), coverLandscapeOffsetY: real("cover_landscape_offset_y").notNull().default(0), }); export const collectionImages = sqliteTable("collection_images", { collectionId: integer("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }), imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }), position: integer("position").notNull().default(0), }, (t) => ({ pk: primaryKey({ columns: [t.collectionId, t.imageId] }), })); export const imagesRelations = relations(images, ({ one, many }) => ({ parent: one(images, { fields: [images.parentImageId], references: [images.id], relationName: "parent" }), studio: one(studios, { fields: [images.studioId], references: [studios.id] }), label: one(labels, { fields: [images.labelId], references: [labels.id] }), series: one(series, { fields: [images.seriesId], references: [series.id] }), imageActresses: many(imageActresses), imageGenres: many(imageGenres), imageTags: many(imageTags), collectionImages: many(collectionImages), })); export const studiosRelations = relations(studios, ({ many }) => ({ images: many(images) })); export const labelsRelations = relations(labels, ({ many }) => ({ images: many(images) })); export const seriesRelations = relations(series, ({ many }) => ({ images: many(images) })); export const actressesRelations = relations(actresses, ({ many }) => ({ imageActresses: many(imageActresses) })); export const genresRelations = relations(genres, ({ many }) => ({ imageGenres: many(imageGenres) })); export const tagsRelations = relations(tags, ({ many }) => ({ imageTags: many(imageTags) })); export const imageActressesRelations = relations(imageActresses, ({ one }) => ({ image: one(images, { fields: [imageActresses.imageId], references: [images.id] }), actress: one(actresses, { fields: [imageActresses.actressId], references: [actresses.id] }), })); export const imageGenresRelations = relations(imageGenres, ({ one }) => ({ image: one(images, { fields: [imageGenres.imageId], references: [images.id] }), genre: one(genres, { fields: [imageGenres.genreId], references: [genres.id] }), })); export const imageTagsRelations = relations(imageTags, ({ one }) => ({ image: one(images, { fields: [imageTags.imageId], references: [images.id] }), tag: one(tags, { fields: [imageTags.tagId], references: [tags.id] }), })); export const collectionsRelations = relations(collections, ({ many, one }) => ({ collectionImages: many(collectionImages), cover: one(images, { fields: [collections.coverImageId], references: [images.id] }), })); export const collectionImagesRelations = relations(collectionImages, ({ one }) => ({ collection: one(collections, { fields: [collectionImages.collectionId], references: [collections.id] }), image: one(images, { fields: [collectionImages.imageId], references: [images.id] }), })); export type Image = typeof images.$inferSelect; export type Studio = typeof studios.$inferSelect; export type Label = typeof labels.$inferSelect; export type Series = typeof series.$inferSelect; export type Actress = typeof actresses.$inferSelect; export type Genre = typeof genres.$inferSelect; export type Tag = typeof tags.$inferSelect; export type Collection = typeof collections.$inferSelect; export type VideoMetadata = typeof videoMetadata.$inferSelect;