diff --git a/client/src/features/book/components/detail/RecommendedBooksRow.vue b/client/src/features/book/components/detail/RecommendedBooksRow.vue new file mode 100644 index 000000000..4442d91b3 --- /dev/null +++ b/client/src/features/book/components/detail/RecommendedBooksRow.vue @@ -0,0 +1,50 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index fbf830194..21263bfdb 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -6,6 +6,7 @@ import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } f import { bookCoverStyle } from '@/features/book/lib/book-cover' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import type { BookDetail } from '@projectx/types' +import RecommendedBooksRow from '@/features/book/components/detail/RecommendedBooksRow.vue' const props = defineProps<{ book: BookDetail }>() const router = useRouter() @@ -201,7 +202,7 @@ function downloadFile() {
+ + diff --git a/client/src/features/book/composables/useRecommendations.ts b/client/src/features/book/composables/useRecommendations.ts new file mode 100644 index 000000000..8b6e347e9 --- /dev/null +++ b/client/src/features/book/composables/useRecommendations.ts @@ -0,0 +1,25 @@ +import { ref } from 'vue' + +import type { BookRecommendation } from '@projectx/types' +import { api } from '@/lib/api' + +export function useRecommendations() { + const recommendations = ref([]) + const loading = ref(false) + + async function fetch(bookId: number) { + loading.value = true + recommendations.value = [] + try { + const res = await api(`/api/books/${bookId}/recommendations`) + if (!res.ok) return + recommendations.value = await res.json() + } catch { + // recommendations are non-critical, fail silently + } finally { + loading.value = false + } + } + + return { recommendations, loading, fetch } +} diff --git a/client/src/features/settings/MaintenanceSettings.vue b/client/src/features/settings/MaintenanceSettings.vue new file mode 100644 index 000000000..30fbc0df1 --- /dev/null +++ b/client/src/features/settings/MaintenanceSettings.vue @@ -0,0 +1,67 @@ + + + diff --git a/client/src/features/settings/SettingsDrawer.vue b/client/src/features/settings/SettingsDrawer.vue index 74c39c7f4..84d1027c8 100644 --- a/client/src/features/settings/SettingsDrawer.vue +++ b/client/src/features/settings/SettingsDrawer.vue @@ -17,6 +17,7 @@ import { LogIn, Rss, Tablet, + Wrench, } from 'lucide-vue-next' import { useSettingsDrawer } from '@/composables/useSettingsDrawer' import { usePermissions } from '@/features/auth/composables/usePermissions' @@ -34,6 +35,7 @@ import PermissionsPage from '@/features/admin/PermissionsPage.vue' import OidcSettings from './OidcSettings.vue' import OpdsSettings from './OpdsSettings.vue' import KoboSettings from './KoboSettings.vue' +import MaintenanceSettings from './MaintenanceSettings.vue' const { isOpen, close } = useSettingsDrawer() const { isSuperuser, userPermissions } = usePermissions() @@ -54,6 +56,7 @@ type SectionId = | 'roles' | 'permissions' | 'oidc' + | 'maintenance' const navGroups = computed(() => { const perms = userPermissions.value @@ -88,6 +91,7 @@ const navGroups = computed(() => { } if (su || perms.includes('manage_app_settings')) { adminItems.push({ id: 'oidc', label: 'OIDC / SSO', icon: LogIn, component: OidcSettings }) + adminItems.push({ id: 'maintenance', label: 'Maintenance', icon: Wrench, component: MaintenanceSettings }) } if (adminItems.length) { groups.push({ label: 'Administration', items: adminItems }) diff --git a/docker-compose.yml b/docker-compose.yml index e6ebb6cc4..d054a178f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ name: projectx-dev services: postgres: - image: postgres:16-alpine + image: pgvector/pgvector:pg16 restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-projectx} diff --git a/docker/postgres/init.sql b/docker/postgres/init.sql index 3b232cbf4..756b3b224 100644 --- a/docker/postgres/init.sql +++ b/docker/postgres/init.sql @@ -4,3 +4,4 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- for fuzzy text search +CREATE EXTENSION IF NOT EXISTS "vector"; -- for pgvector ANN search diff --git a/packages/types/src/book.ts b/packages/types/src/book.ts index 517a5f704..6dfd6b8ab 100644 --- a/packages/types/src/book.ts +++ b/packages/types/src/book.ts @@ -66,3 +66,8 @@ export type BooksPage = { page: number; size: number; }; + +export type BookRecommendation = { + book: BookCard; + score: number; +}; diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c9ababf8a..7a9cbf694 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -26,6 +26,7 @@ import { PathModule } from './modules/path/path.module'; import { MetadataFetchModule } from './modules/metadata-fetch/metadata-fetch.module'; import { MetadataModule } from './modules/metadata/metadata.module'; import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module'; +import { RecommendationModule } from './modules/recommendation/recommendation.module'; import { RoleModule } from './modules/role/role.module'; import { ScannerModule } from './modules/scanner/scanner.module'; import { SeedModule } from './modules/seed/seed.module'; @@ -55,6 +56,7 @@ import { UserModule } from './modules/user/user.module'; ScannerModule, MetadataModule, MetadataFetchModule, + RecommendationModule, KoboModule, OpdsModule, BookmarkModule, diff --git a/server/src/db/migrations/0001_add_embedding_column.sql b/server/src/db/migrations/0001_add_embedding_column.sql new file mode 100644 index 000000000..2028c0bc4 --- /dev/null +++ b/server/src/db/migrations/0001_add_embedding_column.sql @@ -0,0 +1 @@ +ALTER TABLE "book_metadata" ADD COLUMN "embedding" vector(256); \ No newline at end of file diff --git a/server/src/db/migrations/meta/0001_snapshot.json b/server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..1e9e9c2ca --- /dev/null +++ b/server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,3065 @@ +{ + "id": "a3dc437e-3441-4d6a-8dda-4f4423bf6eda", + "prevId": "0eeff5ae-6f70-4e72-b6f3-c06a1e702c9a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_settings_key_unique": { + "name": "app_settings_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_hash_unique": { + "name": "password_reset_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "permissions_name_unique": { + "name": "permissions_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_hash_unique": { + "name": "refresh_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_superuser": { + "name": "is_superuser", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library_access": { + "name": "user_library_access", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "access_level": { + "name": "access_level", + "type": "library_access_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_library_access_user_id_users_id_fk": { + "name": "user_library_access_user_id_users_id_fk", + "tableFrom": "user_library_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_access_library_id_libraries_id_fk": { + "name": "user_library_access_library_id_libraries_id_fk", + "tableFrom": "user_library_access", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_library_access_user_id_library_id_pk": { + "name": "user_library_access_user_id_library_id_pk", + "columns": [ + "user_id", + "library_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default_password": { + "name": "is_default_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_method": { + "name": "provisioning_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_files": { + "name": "book_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "rel_path": { + "name": "rel_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": false + }, + "ino": { + "name": "ino", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mtime": { + "name": "mtime", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "book_files_book_id_books_id_fk": { + "name": "book_files_book_id_books_id_fk", + "tableFrom": "book_files", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_files_library_folder_id_library_folders_id_fk": { + "name": "book_files_library_folder_id_library_folders_id_fk", + "tableFrom": "book_files", + "tableTo": "library_folders", + "columnsFrom": [ + "library_folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "folder_path": { + "name": "folder_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'present'" + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "books_library_id_libraries_id_fk": { + "name": "books_library_id_libraries_id_fk", + "tableFrom": "books", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "books_library_folder_id_library_folders_id_fk": { + "name": "books_library_folder_id_library_folders_id_fk", + "tableFrom": "books", + "tableTo": "library_folders", + "columnsFrom": [ + "library_folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_books": { + "name": "collection_books", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_books_collection_id_collections_id_fk": { + "name": "collection_books_collection_id_collections_id_fk", + "tableFrom": "collection_books", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_books_book_id_books_id_fk": { + "name": "collection_books_book_id_books_id_fk", + "tableFrom": "collection_books", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_books_collection_id_book_id_pk": { + "name": "collection_books_collection_id_book_id_pk", + "columns": [ + "collection_id", + "book_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_to_kobo": { + "name": "sync_to_kobo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.libraries": { + "name": "libraries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "watch": { + "name": "watch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_scan_cron_expression": { + "name": "auto_scan_cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_precedence": { + "name": "metadata_precedence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"folderStructure\",\"embedded\",\"nfoFile\",\"opfFile\",\"sidecar\"]'::jsonb" + }, + "format_priority": { + "name": "format_priority", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"epub\",\"pdf\",\"cbz\",\"cbr\",\"mobi\",\"azw3\",\"fb2\"]'::jsonb" + }, + "allowed_formats": { + "name": "allowed_formats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "organization_mode": { + "name": "organization_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "mark_as_finished_seconds_remaining": { + "name": "mark_as_finished_seconds_remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mark_as_finished_percent_complete": { + "name": "mark_as_finished_percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scan_mode": { + "name": "scan_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "poll_interval_seconds": { + "name": "poll_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_folders": { + "name": "library_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_folders_library_id_libraries_id_fk": { + "name": "library_folders_library_id_libraries_id_fk", + "tableFrom": "library_folders", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lenses": { + "name": "lenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_sort": { + "name": "default_sort", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lenses_user_id_users_id_fk": { + "name": "lenses_user_id_users_id_fk", + "tableFrom": "lenses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lenses_user_id_name_unique": { + "name": "lenses_user_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authors": { + "name": "authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_authors": { + "name": "book_authors", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "book_authors_book_id_books_id_fk": { + "name": "book_authors_book_id_books_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_author_id_authors_id_fk": { + "name": "book_authors_author_id_authors_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_book_id_author_id_pk": { + "name": "book_authors_book_id_author_id_pk", + "columns": [ + "book_id", + "author_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_genres": { + "name": "book_genres", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "book_genres_book_id_books_id_fk": { + "name": "book_genres_book_id_books_id_fk", + "tableFrom": "book_genres", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_genres_genre_id_genres_id_fk": { + "name": "book_genres_genre_id_genres_id_fk", + "tableFrom": "book_genres", + "tableTo": "genres", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_genres_book_id_genre_id_pk": { + "name": "book_genres_book_id_genre_id_pk", + "columns": [ + "book_id", + "genre_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_metadata": { + "name": "book_metadata", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isbn10": { + "name": "isbn10", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "isbn13": { + "name": "isbn13", + "type": "varchar(13)", + "primaryKey": false, + "notNull": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "published_year": { + "name": "published_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_name": { + "name": "series_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cover_source": { + "name": "cover_source", + "type": "varchar(9)", + "primaryKey": false, + "notNull": false + }, + "google_books_id": { + "name": "google_books_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "goodreads_id": { + "name": "goodreads_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "amazon_id": { + "name": "amazon_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "hardcover_id": { + "name": "hardcover_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "open_library_id": { + "name": "open_library_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(256)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "book_metadata_book_id_books_id_fk": { + "name": "book_metadata_book_id_books_id_fk", + "tableFrom": "book_metadata", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_tags": { + "name": "book_tags", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "book_tags_book_id_books_id_fk": { + "name": "book_tags_book_id_books_id_fk", + "tableFrom": "book_tags", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_tags_tag_id_tags_id_fk": { + "name": "book_tags_tag_id_tags_id_fk", + "tableFrom": "book_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_tags_book_id_tag_id_pk": { + "name": "book_tags_book_id_tag_id_pk", + "columns": [ + "book_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.genres": { + "name": "genres", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "genres_name_unique": { + "name": "genres_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scan_jobs": { + "name": "scan_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "triggered_by": { + "name": "triggered_by", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "added_count": { + "name": "added_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "missing_count": { + "name": "missing_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "scan_jobs_library_id_libraries_id_fk": { + "name": "scan_jobs_library_id_libraries_id_fk", + "tableFrom": "scan_jobs", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'yellow'" + }, + "style": { + "name": "style", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'highlight'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chapter_title": { + "name": "chapter_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotations_user_id_idx": { + "name": "annotations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_user_id_users_id_fk": { + "name": "annotations_user_id_users_id_fk", + "tableFrom": "annotations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "annotations_book_id_books_id_fk": { + "name": "annotations_book_id_books_id_fk", + "tableFrom": "annotations", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bookmarks_user_id_idx": { + "name": "bookmarks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarks_user_id_users_id_fk": { + "name": "bookmarks_user_id_users_id_fk", + "tableFrom": "bookmarks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_book_id_books_id_fk": { + "name": "bookmarks_book_id_books_id_fk", + "tableFrom": "bookmarks", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reader_default_preferences": { + "name": "reader_default_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format_group": { + "name": "format_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rdp_user_format_idx": { + "name": "rdp_user_format_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reader_default_preferences_user_id_users_id_fk": { + "name": "reader_default_preferences_user_id_users_id_fk", + "tableFrom": "reader_default_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reader_preferences": { + "name": "reader_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rp_user_file_idx": { + "name": "rp_user_file_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reader_preferences_user_id_users_id_fk": { + "name": "reader_preferences_user_id_users_id_fk", + "tableFrom": "reader_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reader_preferences_book_file_id_book_files_id_fk": { + "name": "reader_preferences_book_file_id_book_files_id_fk", + "tableFrom": "reader_preferences", + "tableTo": "book_files", + "columnsFrom": [ + "book_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reading_progress": { + "name": "reading_progress", + "schema": "", + "columns": { + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_progress_user_id_idx": { + "name": "reading_progress_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_progress_book_file_id_book_files_id_fk": { + "name": "reading_progress_book_file_id_book_files_id_fk", + "tableFrom": "reading_progress", + "tableTo": "book_files", + "columnsFrom": [ + "book_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_progress_user_id_users_id_fk": { + "name": "reading_progress_user_id_users_id_fk", + "tableFrom": "reading_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reading_progress_book_file_id_user_id_pk": { + "name": "reading_progress_book_file_id_user_id_pk", + "columns": [ + "book_file_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_group_mappings": { + "name": "oidc_group_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "oidc_group_claim": { + "name": "oidc_group_claim", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oidc_group_mappings_role_id_roles_id_fk": { + "name": "oidc_group_mappings_role_id_roles_id_fk", + "tableFrom": "oidc_group_mappings", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oidc_group_mappings_oidc_group_claim_unique": { + "name": "oidc_group_mappings_oidc_group_claim_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_group_claim" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_sessions": { + "name": "oidc_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_session_id": { + "name": "oidc_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token_hint": { + "name": "id_token_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_sessions_user_id_idx": { + "name": "oidc_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_subject_issuer_idx": { + "name": "oidc_sessions_subject_issuer_idx", + "columns": [ + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_sid_idx": { + "name": "oidc_sessions_sid_idx", + "columns": [ + { + "expression": "oidc_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_sessions_user_id_users_id_fk": { + "name": "oidc_sessions_user_id_users_id_fk", + "tableFrom": "oidc_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opds_users": { + "name": "opds_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "opds_sort_order", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'recent'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "opds_users_user_id_users_id_fk": { + "name": "opds_users_user_id_users_id_fk", + "tableFrom": "opds_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "opds_users_username_unique": { + "name": "opds_users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_devices": { + "name": "kobo_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_devices_user_id_users_id_fk": { + "name": "kobo_devices_user_id_users_id_fk", + "tableFrom": "kobo_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_devices_token_unique": { + "name": "kobo_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_library_snapshots": { + "name": "kobo_library_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_library_snapshots_user_id_users_id_fk": { + "name": "kobo_library_snapshots_user_id_users_id_fk", + "tableFrom": "kobo_library_snapshots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_library_snapshots_user_id_unique": { + "name": "kobo_library_snapshots_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_reading_states": { + "name": "kobo_reading_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at_kobo": { + "name": "created_at_kobo", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_modified_kobo": { + "name": "last_modified_kobo", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority_timestamp": { + "name": "priority_timestamp", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "current_bookmark": { + "name": "current_bookmark", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "statistics": { + "name": "statistics", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_info": { + "name": "status_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "progress_synced_at": { + "name": "progress_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_reading_states_user_id_users_id_fk": { + "name": "kobo_reading_states_user_id_users_id_fk", + "tableFrom": "kobo_reading_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_reading_states_book_id_books_id_fk": { + "name": "kobo_reading_states_book_id_books_id_fk", + "tableFrom": "kobo_reading_states", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_reading_states_user_id_book_id_unique": { + "name": "kobo_reading_states_user_id_book_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "book_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_snapshot_books": { + "name": "kobo_snapshot_books", + "schema": "", + "columns": { + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_delete": { + "name": "pending_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_by_device": { + "name": "removed_by_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk": { + "name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk", + "tableFrom": "kobo_snapshot_books", + "tableTo": "kobo_library_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_snapshot_books_book_id_books_id_fk": { + "name": "kobo_snapshot_books_book_id_books_id_fk", + "tableFrom": "kobo_snapshot_books", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kobo_snapshot_books_snapshot_id_book_id_pk": { + "name": "kobo_snapshot_books_snapshot_id_book_id_pk", + "columns": [ + "snapshot_id", + "book_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_sync_settings": { + "name": "kobo_sync_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reading_threshold": { + "name": "reading_threshold", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "finished_threshold": { + "name": "finished_threshold", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 99 + }, + "convert_to_kepub": { + "name": "convert_to_kepub", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "two_way_progress_sync": { + "name": "two_way_progress_sync", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_enable_hyphenation": { + "name": "force_enable_hyphenation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kepub_conversion_limit_mb": { + "name": "kepub_conversion_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_sync_settings_user_id_users_id_fk": { + "name": "kobo_sync_settings_user_id_users_id_fk", + "tableFrom": "kobo_sync_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_sync_settings_user_id_unique": { + "name": "kobo_sync_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.library_access_level": { + "name": "library_access_level", + "schema": "public", + "values": [ + "viewer", + "editor", + "owner" + ] + }, + "public.opds_sort_order": { + "name": "opds_sort_order", + "schema": "public", + "values": [ + "recent", + "title_asc", + "title_desc", + "author_asc", + "author_desc", + "series_asc", + "series_desc" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index af9e17bec..4410d426e 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -5,9 +5,16 @@ { "idx": 0, "version": "7", - "when": 1771545632263, + "when": 1771614528351, "tag": "0000_baseline", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1771614528351, + "tag": "0001_add_embedding_column", + "breakpoints": true } ] } diff --git a/server/src/db/schema/metadata.ts b/server/src/db/schema/metadata.ts index 6b200492a..539ed7e84 100644 --- a/server/src/db/schema/metadata.ts +++ b/server/src/db/schema/metadata.ts @@ -1,4 +1,10 @@ -import { integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { customType, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; + +const embedding256 = customType<{ data: number[]; driverData: string }>({ + dataType: () => 'vector(256)', + toDriver: (v) => `[${v.join(',')}]`, + fromDriver: (v) => v.slice(1, -1).split(',').map(Number), +}); import { books } from './books'; @@ -24,6 +30,7 @@ export const bookMetadata = pgTable('book_metadata', { amazonId: varchar('amazon_id', { length: 20 }), hardcoverId: varchar('hardcover_id', { length: 50 }), openLibraryId: varchar('open_library_id', { length: 50 }), + embedding: embedding256('embedding'), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); diff --git a/server/src/modules/book/book.controller.ts b/server/src/modules/book/book.controller.ts index 406a086a0..c3e2bd590 100644 --- a/server/src/modules/book/book.controller.ts +++ b/server/src/modules/book/book.controller.ts @@ -32,6 +32,12 @@ import type { BookQuery } from '@projectx/types'; export class BookController { constructor(private readonly bookService: BookService) {} + @Post('embed-all') + @RequirePermission('manage_app_settings') + embedAll() { + return this.bookService.embedAll(); + } + @Delete() @HttpCode(HttpStatus.NO_CONTENT) @RequirePermission('library_delete_books') diff --git a/server/src/modules/book/book.module.ts b/server/src/modules/book/book.module.ts index 37bd946cd..4b2699c1d 100644 --- a/server/src/modules/book/book.module.ts +++ b/server/src/modules/book/book.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; +import { EmbeddingModule } from '../embedding/embedding.module'; import { LibraryModule } from '../library/library.module'; import { MetadataModule } from '../metadata/metadata.module'; import { BookQueryBuilder } from './book-query-builder.service'; @@ -8,7 +9,7 @@ import { BookRepository } from './book.repository'; import { BookService } from './book.service'; @Module({ - imports: [forwardRef(() => LibraryModule), MetadataModule], + imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule], controllers: [BookController], providers: [BookService, BookRepository, BookQueryBuilder], exports: [BookService, BookRepository, BookQueryBuilder], diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index d71d6e972..47600019d 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -209,6 +209,11 @@ export class BookRepository { return this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(inArray(books.id, bookIds)); } + async findAllIds(): Promise { + const rows = await this.db.select({ id: books.id }).from(books); + return rows.map((r) => r.id); + } + async deleteByIds(bookIds: number[]): Promise { await this.db.delete(books).where(inArray(books.id, bookIds)); } diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 712bfe3f0..54729b86e 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { access, readdir, rm, stat } from 'fs/promises'; import { basename, join } from 'path'; @@ -7,6 +7,7 @@ import { MetadataProviderKey } from '@projectx/types'; import type { BookQuery, BooksPage } from '@projectx/types'; import { assembleBookCards } from './utils/assemble-book-cards'; import type { RequestUser } from '../../common/types/request-user'; +import { BookEmbedderService } from '../embedding/book-embedder.service'; import { MetadataService } from '../metadata/metadata.service'; import { LibraryService } from '../library/library.service'; import { BookQueryBuilder } from './book-query-builder.service'; @@ -26,6 +27,7 @@ export class BookService { private readonly queryBuilder: BookQueryBuilder, private readonly metadataService: MetadataService, private readonly config: ConfigService, + @Optional() private readonly embedder: BookEmbedderService, ) { this.booksPath = this.config.get('storage.booksPath')!; } @@ -173,9 +175,28 @@ export class BookService { await this.metadataService.replaceTags(id, dto.tags); } + this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`)); return this.getDetail(id, user); } + async embedAll(): Promise<{ queued: number }> { + const bookIds = await this.bookRepo.findAllIds(); + this.runEmbeddings(bookIds).catch((err: Error) => this.logger.error(`Embed-all failed: ${err.message}`)); + return { queued: bookIds.length }; + } + + private async runEmbeddings(bookIds: number[]): Promise { + const BATCH = 10; + for (let i = 0; i < bookIds.length; i += BATCH) { + await Promise.all( + bookIds + .slice(i, i + BATCH) + .map((id) => this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Failed to embed book ${id}: ${err.message}`))), + ); + } + this.logger.log(`Embeddings complete: ${bookIds.length} books processed`); + } + async getProgress(userId: number, fileId: number, user: RequestUser) { await this.verifyFileAccess(fileId, user); return this.bookRepo.findProgress(userId, fileId); diff --git a/server/src/modules/embedding/book-embedder.service.ts b/server/src/modules/embedding/book-embedder.service.ts new file mode 100644 index 000000000..96a889c8f --- /dev/null +++ b/server/src/modules/embedding/book-embedder.service.ts @@ -0,0 +1,178 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema'; + +type Db = NodePgDatabase; + +const DIMS = 256; + +const STOPWORDS = new Set([ + 'the', + 'a', + 'an', + 'and', + 'or', + 'of', + 'in', + 'to', + 'for', + 'is', + 'it', + 'on', + 'at', + 'by', + 'with', + 'as', + 'be', + 'this', + 'that', + 'are', + 'was', + 'were', + 'has', + 'have', + 'had', + 'not', + 'but', + 'from', + 'can', + 'all', + 'so', + 'if', + 'no', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'do', + 'does', + 'did', + 'been', + 'being', + 'into', + 'out', + 'up', + 'one', + 'two', + 'more', + 'most', + 'also', + 'just', + 'about', + 'after', + 'its', +]); + +@Injectable() +export class BookEmbedderService { + private readonly logger = new Logger(BookEmbedderService.name); + + constructor(@Inject(DB) private readonly db: Db) {} + + async embedBook(bookId: number): Promise { + const [meta] = await this.db + .select({ + title: bookMetadata.title, + seriesName: bookMetadata.seriesName, + publisher: bookMetadata.publisher, + description: bookMetadata.description, + }) + .from(bookMetadata) + .where(eq(bookMetadata.bookId, bookId)) + .limit(1); + + if (!meta) return null; + + const [authorRows, genreRows, tagRows] = await Promise.all([ + this.db + .select({ name: authors.name }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .where(eq(bookAuthors.bookId, bookId)), + this.db + .select({ name: genres.name }) + .from(bookGenres) + .innerJoin(genres, eq(genres.id, bookGenres.genreId)) + .where(eq(bookGenres.bookId, bookId)), + this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)), + ]); + + const embedding = this.buildVector({ + title: meta.title, + seriesName: meta.seriesName, + publisher: meta.publisher, + description: meta.description, + authors: authorRows.map((r) => r.name), + genres: genreRows.map((r) => r.name), + tags: tagRows.map((r) => r.name), + }); + + await this.db.update(bookMetadata).set({ embedding }).where(eq(bookMetadata.bookId, bookId)); + + this.logger.debug(`Embedded book ${bookId}`); + return embedding; + } + + buildVector(params: { + title: string | null; + authors: string[]; + genres: string[]; + tags: string[]; + seriesName: string | null; + publisher: string | null; + description: string | null; + }): number[] { + const acc = new Float64Array(DIMS); + + const addExact = (text: string, weight: number) => { + const key = text.toLowerCase().trim(); + if (key.length > 0) acc[this.fnv32(key) % DIMS] += weight; + }; + + const addTokens = (text: string, weight: number) => { + for (const token of this.tokenize(text)) { + acc[this.fnv32(token) % DIMS] += weight; + } + }; + + if (params.seriesName) addExact(params.seriesName, 3.0); + for (const author of params.authors) addExact(author, 1.0); + for (const genre of params.genres) addExact(genre, 4.0); + for (const tag of params.tags) addExact(tag, 3.5); + if (params.title) addTokens(params.title, 3.0); + if (params.publisher) addExact(params.publisher, 2.0); + if (params.description) { + addTokens(params.description.split(/\s+/).slice(0, 100).join(' '), 1.0); + } + + return this.l2normalize(Array.from(acc)); + } + + private tokenize(text: string): string[] { + return text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 2 && !STOPWORDS.has(t)); + } + + private fnv32(s: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + hash ^= s.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash; + } + + private l2normalize(vec: number[]): number[] { + const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)); + if (norm === 0) return vec; + return vec.map((v) => v / norm); + } +} diff --git a/server/src/modules/embedding/embedding.module.ts b/server/src/modules/embedding/embedding.module.ts new file mode 100644 index 000000000..a133e6825 --- /dev/null +++ b/server/src/modules/embedding/embedding.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { BookEmbedderService } from './book-embedder.service'; + +@Module({ + providers: [BookEmbedderService], + exports: [BookEmbedderService], +}) +export class EmbeddingModule {} diff --git a/server/src/modules/metadata/metadata.module.ts b/server/src/modules/metadata/metadata.module.ts index 6ad3a5610..bcdeca7c0 100644 --- a/server/src/modules/metadata/metadata.module.ts +++ b/server/src/modules/metadata/metadata.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { EmbeddingModule } from '../embedding/embedding.module'; import { MetadataService } from './metadata.service'; @Module({ + imports: [EmbeddingModule], providers: [MetadataService], exports: [MetadataService], }) diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index b1fc60603..d229f0644 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { and, eq, isNull } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -7,6 +7,7 @@ import { join } from 'path'; import { DB } from '../../db'; import * as schema from '../../db/schema'; +import { BookEmbedderService } from '../embedding/book-embedder.service'; import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema'; import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from './lib/cbz-metadata'; import { extractAndSaveCover, generateThumbnail } from './lib/cover'; @@ -25,6 +26,7 @@ export class MetadataService { constructor( @Inject(DB) private readonly db: Db, private readonly config: ConfigService, + @Optional() private readonly embedder: BookEmbedderService, ) { this.booksPath = this.config.get('storage.booksPath')!; } @@ -170,6 +172,7 @@ export class MetadataService { await this.replaceGenres(bookId, parsed.tags); this.logger.debug(`Metadata saved for book ${bookId}: "${parsed.title}"`); + this.embedder?.embedBook(bookId).catch((err: Error) => this.logger.warn(`Embedding failed for book ${bookId}: ${err.message}`)); } // ── Cover ──────────────────────────────────────────────────────────────────── diff --git a/server/src/modules/recommendation/recommendation.controller.ts b/server/src/modules/recommendation/recommendation.controller.ts new file mode 100644 index 000000000..5a29ee808 --- /dev/null +++ b/server/src/modules/recommendation/recommendation.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; + +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { RequestUser } from '../../common/types/request-user'; +import { RecommendationService } from './recommendation.service'; + +@Controller('books') +export class RecommendationController { + constructor(private readonly recommendationService: RecommendationService) {} + + @Get(':id/recommendations') + getRecommendations(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) { + return this.recommendationService.getRecommendations(id, user); + } +} diff --git a/server/src/modules/recommendation/recommendation.module.ts b/server/src/modules/recommendation/recommendation.module.ts new file mode 100644 index 000000000..34d22a3f7 --- /dev/null +++ b/server/src/modules/recommendation/recommendation.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { BookModule } from '../book/book.module'; +import { EmbeddingModule } from '../embedding/embedding.module'; +import { LibraryModule } from '../library/library.module'; +import { RecommendationController } from './recommendation.controller'; +import { RecommendationRepository } from './recommendation.repository'; +import { RecommendationService } from './recommendation.service'; + +@Module({ + imports: [BookModule, LibraryModule, EmbeddingModule], + controllers: [RecommendationController], + providers: [RecommendationService, RecommendationRepository], +}) +export class RecommendationModule {} diff --git a/server/src/modules/recommendation/recommendation.repository.ts b/server/src/modules/recommendation/recommendation.repository.ts new file mode 100644 index 000000000..bd164414d --- /dev/null +++ b/server/src/modules/recommendation/recommendation.repository.ts @@ -0,0 +1,132 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { eq, inArray, ne, and, isNotNull, sql } from 'drizzle-orm'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, books, genres, tags } from '../../db/schema'; + +type Db = NodePgDatabase; + +export interface AnnCandidate { + bookId: number; + cosineSim: number; + seriesName: string | null; + rating: number | null; +} + +export interface CandidateMetadata { + bookId: number; + authorNames: string[]; + genreTagNames: string[]; +} + +export interface TargetBookData { + embedding: number[] | null; + seriesName: string | null; + rating: number | null; + authorNames: string[]; + genreTagNames: string[]; +} + +@Injectable() +export class RecommendationRepository { + constructor(@Inject(DB) private readonly db: Db) {} + + async getTargetBookData(bookId: number): Promise { + const [meta] = await this.db + .select({ + embedding: bookMetadata.embedding, + seriesName: bookMetadata.seriesName, + rating: bookMetadata.rating, + }) + .from(bookMetadata) + .where(eq(bookMetadata.bookId, bookId)) + .limit(1); + + if (!meta) return null; + + const [authorRows, genreRows, tagRows] = await Promise.all([ + this.db + .select({ name: authors.name }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .where(eq(bookAuthors.bookId, bookId)), + this.db + .select({ name: genres.name }) + .from(bookGenres) + .innerJoin(genres, eq(genres.id, bookGenres.genreId)) + .where(eq(bookGenres.bookId, bookId)), + this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)), + ]); + + return { + embedding: meta.embedding, + seriesName: meta.seriesName, + rating: meta.rating, + authorNames: authorRows.map((r) => r.name), + genreTagNames: [...genreRows.map((r) => r.name), ...tagRows.map((r) => r.name)], + }; + } + + async findAnnCandidates(embedding: number[], targetBookId: number, libraryIds: number[]): Promise { + if (libraryIds.length === 0) return []; + + const vecStr = `[${embedding.join(',')}]`; + + return this.db + .select({ + bookId: bookMetadata.bookId, + cosineSim: sql`(1 - (${bookMetadata.embedding} <=> ${vecStr}::vector))::float`, + seriesName: bookMetadata.seriesName, + rating: bookMetadata.rating, + }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(inArray(books.libraryId, libraryIds), ne(bookMetadata.bookId, targetBookId), isNotNull(bookMetadata.embedding))) + .orderBy(sql`${bookMetadata.embedding} <=> ${vecStr}::vector`) + .limit(100); + } + + async getCandidateMetadata(bookIds: number[]): Promise { + if (bookIds.length === 0) return []; + + const [authorRows, genreRows, tagRows] = await Promise.all([ + this.db + .select({ bookId: bookAuthors.bookId, name: authors.name }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .where(inArray(bookAuthors.bookId, bookIds)), + this.db + .select({ bookId: bookGenres.bookId, name: genres.name }) + .from(bookGenres) + .innerJoin(genres, eq(genres.id, bookGenres.genreId)) + .where(inArray(bookGenres.bookId, bookIds)), + this.db + .select({ bookId: bookTags.bookId, name: tags.name }) + .from(bookTags) + .innerJoin(tags, eq(tags.id, bookTags.tagId)) + .where(inArray(bookTags.bookId, bookIds)), + ]); + + const authorsByBook = new Map(); + for (const r of authorRows) { + const list = authorsByBook.get(r.bookId) ?? []; + list.push(r.name); + authorsByBook.set(r.bookId, list); + } + + const genreTagsByBook = new Map(); + for (const r of [...genreRows, ...tagRows]) { + const list = genreTagsByBook.get(r.bookId) ?? []; + list.push(r.name); + genreTagsByBook.set(r.bookId, list); + } + + return bookIds.map((id) => ({ + bookId: id, + authorNames: authorsByBook.get(id) ?? [], + genreTagNames: genreTagsByBook.get(id) ?? [], + })); + } +} diff --git a/server/src/modules/recommendation/recommendation.service.ts b/server/src/modules/recommendation/recommendation.service.ts new file mode 100644 index 000000000..98f6910d4 --- /dev/null +++ b/server/src/modules/recommendation/recommendation.service.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { inArray } from 'drizzle-orm'; + +import type { BookRecommendation } from '@projectx/types'; +import { assembleBookCards } from '../book/utils/assemble-book-cards'; +import type { RequestUser } from '../../common/types/request-user'; +import { BookEmbedderService } from '../embedding/book-embedder.service'; +import { BookRepository } from '../book/book.repository'; +import { LibraryService } from '../library/library.service'; +import { books } from '../../db/schema'; +import { AnnCandidate, CandidateMetadata, RecommendationRepository, TargetBookData } from './recommendation.repository'; + +@Injectable() +export class RecommendationService { + private readonly logger = new Logger(RecommendationService.name); + + constructor( + private readonly recRepo: RecommendationRepository, + private readonly bookRepo: BookRepository, + private readonly libraryService: LibraryService, + private readonly embedder: BookEmbedderService, + ) {} + + async getRecommendations(bookId: number, user: RequestUser): Promise { + const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId); + if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`); + await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user)); + + const target = await this.recRepo.getTargetBookData(bookId); + if (!target) return []; + + let embedding = target.embedding; + if (!embedding) { + embedding = await this.embedder.embedBook(bookId); + } + if (!embedding) return []; + + const libs = await this.libraryService.findAll(user); + const accessibleLibraryIds = libs.map((l) => l.id); + + const candidates = await this.recRepo.findAnnCandidates(embedding, bookId, accessibleLibraryIds); + if (candidates.length === 0) return []; + + const candidateMetadata = await this.recRepo.getCandidateMetadata(candidates.map((c) => c.bookId)); + const metaMap = new Map(candidateMetadata.map((m) => [m.bookId, m])); + + const rescored = candidates + .map((c) => ({ bookId: c.bookId, score: this.rescore(c, target, metaMap.get(c.bookId) ?? null) })) + .sort((a, b) => b.score - a.score) + .slice(0, 25); + + if (rescored.length === 0) return []; + + const topIds = rescored.map((r) => r.bookId); + const { rows, authorRows, fileRows, genreRows, tagRows, progressRows } = await this.bookRepo.findCards({ + where: inArray(books.id, topIds), + orderBy: [], + limit: 25, + offset: 0, + userId: user.id, + }); + + const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows); + const cardMap = new Map(cards.map((c) => [c.id, c])); + + return rescored.map((r) => ({ book: cardMap.get(r.bookId)!, score: r.score })).filter((r) => r.book != null); + } + + private rescore(candidate: AnnCandidate, target: TargetBookData, meta: CandidateMetadata | null): number { + const cosineSim = Math.max(0, Math.min(1, candidate.cosineSim)); + + const authorSim = meta ? this.jaccard(new Set(target.authorNames), new Set(meta.authorNames)) : 0; + const genreTagSim = meta ? this.jaccard(new Set(target.genreTagNames), new Set(meta.genreTagNames)) : 0; + + const seriesBonus = target.seriesName && candidate.seriesName && target.seriesName === candidate.seriesName ? 1.0 : 0.0; + + let ratingProximity = 0.5; + if (target.rating != null && candidate.rating != null) { + ratingProximity = 1 - Math.abs(target.rating - candidate.rating) / 4; + } + + return 0.5 * cosineSim + 0.1 * authorSim + 0.25 * genreTagSim + 0.1 * seriesBonus + 0.05 * ratingProximity; + } + + private jaccard(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 0; + let intersection = 0; + for (const x of a) if (b.has(x)) intersection++; + return intersection / (a.size + b.size - intersection); + } + + private isSuperuser(user: RequestUser): boolean { + return user.roles.some((r) => r.isSuperuser); + } +}