253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
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;
|