Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
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;