diff --git a/client/src/features/book/composables/__tests__/useBookEvents.spec.ts b/client/src/features/book/composables/__tests__/useBookEvents.spec.ts
index fec330d24..2abb7023a 100644
--- a/client/src/features/book/composables/__tests__/useBookEvents.spec.ts
+++ b/client/src/features/book/composables/__tests__/useBookEvents.spec.ts
@@ -93,4 +93,3 @@ describe('onBookMissing', () => {
cleanup()
})
})
-
diff --git a/client/src/features/book/composables/useMetadataDiff.ts b/client/src/features/book/composables/useMetadataDiff.ts
index b4c460457..6cc216a31 100644
--- a/client/src/features/book/composables/useMetadataDiff.ts
+++ b/client/src/features/book/composables/useMetadataDiff.ts
@@ -14,7 +14,7 @@ export type DiffFieldKey =
| 'seriesIndex'
| 'isbn13'
| 'isbn10'
- | 'tags'
+ | 'genres'
| 'coverUrl'
export interface DiffField {
@@ -41,7 +41,7 @@ export interface MetadataPatch {
isbn10?: string | null
isbn13?: string | null
authors?: string[]
- tags?: string[]
+ genres?: string[]
googleBooksId?: string | null
goodreadsId?: string | null
amazonId?: string | null
@@ -63,7 +63,7 @@ const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'seriesIndex', label: 'Series Index' },
{ key: 'isbn13', label: 'ISBN-13' },
{ key: 'isbn10', label: 'ISBN-10' },
- { key: 'tags', label: 'Tags' },
+ { key: 'genres', label: 'Genres' },
]
const PROVIDER_ID_FIELD: Record
= {
@@ -79,7 +79,7 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
function getBookValue(key: DiffFieldKey): string {
if (key === 'authors') return book.authors.map((a) => a.name).join(', ')
- if (key === 'tags') return book.tags.join(', ')
+ if (key === 'genres') return book.genres.join(', ')
if (key === 'coverUrl') return ''
const val = book[key as keyof BookDetail]
return val != null ? String(val) : ''
@@ -88,7 +88,7 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
function getCandidateValue(key: DiffFieldKey): string {
if (key === 'coverUrl') return candidate.coverUrl ?? ''
if (key === 'authors') return (candidate.authors ?? []).join(', ')
- if (key === 'tags') return (candidate.tags ?? []).join(', ')
+ if (key === 'genres') return (candidate.genres ?? []).join(', ')
const val = candidate[key as keyof MetadataCandidate]
return val != null ? String(val) : ''
}
@@ -99,16 +99,18 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
if (!candidateVal) return []
const bookVal = getBookValue(def.key)
const isCopied = copiedFields.has(def.key)
- return [{
- key: def.key,
- label: def.label,
- bookValue: bookVal,
- currentDisplay: isCopied ? candidateVal : bookVal,
- candidateDisplay: candidateVal,
- hasDiff: bookVal !== candidateVal,
- isCopied,
- isCover: def.key === 'coverUrl',
- }]
+ return [
+ {
+ key: def.key,
+ label: def.label,
+ bookValue: bookVal,
+ currentDisplay: isCopied ? candidateVal : bookVal,
+ candidateDisplay: candidateVal,
+ hasDiff: bookVal !== candidateVal,
+ isCopied,
+ isCover: def.key === 'coverUrl',
+ },
+ ]
}),
)
@@ -132,12 +134,30 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
let coverUrl: string | undefined
for (const key of copiedFields) {
- if (key === 'coverUrl') { coverUrl = candidate.coverUrl; continue }
- if (key === 'authors') { formPatch.authors = candidate.authors ?? []; continue }
- if (key === 'tags') { formPatch.tags = candidate.tags ?? []; continue }
- if (key === 'publishedYear') { formPatch.publishedYear = candidate.publishedYear ?? null; continue }
- if (key === 'pageCount') { formPatch.pageCount = candidate.pageCount ?? null; continue }
- if (key === 'seriesIndex') { formPatch.seriesIndex = candidate.seriesIndex ?? null; continue }
+ if (key === 'coverUrl') {
+ coverUrl = candidate.coverUrl
+ continue
+ }
+ if (key === 'authors') {
+ formPatch.authors = candidate.authors ?? []
+ continue
+ }
+ if (key === 'genres') {
+ formPatch.genres = candidate.genres ?? []
+ continue
+ }
+ if (key === 'publishedYear') {
+ formPatch.publishedYear = candidate.publishedYear ?? null
+ continue
+ }
+ if (key === 'pageCount') {
+ formPatch.pageCount = candidate.pageCount ?? null
+ continue
+ }
+ if (key === 'seriesIndex') {
+ formPatch.seriesIndex = candidate.seriesIndex ?? null
+ continue
+ }
const val = candidate[key as keyof MetadataCandidate]
;(formPatch as Record)[key] = val != null ? String(val) : null
}
diff --git a/client/src/features/book/composables/useMetadataEditor.ts b/client/src/features/book/composables/useMetadataEditor.ts
index 6bc79208b..f958051cf 100644
--- a/client/src/features/book/composables/useMetadataEditor.ts
+++ b/client/src/features/book/composables/useMetadataEditor.ts
@@ -20,6 +20,7 @@ export function useMetadataEditor() {
isbn13: null as string | null,
rating: null as number | null,
authors: [] as string[],
+ genres: [] as string[],
tags: [] as string[],
googleBooksId: null as string | null,
goodreadsId: null as string | null,
@@ -46,6 +47,7 @@ export function useMetadataEditor() {
form.isbn13 = book.isbn13
form.rating = book.rating ?? null
form.authors = book.authors.map((a) => a.name)
+ form.genres = [...book.genres]
form.tags = [...book.tags]
form.googleBooksId = book.providerIds.google ?? null
form.goodreadsId = book.providerIds.goodreads ?? null
diff --git a/client/src/features/book/composables/useMetadataSearch.ts b/client/src/features/book/composables/useMetadataSearch.ts
index 7c2ccbf5c..84d8a51b6 100644
--- a/client/src/features/book/composables/useMetadataSearch.ts
+++ b/client/src/features/book/composables/useMetadataSearch.ts
@@ -83,31 +83,29 @@ export function useMetadataSearch() {
}
}
-const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary']
+ const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary']
-function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
- const byProvider = new Map()
- for (const r of list) {
- const bucket = byProvider.get(r.provider) ?? []
- bucket.push(r)
- byProvider.set(r.provider, bucket)
+ function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
+ const byProvider = new Map()
+ for (const r of list) {
+ const bucket = byProvider.get(r.provider) ?? []
+ bucket.push(r)
+ byProvider.set(r.provider, bucket)
+ }
+
+ const orderedKeys = [
+ ...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
+ ...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
+ ]
+
+ const out: MetadataCandidate[] = []
+ for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
+ for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
+ return out
}
- const orderedKeys = [
- ...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
- ...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
- ]
-
- const out: MetadataCandidate[] = []
- for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
- for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
- return out
-}
-
const filteredResults = computed(() => {
- const filtered = selectedProviders.value.length
- ? results.value.filter((r) => selectedProviders.value.includes(r.provider))
- : results.value
+ const filtered = selectedProviders.value.length ? results.value.filter((r) => selectedProviders.value.includes(r.provider)) : results.value
return sortResults(filtered)
})
diff --git a/client/src/features/book/composables/useTagSearch.ts b/client/src/features/book/composables/useTagSearch.ts
index ffe49442f..93963e186 100644
--- a/client/src/features/book/composables/useTagSearch.ts
+++ b/client/src/features/book/composables/useTagSearch.ts
@@ -1,13 +1,15 @@
import { api } from '@/lib/api'
-export function useTagSearch() {
+function useMetadataSearch(endpoint: string) {
async function search(q: string): Promise {
if (!q.trim()) return []
- const res = await api(`/api/metadata/tags?q=${encodeURIComponent(q)}`)
+ const res = await api(`/api/metadata/${endpoint}?q=${encodeURIComponent(q)}`)
if (!res.ok) return []
const data: { name: string }[] = await res.json()
- return data.map((t) => t.name)
+ return data.map((item) => item.name)
}
-
return { search }
}
+
+export const useGenreSearch = () => useMetadataSearch('genres')
+export const useTagSearch = () => useMetadataSearch('tags')
diff --git a/client/src/features/book/lib/filter-labels.ts b/client/src/features/book/lib/filter-labels.ts
index 020d3cb7a..0d9a0a14e 100644
--- a/client/src/features/book/lib/filter-labels.ts
+++ b/client/src/features/book/lib/filter-labels.ts
@@ -19,7 +19,7 @@ export const FIELD_LABELS: Record = {
publishedYear: 'Published Year',
pageCount: 'Page Count',
author: 'Author',
- tag: 'Tag',
+ genre: 'Genre',
format: 'Format',
addedAt: 'Added Date',
fileAvailability: 'File Availability',
diff --git a/client/src/features/book/lib/metadata-fetch.ts b/client/src/features/book/lib/metadata-fetch.ts
index 6a6c733aa..4c5eb550a 100644
--- a/client/src/features/book/lib/metadata-fetch.ts
+++ b/client/src/features/book/lib/metadata-fetch.ts
@@ -5,7 +5,7 @@ export function getProviderLabel(provider: MetadataProviderKey, providers: Metad
}
export function hideOnError(e: Event): void {
- (e.target as HTMLImageElement).style.visibility = 'hidden'
+ ;(e.target as HTMLImageElement).style.visibility = 'hidden'
}
/** Brand colors for each provider - used for pill/badge tinting. */
diff --git a/client/src/features/lens/components/LensEditorPanel.vue b/client/src/features/lens/components/LensEditorPanel.vue
index 280ec38ac..106601bb6 100644
--- a/client/src/features/lens/components/LensEditorPanel.vue
+++ b/client/src/features/lens/components/LensEditorPanel.vue
@@ -23,8 +23,8 @@ const TEMPLATES: { label: string; build: () => GroupRule }[] = [
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] } as Rule] }),
},
{
- label: 'No Tags',
- build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'tag', operator: 'isEmpty' } as Rule] }),
+ label: 'No Genres',
+ build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'genre', operator: 'isEmpty' } as Rule] }),
},
{
label: 'EPUB Only',
diff --git a/client/src/features/library/components/LibraryCreatorAccess.vue b/client/src/features/library/components/LibraryCreatorAccess.vue
index 03ce935e9..df35d16a7 100644
--- a/client/src/features/library/components/LibraryCreatorAccess.vue
+++ b/client/src/features/library/components/LibraryCreatorAccess.vue
@@ -63,7 +63,6 @@ async function revoke(userId: number) {
await loadAccess()
}
-
onMounted(loadAccess)
diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue
index bb062c457..b75440b62 100644
--- a/client/src/features/reader/ReaderView.vue
+++ b/client/src/features/reader/ReaderView.vue
@@ -345,13 +345,7 @@ function navigateSearch(cfiTarget: string) {
@search="onSearchQuery"
@clear="onSearchClear"
@navigate="navigateSearch($event)"
- @close="
- () => {
- onSearchClear()
- searchInitialQuery = ''
- showSearch = false
- }
- "
+ @close="onSearchClear(); searchInitialQuery = ''; showSearch = false"
/>
diff --git a/client/src/features/settings/OidcSettings.vue b/client/src/features/settings/OidcSettings.vue
index f1a32d24f..6b6a30b4f 100644
--- a/client/src/features/settings/OidcSettings.vue
+++ b/client/src/features/settings/OidcSettings.vue
@@ -59,7 +59,7 @@ async function save() {
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
- throw new Error((err as Record).message as string ?? 'Failed to save')
+ throw new Error(((err as Record).message as string) ?? 'Failed to save')
}
saveSuccess.value = true
setTimeout(() => {
diff --git a/client/src/views/CollectionView.vue b/client/src/views/CollectionView.vue
index b45c02137..76a9d4211 100644
--- a/client/src/views/CollectionView.vue
+++ b/client/src/views/CollectionView.vue
@@ -36,7 +36,11 @@ const { items: books, total, loading, hasMore, load } = useCollectionBooks(colle
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
function handleSelect(id: number, event: MouseEvent) {
- if (event.shiftKey) rangeSelectTo(id, books.value.map((b) => b.id))
+ if (event.shiftKey)
+ rangeSelectTo(
+ id,
+ books.value.map((b) => b.id),
+ )
else toggleBook(id)
}
diff --git a/packages/types/src/book.ts b/packages/types/src/book.ts
index bcf2c687f..517a5f704 100644
--- a/packages/types/src/book.ts
+++ b/packages/types/src/book.ts
@@ -16,6 +16,7 @@ export type BookCard = {
files: BookFileRef[];
publishedYear: number | null;
language: string | null;
+ genres: string[];
tags: string[];
rating: number | null;
readingProgress: number | null;
@@ -54,6 +55,7 @@ export type BookDetail = {
coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[];
+ genres: string[];
tags: string[];
files: BookDetailFile[];
};
diff --git a/packages/types/src/metadata-fetch.ts b/packages/types/src/metadata-fetch.ts
index cf02f7c26..3845928a3 100644
--- a/packages/types/src/metadata-fetch.ts
+++ b/packages/types/src/metadata-fetch.ts
@@ -23,7 +23,7 @@ export interface MetadataCandidate {
isbn13?: string;
seriesName?: string;
seriesIndex?: number;
- tags?: string[];
+ genres?: string[];
coverUrl?: string;
sourceUrl?: string;
}
diff --git a/packages/types/src/query.ts b/packages/types/src/query.ts
index a759a2f76..a4e74ef3d 100644
--- a/packages/types/src/query.ts
+++ b/packages/types/src/query.ts
@@ -7,7 +7,7 @@ export type RuleField =
| 'publishedYear'
| 'pageCount'
| 'author'
- | 'tag'
+ | 'genre'
| 'format'
| 'addedAt'
| 'fileAvailability'
@@ -48,7 +48,7 @@ export const FIELD_OPERATORS: Record = {
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
- tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
+ genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
diff --git a/server/src/db/migrations/0000_add_provider_ids.sql b/server/src/db/migrations/0000_baseline.sql
similarity index 96%
rename from server/src/db/migrations/0000_add_provider_ids.sql
rename to server/src/db/migrations/0000_baseline.sql
index b903b0049..1e5486bef 100644
--- a/server/src/db/migrations/0000_add_provider_ids.sql
+++ b/server/src/db/migrations/0000_baseline.sql
@@ -183,6 +183,12 @@ CREATE TABLE "book_authors" (
CONSTRAINT "book_authors_book_id_author_id_pk" PRIMARY KEY("book_id","author_id")
);
--> statement-breakpoint
+CREATE TABLE "book_genres" (
+ "book_id" integer NOT NULL,
+ "genre_id" integer NOT NULL,
+ CONSTRAINT "book_genres_book_id_genre_id_pk" PRIMARY KEY("book_id","genre_id")
+);
+--> statement-breakpoint
CREATE TABLE "book_metadata" (
"book_id" integer PRIMARY KEY NOT NULL,
"title" varchar(1000),
@@ -212,6 +218,12 @@ CREATE TABLE "book_tags" (
CONSTRAINT "book_tags_book_id_tag_id_pk" PRIMARY KEY("book_id","tag_id")
);
--> statement-breakpoint
+CREATE TABLE "genres" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "name" varchar(200) NOT NULL,
+ CONSTRAINT "genres_name_unique" UNIQUE("name")
+);
+--> statement-breakpoint
CREATE TABLE "tags" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(200) NOT NULL,
@@ -389,6 +401,8 @@ ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_librari
ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."authors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "book_genres" ADD CONSTRAINT "book_genres_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "book_genres" ADD CONSTRAINT "book_genres_genre_id_genres_id_fk" FOREIGN KEY ("genre_id") REFERENCES "public"."genres"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_metadata" ADD CONSTRAINT "book_metadata_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json
index 77ff9bde3..718ffde65 100644
--- a/server/src/db/migrations/meta/0000_snapshot.json
+++ b/server/src/db/migrations/meta/0000_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "f572f92e-eb3a-4b8c-8a8a-bde7de427787",
+ "id": "0eeff5ae-6f70-4e72-b6f3-c06a1e702c9a",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -41,9 +41,7 @@
"app_settings_key_unique": {
"name": "app_settings_key_unique",
"nullsNotDistinct": false,
- "columns": [
- "key"
- ]
+ "columns": ["key"]
}
},
"policies": {},
@@ -114,12 +112,8 @@
"name": "password_reset_tokens_user_id_users_id_fk",
"tableFrom": "password_reset_tokens",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -129,9 +123,7 @@
"password_reset_tokens_token_hash_unique": {
"name": "password_reset_tokens_token_hash_unique",
"nullsNotDistinct": false,
- "columns": [
- "token_hash"
- ]
+ "columns": ["token_hash"]
}
},
"policies": {},
@@ -182,9 +174,7 @@
"permissions_name_unique": {
"name": "permissions_name_unique",
"nullsNotDistinct": false,
- "columns": [
- "name"
- ]
+ "columns": ["name"]
}
},
"policies": {},
@@ -255,12 +245,8 @@
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -270,9 +256,7 @@
"refresh_tokens_token_hash_unique": {
"name": "refresh_tokens_token_hash_unique",
"nullsNotDistinct": false,
- "columns": [
- "token_hash"
- ]
+ "columns": ["token_hash"]
}
},
"policies": {},
@@ -302,12 +286,8 @@
"name": "role_permissions_role_id_roles_id_fk",
"tableFrom": "role_permissions",
"tableTo": "roles",
- "columnsFrom": [
- "role_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -315,12 +295,8 @@
"name": "role_permissions_permission_id_permissions_id_fk",
"tableFrom": "role_permissions",
"tableTo": "permissions",
- "columnsFrom": [
- "permission_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["permission_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -328,10 +304,7 @@
"compositePrimaryKeys": {
"role_permissions_role_id_permission_id_pk": {
"name": "role_permissions_role_id_permission_id_pk",
- "columns": [
- "role_id",
- "permission_id"
- ]
+ "columns": ["role_id", "permission_id"]
}
},
"uniqueConstraints": {},
@@ -390,9 +363,7 @@
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
- "columns": [
- "name"
- ]
+ "columns": ["name"]
}
},
"policies": {},
@@ -430,12 +401,8 @@
"name": "user_library_access_user_id_users_id_fk",
"tableFrom": "user_library_access",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -443,12 +410,8 @@
"name": "user_library_access_library_id_libraries_id_fk",
"tableFrom": "user_library_access",
"tableTo": "libraries",
- "columnsFrom": [
- "library_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -456,10 +419,7 @@
"compositePrimaryKeys": {
"user_library_access_user_id_library_id_pk": {
"name": "user_library_access_user_id_library_id_pk",
- "columns": [
- "user_id",
- "library_id"
- ]
+ "columns": ["user_id", "library_id"]
}
},
"uniqueConstraints": {},
@@ -490,12 +450,8 @@
"name": "user_roles_user_id_users_id_fk",
"tableFrom": "user_roles",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -503,12 +459,8 @@
"name": "user_roles_role_id_roles_id_fk",
"tableFrom": "user_roles",
"tableTo": "roles",
- "columnsFrom": [
- "role_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -516,10 +468,7 @@
"compositePrimaryKeys": {
"user_roles_user_id_role_id_pk": {
"name": "user_roles_user_id_role_id_pk",
- "columns": [
- "user_id",
- "role_id"
- ]
+ "columns": ["user_id", "role_id"]
}
},
"uniqueConstraints": {},
@@ -636,16 +585,12 @@
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
- "columns": [
- "username"
- ]
+ "columns": ["username"]
},
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
- "columns": [
- "email"
- ]
+ "columns": ["email"]
}
},
"policies": {},
@@ -744,12 +689,8 @@
"name": "book_files_book_id_books_id_fk",
"tableFrom": "book_files",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -757,12 +698,8 @@
"name": "book_files_library_folder_id_library_folders_id_fk",
"tableFrom": "book_files",
"tableTo": "library_folders",
- "columnsFrom": [
- "library_folder_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["library_folder_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -829,12 +766,8 @@
"name": "books_library_id_libraries_id_fk",
"tableFrom": "books",
"tableTo": "libraries",
- "columnsFrom": [
- "library_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -842,12 +775,8 @@
"name": "books_library_folder_id_library_folders_id_fk",
"tableFrom": "books",
"tableTo": "library_folders",
- "columnsFrom": [
- "library_folder_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["library_folder_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -888,12 +817,8 @@
"name": "collection_books_collection_id_collections_id_fk",
"tableFrom": "collection_books",
"tableTo": "collections",
- "columnsFrom": [
- "collection_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["collection_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -901,12 +826,8 @@
"name": "collection_books_book_id_books_id_fk",
"tableFrom": "collection_books",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -914,10 +835,7 @@
"compositePrimaryKeys": {
"collection_books_collection_id_book_id_pk": {
"name": "collection_books_collection_id_book_id_pk",
- "columns": [
- "collection_id",
- "book_id"
- ]
+ "columns": ["collection_id", "book_id"]
}
},
"uniqueConstraints": {},
@@ -987,12 +905,8 @@
"name": "collections_user_id_users_id_fk",
"tableFrom": "collections",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1165,12 +1079,8 @@
"name": "library_folders_library_id_libraries_id_fk",
"tableFrom": "library_folders",
"tableTo": "libraries",
- "columnsFrom": [
- "library_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1250,12 +1160,8 @@
"name": "lenses_user_id_users_id_fk",
"tableFrom": "lenses",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1265,10 +1171,7 @@
"lenses_user_id_name_unique": {
"name": "lenses_user_id_name_unique",
"nullsNotDistinct": false,
- "columns": [
- "user_id",
- "name"
- ]
+ "columns": ["user_id", "name"]
}
},
"policies": {},
@@ -1336,12 +1239,8 @@
"name": "book_authors_book_id_books_id_fk",
"tableFrom": "book_authors",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1349,12 +1248,8 @@
"name": "book_authors_author_id_authors_id_fk",
"tableFrom": "book_authors",
"tableTo": "authors",
- "columnsFrom": [
- "author_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["author_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1362,10 +1257,56 @@
"compositePrimaryKeys": {
"book_authors_book_id_author_id_pk": {
"name": "book_authors_book_id_author_id_pk",
- "columns": [
- "book_id",
- "author_id"
- ]
+ "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": {},
@@ -1505,12 +1446,8 @@
"name": "book_metadata_book_id_books_id_fk",
"tableFrom": "book_metadata",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1544,12 +1481,8 @@
"name": "book_tags_book_id_books_id_fk",
"tableFrom": "book_tags",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1557,12 +1490,8 @@
"name": "book_tags_tag_id_tags_id_fk",
"tableFrom": "book_tags",
"tableTo": "tags",
- "columnsFrom": [
- "tag_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["tag_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1570,10 +1499,7 @@
"compositePrimaryKeys": {
"book_tags_book_id_tag_id_pk": {
"name": "book_tags_book_id_tag_id_pk",
- "columns": [
- "book_id",
- "tag_id"
- ]
+ "columns": ["book_id", "tag_id"]
}
},
"uniqueConstraints": {},
@@ -1581,6 +1507,37 @@
"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": "",
@@ -1605,9 +1562,7 @@
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
- "columns": [
- "name"
- ]
+ "columns": ["name"]
}
},
"policies": {},
@@ -1690,12 +1645,8 @@
"name": "scan_jobs_library_id_libraries_id_fk",
"tableFrom": "scan_jobs",
"tableTo": "libraries",
- "columnsFrom": [
- "library_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1803,12 +1754,8 @@
"name": "annotations_user_id_users_id_fk",
"tableFrom": "annotations",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1816,12 +1763,8 @@
"name": "annotations_book_id_books_id_fk",
"tableFrom": "annotations",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1896,12 +1839,8 @@
"name": "bookmarks_user_id_users_id_fk",
"tableFrom": "bookmarks",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1909,12 +1848,8 @@
"name": "bookmarks_book_id_books_id_fk",
"tableFrom": "bookmarks",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1989,12 +1924,8 @@
"name": "reader_default_preferences_user_id_users_id_fk",
"tableFrom": "reader_default_preferences",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2069,12 +2000,8 @@
"name": "reader_preferences_user_id_users_id_fk",
"tableFrom": "reader_preferences",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2082,12 +2009,8 @@
"name": "reader_preferences_book_file_id_book_files_id_fk",
"tableFrom": "reader_preferences",
"tableTo": "book_files",
- "columnsFrom": [
- "book_file_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2163,12 +2086,8 @@
"name": "reading_progress_book_file_id_book_files_id_fk",
"tableFrom": "reading_progress",
"tableTo": "book_files",
- "columnsFrom": [
- "book_file_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2176,12 +2095,8 @@
"name": "reading_progress_user_id_users_id_fk",
"tableFrom": "reading_progress",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2189,10 +2104,7 @@
"compositePrimaryKeys": {
"reading_progress_book_file_id_user_id_pk": {
"name": "reading_progress_book_file_id_user_id_pk",
- "columns": [
- "book_file_id",
- "user_id"
- ]
+ "columns": ["book_file_id", "user_id"]
}
},
"uniqueConstraints": {},
@@ -2236,12 +2148,8 @@
"name": "oidc_group_mappings_role_id_roles_id_fk",
"tableFrom": "oidc_group_mappings",
"tableTo": "roles",
- "columnsFrom": [
- "role_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -2251,9 +2159,7 @@
"oidc_group_mappings_oidc_group_claim_unique": {
"name": "oidc_group_mappings_oidc_group_claim_unique",
"nullsNotDistinct": false,
- "columns": [
- "oidc_group_claim"
- ]
+ "columns": ["oidc_group_claim"]
}
},
"policies": {},
@@ -2373,12 +2279,8 @@
"name": "oidc_sessions_user_id_users_id_fk",
"tableFrom": "oidc_sessions",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2446,12 +2348,8 @@
"name": "opds_users_user_id_users_id_fk",
"tableFrom": "opds_users",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2461,9 +2359,7 @@
"opds_users_username_unique": {
"name": "opds_users_username_unique",
"nullsNotDistinct": false,
- "columns": [
- "username"
- ]
+ "columns": ["username"]
}
},
"policies": {},
@@ -2518,12 +2414,8 @@
"name": "kobo_devices_user_id_users_id_fk",
"tableFrom": "kobo_devices",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2533,9 +2425,7 @@
"kobo_devices_token_unique": {
"name": "kobo_devices_token_unique",
"nullsNotDistinct": false,
- "columns": [
- "token"
- ]
+ "columns": ["token"]
}
},
"policies": {},
@@ -2579,12 +2469,8 @@
"name": "kobo_library_snapshots_user_id_users_id_fk",
"tableFrom": "kobo_library_snapshots",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2594,9 +2480,7 @@
"kobo_library_snapshots_user_id_unique": {
"name": "kobo_library_snapshots_user_id_unique",
"nullsNotDistinct": false,
- "columns": [
- "user_id"
- ]
+ "columns": ["user_id"]
}
},
"policies": {},
@@ -2687,12 +2571,8 @@
"name": "kobo_reading_states_user_id_users_id_fk",
"tableFrom": "kobo_reading_states",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2700,12 +2580,8 @@
"name": "kobo_reading_states_book_id_books_id_fk",
"tableFrom": "kobo_reading_states",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2715,10 +2591,7 @@
"kobo_reading_states_user_id_book_id_unique": {
"name": "kobo_reading_states_user_id_book_id_unique",
"nullsNotDistinct": false,
- "columns": [
- "user_id",
- "book_id"
- ]
+ "columns": ["user_id", "book_id"]
}
},
"policies": {},
@@ -2788,12 +2661,8 @@
"name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk",
"tableFrom": "kobo_snapshot_books",
"tableTo": "kobo_library_snapshots",
- "columnsFrom": [
- "snapshot_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["snapshot_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2801,12 +2670,8 @@
"name": "kobo_snapshot_books_book_id_books_id_fk",
"tableFrom": "kobo_snapshot_books",
"tableTo": "books",
- "columnsFrom": [
- "book_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2814,10 +2679,7 @@
"compositePrimaryKeys": {
"kobo_snapshot_books_snapshot_id_book_id_pk": {
"name": "kobo_snapshot_books_snapshot_id_book_id_pk",
- "columns": [
- "snapshot_id",
- "book_id"
- ]
+ "columns": ["snapshot_id", "book_id"]
}
},
"uniqueConstraints": {},
@@ -2904,12 +2766,8 @@
"name": "kobo_sync_settings_user_id_users_id_fk",
"tableFrom": "kobo_sync_settings",
"tableTo": "users",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2919,9 +2777,7 @@
"kobo_sync_settings_user_id_unique": {
"name": "kobo_sync_settings_user_id_unique",
"nullsNotDistinct": false,
- "columns": [
- "user_id"
- ]
+ "columns": ["user_id"]
}
},
"policies": {},
@@ -2933,24 +2789,12 @@
"public.library_access_level": {
"name": "library_access_level",
"schema": "public",
- "values": [
- "viewer",
- "editor",
- "owner"
- ]
+ "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"
- ]
+ "values": ["recent", "title_asc", "title_desc", "author_asc", "author_desc", "series_asc", "series_desc"]
}
},
"schemas": {},
@@ -2963,4 +2807,4 @@
"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 57c2913a0..af9e17bec 100644
--- a/server/src/db/migrations/meta/_journal.json
+++ b/server/src/db/migrations/meta/_journal.json
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
- "when": 1771540165274,
- "tag": "0000_add_provider_ids",
+ "when": 1771545632263,
+ "tag": "0000_baseline",
"breakpoints": true
}
]
diff --git a/server/src/db/schema/metadata.ts b/server/src/db/schema/metadata.ts
index 27ed8cacd..6b200492a 100644
--- a/server/src/db/schema/metadata.ts
+++ b/server/src/db/schema/metadata.ts
@@ -47,6 +47,24 @@ export const bookAuthors = pgTable(
(t) => [primaryKey({ columns: [t.bookId, t.authorId] })],
);
+export const genres = pgTable('genres', {
+ id: serial('id').primaryKey(),
+ name: varchar('name', { length: 200 }).notNull().unique(),
+});
+
+export const bookGenres = pgTable(
+ 'book_genres',
+ {
+ bookId: integer('book_id')
+ .notNull()
+ .references(() => books.id, { onDelete: 'cascade' }),
+ genreId: integer('genre_id')
+ .notNull()
+ .references(() => genres.id, { onDelete: 'cascade' }),
+ },
+ (t) => [primaryKey({ columns: [t.bookId, t.genreId] })],
+);
+
export const tags = pgTable('tags', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull().unique(),
@@ -71,5 +89,8 @@ export type NewBookMetadata = typeof bookMetadata.$inferInsert;
export type Author = typeof authors.$inferSelect;
export type NewAuthor = typeof authors.$inferInsert;
+export type Genre = typeof genres.$inferSelect;
+export type NewGenre = typeof genres.$inferInsert;
+
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
diff --git a/server/src/modules/book/book-query-builder.service.ts b/server/src/modules/book/book-query-builder.service.ts
index a49ddecfc..2ade2ba57 100644
--- a/server/src/modules/book/book-query-builder.service.ts
+++ b/server/src/modules/book/book-query-builder.service.ts
@@ -5,7 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
-import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, readingProgress, tags } from '../../db/schema';
+import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, readingProgress, genres } from '../../db/schema';
type Db = NodePgDatabase;
@@ -92,8 +92,8 @@ export class BookQueryBuilder {
return this.numericRuleToSql(bookMetadata.rating, operator, value as number, valueTo as number | undefined);
case 'author':
return this.authorRuleToSql(operator, value as string[]);
- case 'tag':
- return this.tagRuleToSql(operator, value as string[]);
+ case 'genre':
+ return this.genreRuleToSql(operator, value as string[]);
case 'format':
return this.formatRuleToSql(operator, value as string[]);
case 'addedAt':
@@ -186,26 +186,26 @@ export class BookQueryBuilder {
}
}
- private tagRuleToSql(operator: string, values?: string[]): SQL {
+ private genreRuleToSql(operator: string, values?: string[]): SQL {
const sq = (whereClause?: SQL) =>
- this.db.select({ bookId: bookTags.bookId }).from(bookTags).innerJoin(tags, eq(bookTags.tagId, tags.id)).where(whereClause);
+ this.db.select({ bookId: bookGenres.bookId }).from(bookGenres).innerJoin(genres, eq(bookGenres.genreId, genres.id)).where(whereClause);
switch (operator) {
case 'includesAny':
if (!values?.length) return sql`1 = 0`;
- return inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v)))));
+ return inArray(books.id, sq(or(...values.map((v) => eq(genres.name, v)))));
case 'includesAll':
if (!values?.length) return sql`1 = 0`;
- return and(...values.map((v) => inArray(books.id, sq(eq(tags.name, v)))))!;
+ return and(...values.map((v) => inArray(books.id, sq(eq(genres.name, v)))))!;
case 'excludesAll':
if (!values?.length) return sql`1 = 1`;
- return not(inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v))))));
+ return not(inArray(books.id, sq(or(...values.map((v) => eq(genres.name, v))))));
case 'isEmpty':
return not(inArray(books.id, sq()));
case 'isNotEmpty':
return inArray(books.id, sq());
default:
- throw new BadRequestException(`Invalid operator '${operator}' for tag field`);
+ throw new BadRequestException(`Invalid operator '${operator}' for genre field`);
}
}
diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts
index 536e6bb41..d71d6e972 100644
--- a/server/src/modules/book/book.repository.ts
+++ b/server/src/modules/book/book.repository.ts
@@ -4,7 +4,19 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
-import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, libraries, readingProgress, tags } from '../../db/schema';
+import {
+ authors,
+ bookAuthors,
+ bookFiles,
+ bookGenres,
+ bookMetadata,
+ books,
+ bookTags,
+ genres,
+ libraries,
+ readingProgress,
+ tags,
+} from '../../db/schema';
type Db = NodePgDatabase;
@@ -40,7 +52,7 @@ export class BookRepository {
const bookIds = rows.map((r) => r.id);
- const [authorRows, fileRows, tagRows] = await Promise.all([
+ const [authorRows, fileRows, genreRows, tagRows] = await Promise.all([
bookIds.length > 0
? this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
@@ -54,6 +66,13 @@ export class BookRepository {
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds))
+ : ([] as { bookId: number; id: number; format: string | null; role: string }[]),
+ bookIds.length > 0
+ ? this.db
+ .select({ bookId: bookGenres.bookId, name: genres.name })
+ .from(bookGenres)
+ .innerJoin(genres, eq(genres.id, bookGenres.genreId))
+ .where(inArray(bookGenres.bookId, bookIds))
: [],
bookIds.length > 0
? this.db
@@ -73,7 +92,7 @@ export class BookRepository {
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: [];
- return { rows, authorRows, fileRows, tagRows, progressRows, total: Number(total) };
+ return { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total: Number(total) };
}
async findById(id: number) {
@@ -81,13 +100,14 @@ export class BookRepository {
if (!book) return null;
- const [authorRows, tagRows, fileRows] = await Promise.all([
+ const [authorRows, genreRows, tagRows, fileRows] = await Promise.all([
this.db
.select({ id: authors.id, name: authors.name, sortName: authors.sortName })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, id))
.orderBy(bookAuthors.displayOrder),
+ this.db.select({ name: genres.name }).from(bookGenres).innerJoin(genres, eq(genres.id, bookGenres.genreId)).where(eq(bookGenres.bookId, id)),
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, id)),
this.db
.select({
@@ -102,7 +122,7 @@ export class BookRepository {
.where(eq(bookFiles.bookId, id)),
]);
- return { book, authorRows, tagRows, fileRows };
+ return { book, authorRows, genreRows, tagRows, fileRows };
}
async findLibraryIdByBookId(bookId: number): Promise {
diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts
index a18583226..712bfe3f0 100644
--- a/server/src/modules/book/book.service.ts
+++ b/server/src/modules/book/book.service.ts
@@ -4,7 +4,8 @@ import { access, readdir, rm, stat } from 'fs/promises';
import { basename, join } from 'path';
import { MetadataProviderKey } from '@projectx/types';
-import type { BookCard, BookQuery, BooksPage } 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 { MetadataService } from '../metadata/metadata.service';
import { LibraryService } from '../library/library.service';
@@ -46,78 +47,11 @@ export class BookService {
return file;
}
- private assembleBookCards(
- rows: {
- id: number;
- status: string;
- folderPath: string;
- addedAt: Date;
- title: string | null;
- seriesName: string | null;
- seriesIndex: number | null;
- publishedYear: number | null;
- language: string | null;
- rating: number | null;
- }[],
- authorRows: { bookId: number; name: string }[],
- fileRows: { bookId: number; id: number; format: string | null; role: string }[],
- tagRows: { bookId: number; name: string }[],
- progressRows: { bookFileId: number; percentage: number }[],
- ): BookCard[] {
- const authorsByBook = new Map();
- for (const row of authorRows) {
- const list = authorsByBook.get(row.bookId) ?? [];
- list.push(row.name);
- authorsByBook.set(row.bookId, list);
- }
-
- const filesByBook = new Map();
- for (const row of fileRows) {
- const list = filesByBook.get(row.bookId) ?? [];
- list.push({ id: row.id, format: row.format, role: row.role });
- filesByBook.set(row.bookId, list);
- }
-
- const tagsByBook = new Map();
- for (const row of tagRows) {
- const list = tagsByBook.get(row.bookId) ?? [];
- list.push(row.name);
- tagsByBook.set(row.bookId, list);
- }
-
- const progressByFileId = new Map();
- for (const row of progressRows) {
- progressByFileId.set(row.bookFileId, row.percentage);
- }
-
- return rows.map((row) => {
- const files = filesByBook.get(row.id) ?? [];
- const primaryFile = files.find((f) => f.role === 'primary') ?? files[0] ?? null;
- const readingProgress = primaryFile != null ? (progressByFileId.get(primaryFile.id) ?? null) : null;
-
- return {
- id: row.id,
- status: row.status,
- title: row.title ?? basename(row.folderPath),
- seriesName: row.seriesName ?? null,
- seriesIndex: row.seriesIndex ?? null,
- authors: authorsByBook.get(row.id) ?? [],
- files,
- publishedYear: row.publishedYear ?? null,
- language: row.language ?? null,
- tags: tagsByBook.get(row.id) ?? [],
- rating: row.rating ?? null,
- readingProgress,
- addedAt: row.addedAt.toISOString(),
- };
- });
- }
-
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
- const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
+ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: query.pagination.size,
@@ -125,7 +59,7 @@ export class BookService {
userId: user.id,
});
return {
- items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows),
+ items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows),
total,
page: query.pagination.page,
size: query.pagination.size,
@@ -137,7 +71,7 @@ export class BookService {
const accessibleLibraryIds = libs.map((l) => l.id);
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
- const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
+ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: query.pagination.size,
@@ -145,7 +79,7 @@ export class BookService {
userId: user.id,
});
return {
- items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows),
+ items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows),
total,
page: query.pagination.page,
size: query.pagination.size,
@@ -232,6 +166,9 @@ export class BookService {
dto.authors.map((name) => ({ name, sortName: null })),
);
}
+ if (dto.genres !== undefined) {
+ await this.metadataService.replaceGenres(id, dto.genres);
+ }
if (dto.tags !== undefined) {
await this.metadataService.replaceTags(id, dto.tags);
}
@@ -254,7 +191,7 @@ export class BookService {
const result = await this.bookRepo.findById(id);
if (!result) throw new NotFoundException(`Book ${id} not found`);
- const { book, authorRows, tagRows, fileRows } = result;
+ const { book, authorRows, genreRows, tagRows, fileRows } = result;
const meta = book.book_metadata;
return {
@@ -283,6 +220,7 @@ export class BookService {
[MetadataProviderKey.OPEN_LIBRARY]: meta?.openLibraryId ?? null,
},
authors: authorRows,
+ genres: genreRows.map((g) => g.name),
tags: tagRows.map((t) => t.name),
files: fileRows.map((f) => ({
id: f.id,
diff --git a/server/src/modules/book/dto/book-card.dto.ts b/server/src/modules/book/dto/book-card.dto.ts
index 57fef1ab3..0feef0f8f 100644
--- a/server/src/modules/book/dto/book-card.dto.ts
+++ b/server/src/modules/book/dto/book-card.dto.ts
@@ -14,6 +14,7 @@ export class BookCardDto {
files: BookFileRefDto[];
publishedYear: number | null;
language: string | null;
+ genres: string[];
tags: string[];
rating: number | null;
readingProgress: number | null;
diff --git a/server/src/modules/book/dto/book-detail.dto.ts b/server/src/modules/book/dto/book-detail.dto.ts
index 3c9609f57..260d4732b 100644
--- a/server/src/modules/book/dto/book-detail.dto.ts
+++ b/server/src/modules/book/dto/book-detail.dto.ts
@@ -30,6 +30,7 @@ export class BookDetailDto {
coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[];
+ genres: string[];
tags: string[];
files: BookFileDto[];
}
diff --git a/server/src/modules/book/dto/update-book-metadata.dto.ts b/server/src/modules/book/dto/update-book-metadata.dto.ts
index 2f29ab37a..b2b1e50fc 100644
--- a/server/src/modules/book/dto/update-book-metadata.dto.ts
+++ b/server/src/modules/book/dto/update-book-metadata.dto.ts
@@ -14,6 +14,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
@IsOptional() @IsArray() @IsString({ each: true }) authors?: string[];
+ @IsOptional() @IsArray() @IsString({ each: true }) genres?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null;
@IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
diff --git a/server/src/modules/book/utils/assemble-book-cards.ts b/server/src/modules/book/utils/assemble-book-cards.ts
new file mode 100644
index 000000000..1b25a3e31
--- /dev/null
+++ b/server/src/modules/book/utils/assemble-book-cards.ts
@@ -0,0 +1,85 @@
+import { basename } from 'path';
+
+import type { BookCard } from '@projectx/types';
+
+type BookRow = {
+ id: number;
+ status: string;
+ folderPath: string;
+ addedAt: Date;
+ title: string | null;
+ seriesName: string | null;
+ seriesIndex: number | null;
+ publishedYear: number | null;
+ language: string | null;
+ rating: number | null;
+};
+
+type NameRow = { bookId: number; name: string };
+type FileRow = { bookId: number; id: number; format: string | null; role: string };
+type ProgressRow = { bookFileId: number; percentage: number | null };
+
+export function assembleBookCards(
+ rows: BookRow[],
+ authorRows: NameRow[],
+ fileRows: FileRow[],
+ genreRows: NameRow[],
+ tagRows: NameRow[],
+ progressRows: ProgressRow[],
+): BookCard[] {
+ const authorsByBook = new Map();
+ for (const row of authorRows) {
+ const list = authorsByBook.get(row.bookId) ?? [];
+ list.push(row.name);
+ authorsByBook.set(row.bookId, list);
+ }
+
+ const filesByBook = new Map();
+ for (const row of fileRows) {
+ const list = filesByBook.get(row.bookId) ?? [];
+ list.push({ id: row.id, format: row.format, role: row.role });
+ filesByBook.set(row.bookId, list);
+ }
+
+ const genresByBook = new Map();
+ for (const row of genreRows) {
+ const list = genresByBook.get(row.bookId) ?? [];
+ list.push(row.name);
+ genresByBook.set(row.bookId, list);
+ }
+
+ const tagsByBook = new Map();
+ for (const row of tagRows) {
+ const list = tagsByBook.get(row.bookId) ?? [];
+ list.push(row.name);
+ tagsByBook.set(row.bookId, list);
+ }
+
+ const progressByFileId = new Map();
+ for (const row of progressRows) {
+ progressByFileId.set(row.bookFileId, row.percentage);
+ }
+
+ return rows.map((row) => {
+ const files = filesByBook.get(row.id) ?? [];
+ const primaryFile = files.find((f) => f.role === 'primary') ?? files[0] ?? null;
+ const readingProgress = primaryFile != null ? (progressByFileId.get(primaryFile.id) ?? null) : null;
+
+ return {
+ id: row.id,
+ status: row.status,
+ title: row.title ?? basename(row.folderPath),
+ seriesName: row.seriesName ?? null,
+ seriesIndex: row.seriesIndex ?? null,
+ authors: authorsByBook.get(row.id) ?? [],
+ files,
+ publishedYear: row.publishedYear ?? null,
+ language: row.language ?? null,
+ genres: genresByBook.get(row.id) ?? [],
+ tags: tagsByBook.get(row.id) ?? [],
+ rating: row.rating ?? null,
+ readingProgress,
+ addedAt: row.addedAt.toISOString(),
+ };
+ });
+}
diff --git a/server/src/modules/book/utils/group-rule.validator.ts b/server/src/modules/book/utils/group-rule.validator.ts
index 7a30b3791..276882b2e 100644
--- a/server/src/modules/book/utils/group-rule.validator.ts
+++ b/server/src/modules/book/utils/group-rule.validator.ts
@@ -14,7 +14,7 @@ const RULE_FIELDS = [
'publishedYear',
'pageCount',
'author',
- 'tag',
+ 'genre',
'format',
'addedAt',
'fileAvailability',
@@ -56,7 +56,7 @@ const VALID_OPERATORS_BY_FIELD: Record = {
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
- tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
+ genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
diff --git a/server/src/modules/catalog/catalog.controller.ts b/server/src/modules/catalog/catalog.controller.ts
index 306ff5269..4275bb19d 100644
--- a/server/src/modules/catalog/catalog.controller.ts
+++ b/server/src/modules/catalog/catalog.controller.ts
@@ -11,6 +11,11 @@ export class CatalogController {
return this.catalogService.searchAuthors(q);
}
+ @Get('genres')
+ searchGenres(@Query('q') q = '') {
+ return this.catalogService.searchGenres(q);
+ }
+
@Get('tags')
searchTags(@Query('q') q = '') {
return this.catalogService.searchTags(q);
diff --git a/server/src/modules/catalog/catalog.service.ts b/server/src/modules/catalog/catalog.service.ts
index e5629a063..176c12eb6 100644
--- a/server/src/modules/catalog/catalog.service.ts
+++ b/server/src/modules/catalog/catalog.service.ts
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
-import { authors, tags } from '../../db/schema';
+import { authors, genres, tags } from '../../db/schema';
type Db = NodePgDatabase;
@@ -12,23 +12,25 @@ type Db = NodePgDatabase;
export class CatalogService {
constructor(@Inject(DB) private readonly db: Db) {}
- async searchAuthors(q: string): Promise<{ id: number; name: string }[]> {
- if (!q.trim()) return [];
- return this.db
- .select({ id: authors.id, name: authors.name })
- .from(authors)
- .where(ilike(authors.name, `%${q}%`))
- .orderBy(authors.name)
- .limit(15);
+ searchAuthors(q: string) {
+ return this.searchByName(q, authors);
}
- async searchTags(q: string): Promise<{ id: number; name: string }[]> {
- if (!q.trim()) return [];
+ searchGenres(q: string) {
+ return this.searchByName(q, genres);
+ }
+
+ searchTags(q: string) {
+ return this.searchByName(q, tags);
+ }
+
+ private searchByName(q: string, table: typeof authors | typeof genres | typeof tags): Promise<{ id: number; name: string }[]> {
+ if (!q.trim()) return Promise.resolve([]);
return this.db
- .select({ id: tags.id, name: tags.name })
- .from(tags)
- .where(ilike(tags.name, `%${q}%`))
- .orderBy(tags.name)
+ .select({ id: table.id, name: table.name })
+ .from(table)
+ .where(ilike(table.name, `%${q}%`))
+ .orderBy(table.name)
.limit(15);
}
}
diff --git a/server/src/modules/collection/collection.service.ts b/server/src/modules/collection/collection.service.ts
index fd998e24b..d388c2559 100644
--- a/server/src/modules/collection/collection.service.ts
+++ b/server/src/modules/collection/collection.service.ts
@@ -1,8 +1,8 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
-import { basename } from 'path';
import { inArray } from 'drizzle-orm';
-import type { BookCard, BooksPage } from '@projectx/types';
+import type { BooksPage } from '@projectx/types';
+import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { books } from '../../db/schema';
import { BookRepository } from '../book/book.repository';
@@ -28,71 +28,6 @@ export class CollectionService {
}
}
- private assembleBookCards(
- rows: {
- id: number;
- status: string;
- folderPath: string;
- addedAt: Date;
- title: string | null;
- seriesName: string | null;
- seriesIndex: number | null;
- publishedYear: number | null;
- language: string | null;
- rating: number | null;
- }[],
- authorRows: { bookId: number; name: string }[],
- fileRows: { bookId: number; id: number; format: string | null; role: string }[],
- tagRows: { bookId: number; name: string }[],
- progressRows: { bookFileId: number; percentage: number | null }[],
- ): BookCard[] {
- const authorsByBook = new Map();
- for (const row of authorRows) {
- const list = authorsByBook.get(row.bookId) ?? [];
- list.push(row.name);
- authorsByBook.set(row.bookId, list);
- }
-
- const filesByBook = new Map();
- for (const row of fileRows) {
- const list = filesByBook.get(row.bookId) ?? [];
- list.push({ id: row.id, format: row.format, role: row.role });
- filesByBook.set(row.bookId, list);
- }
-
- const tagsByBook = new Map();
- for (const row of tagRows) {
- const list = tagsByBook.get(row.bookId) ?? [];
- list.push(row.name);
- tagsByBook.set(row.bookId, list);
- }
-
- const progressByFileId = new Map();
- for (const row of progressRows) {
- progressByFileId.set(row.bookFileId, row.percentage);
- }
-
- return rows.map((row) => {
- const primaryFile = (filesByBook.get(row.id) ?? []).find((f) => f.role === 'primary');
- const readingProgress = primaryFile ? (progressByFileId.get(primaryFile.id) ?? null) : null;
- return {
- id: row.id,
- status: row.status,
- title: row.title ?? basename(row.folderPath),
- seriesName: row.seriesName ?? null,
- seriesIndex: row.seriesIndex ?? null,
- publishedYear: row.publishedYear ?? null,
- language: row.language ?? null,
- rating: row.rating ?? null,
- addedAt: row.addedAt.toISOString(),
- authors: authorsByBook.get(row.id) ?? [],
- files: filesByBook.get(row.id) ?? [],
- tags: tagsByBook.get(row.id) ?? [],
- readingProgress,
- };
- });
- }
-
findAll(user: RequestUser, bookIds?: number[]) {
if (bookIds && bookIds.length > 0) {
return this.collectionRepo.findAllForUserWithMembership(user.id, bookIds);
@@ -170,7 +105,7 @@ export class CollectionService {
const bookIds = bookIdRows.map((r) => r.bookId);
const where = inArray(books.id, bookIds);
- const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
+ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
orderBy: [],
limit: size,
@@ -178,6 +113,6 @@ export class CollectionService {
userId: user.id,
});
- return { items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows), total, page, size };
+ return { items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows), total, page, size };
}
}
diff --git a/server/src/modules/lens/lens.service.ts b/server/src/modules/lens/lens.service.ts
index 0b56b1300..03b79b208 100644
--- a/server/src/modules/lens/lens.service.ts
+++ b/server/src/modules/lens/lens.service.ts
@@ -1,7 +1,7 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
-import { basename } from 'path';
-import type { BookCard, BooksPage, GroupRule, SortSpec } from '@projectx/types';
+import type { BooksPage, GroupRule, SortSpec } from '@projectx/types';
+import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryBuilder } from '../book/book-query-builder.service';
import { BookRepository } from '../book/book.repository';
@@ -24,71 +24,6 @@ export class LensService {
return user.roles.some((r) => r.isSuperuser);
}
- private assembleBookCards(
- rows: {
- id: number;
- status: string;
- folderPath: string;
- addedAt: Date;
- title: string | null;
- seriesName: string | null;
- seriesIndex: number | null;
- publishedYear: number | null;
- language: string | null;
- rating: number | null;
- }[],
- authorRows: { bookId: number; name: string }[],
- fileRows: { bookId: number; id: number; format: string | null; role: string }[],
- tagRows: { bookId: number; name: string }[],
- progressRows: { bookFileId: number; percentage: number | null }[],
- ): BookCard[] {
- const authorsByBook = new Map();
- for (const row of authorRows) {
- const list = authorsByBook.get(row.bookId) ?? [];
- list.push(row.name);
- authorsByBook.set(row.bookId, list);
- }
-
- const filesByBook = new Map();
- for (const row of fileRows) {
- const list = filesByBook.get(row.bookId) ?? [];
- list.push({ id: row.id, format: row.format, role: row.role });
- filesByBook.set(row.bookId, list);
- }
-
- const tagsByBook = new Map();
- for (const row of tagRows) {
- const list = tagsByBook.get(row.bookId) ?? [];
- list.push(row.name);
- tagsByBook.set(row.bookId, list);
- }
-
- const progressByFileId = new Map();
- for (const row of progressRows) {
- progressByFileId.set(row.bookFileId, row.percentage);
- }
-
- return rows.map((row) => {
- const primaryFile = (filesByBook.get(row.id) ?? []).find((f) => f.role === 'primary');
- const readingProgress = primaryFile ? (progressByFileId.get(primaryFile.id) ?? null) : null;
- return {
- id: row.id,
- status: row.status,
- title: row.title ?? basename(row.folderPath),
- seriesName: row.seriesName ?? null,
- seriesIndex: row.seriesIndex ?? null,
- publishedYear: row.publishedYear ?? null,
- language: row.language ?? null,
- rating: row.rating ?? null,
- addedAt: row.addedAt.toISOString(),
- authors: authorsByBook.get(row.id) ?? [],
- files: filesByBook.get(row.id) ?? [],
- tags: tagsByBook.get(row.id) ?? [],
- readingProgress,
- };
- });
- }
-
async findAll(user: RequestUser) {
const lenses = await this.lensRepo.findAllForUser(user.id);
const libs = await this.libraryService.findAll(user);
@@ -160,7 +95,7 @@ export class LensService {
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds });
const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []);
- const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
+ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: size,
@@ -168,6 +103,6 @@ export class LensService {
userId: user.id,
});
- return { items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows), total, page, size };
+ return { items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows), total, page, size };
}
}
diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts
index fc187149e..e34601132 100644
--- a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts
+++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts
@@ -6,8 +6,7 @@ import { MetadataSearchParams } from '../metadata-search-params';
import { extractAsins, parseBookPage } from './amazon.scraper';
const HEADERS: HeadersInit = {
- 'user-agent':
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not_A Brand";v="24"',
@@ -75,7 +74,7 @@ export class AmazonProvider implements IdentifiableProvider {
seriesName: data.seriesName,
seriesIndex: data.seriesIndex,
coverUrl: data.coverUrl,
- tags: data.tags?.length ? data.tags : undefined,
+ genres: data.tags?.length ? data.tags : undefined,
sourceUrl: `https://www.amazon.com/dp/${asin}`,
};
}
diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.scraper.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.scraper.ts
index 001b9fae0..b290f8723 100644
--- a/server/src/modules/metadata-fetch/providers/amazon/amazon.scraper.ts
+++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.scraper.ts
@@ -151,10 +151,7 @@ function extractPageCount($: CheerioAPI): number | undefined {
}
function extractSeriesName($: CheerioAPI): string | undefined {
- return (
- $('#rpi-attribute-book_details-series .rpi-attribute-value a span').first().text().trim() ||
- undefined
- );
+ return $('#rpi-attribute-book_details-series .rpi-attribute-value a span').first().text().trim() || undefined;
}
function extractSeriesIndex($: CheerioAPI): number | undefined {
@@ -177,7 +174,10 @@ function extractCoverUrl($: CheerioAPI): string | undefined {
function extractCategories($: CheerioAPI): string[] {
const cats: string[] = [];
$('#detailBullets_feature_div .zg_hrsr .a-list-item a').each((_, el) => {
- const name = $(el).text().trim().replace(/\s*\(Books\)\s*$/i, '');
+ const name = $(el)
+ .text()
+ .trim()
+ .replace(/\s*\(Books\)\s*$/i, '');
if (name) cats.push(name);
});
return cats;
diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
index 2ff88f5be..c696c6926 100644
--- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
+++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
@@ -1,15 +1,8 @@
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
-import {
- GoodreadsApolloBook,
- GoodreadsApolloContributor,
- GoodreadsApolloSeries,
-} from './goodreads.types';
+import { GoodreadsApolloBook, GoodreadsApolloContributor, GoodreadsApolloSeries } from './goodreads.types';
-export function mapGoodreadsApolloState(
- state: Record,
- bookId: string,
-): MetadataCandidate | null {
+export function mapGoodreadsApolloState(state: Record, bookId: string): MetadataCandidate | null {
const book = findByKeyPrefix(state, 'Book:kca:');
if (!book?.title) return null;
@@ -19,9 +12,7 @@ export function mapGoodreadsApolloState(
const details = book.details;
const firstSeries = book.bookSeries?.[0];
- const tags = (book.bookGenres ?? [])
- .map((g) => g.genre?.name)
- .filter((n): n is string => !!n);
+ const genres = (book.bookGenres ?? []).map((g) => g.genre?.name).filter((n): n is string => !!n);
const { title, subtitle } = splitTitle(book.title);
@@ -42,7 +33,7 @@ export function mapGoodreadsApolloState(
pageCount,
isbn10: normalize(details?.isbn),
isbn13: normalize(details?.isbn13),
- tags: tags.length ? tags : undefined,
+ genres: genres.length ? genres : undefined,
coverUrl: book.imageUrl,
sourceUrl: `https://www.goodreads.com/book/show/${bookId}`,
seriesName: normalize(series?.title),
diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts
index 7534bee0f..fb8dcbc05 100644
--- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts
+++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts
@@ -7,8 +7,7 @@ import { mapGoodreadsApolloState } from './goodreads.mapper';
import { GoodreadsNextData } from './goodreads.types';
const HEADERS: HeadersInit = {
- 'user-agent':
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9',
};
@@ -25,9 +24,7 @@ export class GoodreadsProvider implements IdentifiableProvider {
private readonly logger = new Logger(GoodreadsProvider.name);
async search(params: MetadataSearchParams): Promise {
- const ids = params.isbn
- ? await this.findIdByIsbn(params.isbn).then((id) => (id ? [id] : []))
- : await this.searchIds(params);
+ const ids = params.isbn ? await this.findIdByIsbn(params.isbn).then((id) => (id ? [id] : [])) : await this.searchIds(params);
const results: MetadataCandidate[] = [];
for (const id of ids.slice(0, MAX_RESULTS)) {
diff --git a/server/src/modules/metadata-fetch/providers/google/google.mapper.ts b/server/src/modules/metadata-fetch/providers/google/google.mapper.ts
index ed9464a1a..94d1729fa 100644
--- a/server/src/modules/metadata-fetch/providers/google/google.mapper.ts
+++ b/server/src/modules/metadata-fetch/providers/google/google.mapper.ts
@@ -34,7 +34,7 @@ export function mapGoogleVolume(raw: GoogleVolumeItem): MetadataCandidate {
pageCount: info.pageCount,
isbn10,
isbn13,
- tags: info.categories,
+ genres: info.categories,
coverUrl,
sourceUrl: `https://books.google.com/books?id=${raw.id}`,
};
diff --git a/server/src/modules/metadata-fetch/providers/open-library/open-library.mapper.ts b/server/src/modules/metadata-fetch/providers/open-library/open-library.mapper.ts
index d9a869347..9c23df3ac 100644
--- a/server/src/modules/metadata-fetch/providers/open-library/open-library.mapper.ts
+++ b/server/src/modules/metadata-fetch/providers/open-library/open-library.mapper.ts
@@ -39,7 +39,7 @@ export function mapOpenLibraryDoc(raw: OpenLibraryDoc): MetadataCandidate {
publisher: raw.publisher?.[0],
language: raw.language?.[0],
pageCount: raw.number_of_pages_median,
- tags: raw.subject?.slice(0, 10),
+ genres: raw.subject?.slice(0, 10),
coverUrl: coverUrl(raw.cover_i),
sourceUrl: `https://openlibrary.org${raw.key}`,
};
@@ -55,7 +55,7 @@ export function mapOpenLibraryWork(raw: OpenLibraryWork): MetadataCandidate {
title: raw.title,
description: extractDescription(raw.description),
publishedYear: parseYear(raw.first_publish_date),
- tags: raw.subjects?.slice(0, 10),
+ genres: raw.subjects?.slice(0, 10),
coverUrl: coverUrl(firstCover),
sourceUrl: `https://openlibrary.org${raw.key}`,
};
diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts
index ad5985be0..b1fc60603 100644
--- a/server/src/modules/metadata/metadata.service.ts
+++ b/server/src/modules/metadata/metadata.service.ts
@@ -7,7 +7,7 @@ import { join } from 'path';
import { DB } from '../../db';
import * as schema from '../../db/schema';
-import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/schema';
+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';
import { extractEpubMetadata } from './lib/epub';
@@ -167,7 +167,7 @@ export class MetadataService {
.where(eq(bookMetadata.bookId, bookId));
await this.replaceAuthors(bookId, parsed.authors);
- await this.replaceTags(bookId, parsed.tags);
+ await this.replaceGenres(bookId, parsed.tags);
this.logger.debug(`Metadata saved for book ${bookId}: "${parsed.title}"`);
}
@@ -231,21 +231,34 @@ export class MetadataService {
}
}
+ // ── Genres ───────────────────────────────────────────────────────────────────
+
+ async replaceGenres(bookId: number, parsedGenres: string[]) {
+ await this.db.delete(bookGenres).where(eq(bookGenres.bookId, bookId));
+ const unique = [...new Set(parsedGenres.map((g) => g.substring(0, 200)).filter(Boolean))];
+ if (unique.length === 0) return;
+
+ for (const name of unique) {
+ let [genre] = await this.db.select().from(genres).where(eq(genres.name, name)).limit(1);
+ if (!genre) {
+ [genre] = await this.db.insert(genres).values({ name }).returning();
+ }
+ await this.db.insert(bookGenres).values({ bookId, genreId: genre.id }).onConflictDoNothing();
+ }
+ }
+
// ── Tags ─────────────────────────────────────────────────────────────────────
- async replaceTags(bookId: number, parsedTags: string[]) {
+ async replaceTags(bookId: number, userTags: string[]) {
await this.db.delete(bookTags).where(eq(bookTags.bookId, bookId));
- const uniqueTags = [...new Set(parsedTags.map((t) => t.substring(0, 200)).filter(Boolean))];
- if (uniqueTags.length === 0) return;
+ const unique = [...new Set(userTags.map((t) => t.substring(0, 200)).filter(Boolean))];
+ if (unique.length === 0) return;
- for (const rawName of uniqueTags) {
- const name = rawName;
+ for (const name of unique) {
let [tag] = await this.db.select().from(tags).where(eq(tags.name, name)).limit(1);
-
if (!tag) {
[tag] = await this.db.insert(tags).values({ name }).returning();
}
-
await this.db.insert(bookTags).values({ bookId, tagId: tag.id }).onConflictDoNothing();
}
}