mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add cover extraction for epub, mobi, cbz, and pdf
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/dist
|
||||
**/*.log
|
||||
.env
|
||||
client/.env
|
||||
server/.env
|
||||
docker/postgres/data
|
||||
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 150
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Stage 1: Build client
|
||||
FROM node:24-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package.json client/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY client/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build server
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY server/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Final image — production only, no devDependencies
|
||||
FROM node:24-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./dist
|
||||
COPY --from=server-builder /app/server/package.json ./package.json
|
||||
COPY --from=server-builder /app/server/package-lock.json ./package-lock.json
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Vue build served as static files by NestJS
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:24-alpine
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY client/package.json ./client/
|
||||
COPY server/package.json ./server/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000 5173
|
||||
|
||||
CMD ["pnpm", "dev"]
|
||||
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 150
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 150
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# client
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/assets/main.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,32 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7476
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.19.3",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/eslint-plugin": "^1.6.1",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.35.0",
|
||||
"eslint-plugin-vue": "~10.6.2",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^27.3.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.35.0",
|
||||
"prettier": "3.7.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.5",
|
||||
"vitest": "^4.0.16",
|
||||
"vue-tsc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,165 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Theme tokens ───────────────────────────────────────────
|
||||
All design decisions live here. Change a value here and
|
||||
it propagates everywhere. Never hardcode colours in components.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
||||
--radius: 0.625rem;
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import type { Book } from '../composables/useBooks'
|
||||
import { bookCoverStyle } from '../composables/useBooks'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ book: Book }>()
|
||||
|
||||
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
|
||||
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
|
||||
const seriesLine = computed(() => {
|
||||
if (!props.book.seriesName) return null
|
||||
const idx = props.book.seriesIndex
|
||||
return idx != null ? `${props.book.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : props.book.seriesName
|
||||
})
|
||||
|
||||
const coverUrl = `/api/books/${props.book.id}/cover`
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group cursor-pointer flex flex-col gap-1.5">
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md group-hover:shadow-xl group-hover:scale-[1.02] transition-all duration-150"
|
||||
style="aspect-ratio: 2/3"
|
||||
:style="coverLoaded ? {} : coverStyle"
|
||||
>
|
||||
<!-- Real cover image -->
|
||||
<img
|
||||
v-if="!coverFailed"
|
||||
:src="coverUrl"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
:class="{ 'opacity-0': !coverLoaded }"
|
||||
@load="coverLoaded = true"
|
||||
@error="coverFailed = true"
|
||||
:alt="book.title ?? ''"
|
||||
/>
|
||||
|
||||
<!-- Series badge top-left -->
|
||||
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5">
|
||||
<span
|
||||
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-black/30 backdrop-blur-sm line-clamp-1"
|
||||
:style="{ color: coverStyle.color }"
|
||||
>
|
||||
{{ seriesLine }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Missing overlay -->
|
||||
<div v-if="book.status === 'missing'" class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-destructive-foreground bg-destructive px-2 py-0.5 rounded">
|
||||
Missing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title + author pinned to bottom (shown when no real cover) -->
|
||||
<div v-if="!coverLoaded" class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<p class="text-xs font-bold leading-tight line-clamp-3" :style="{ color: coverStyle.color }">
|
||||
{{ book.title ?? '—' }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-[10px] mt-0.5 opacity-80 truncate" :style="{ color: coverStyle.color }">
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text below -->
|
||||
<div class="px-0.5">
|
||||
<p class="text-xs font-medium text-foreground truncate leading-tight">
|
||||
{{ book.title ?? '—' }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-[11px] text-muted-foreground truncate">
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
|
||||
export interface Book {
|
||||
id: number
|
||||
status: string
|
||||
title: string | null
|
||||
authors: string[]
|
||||
seriesName: string | null
|
||||
seriesIndex: number | null
|
||||
}
|
||||
|
||||
interface BooksPage {
|
||||
items: Book[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// Deterministic per-book cover hue from title — OKLch, always dark and readable.
|
||||
export function bookCoverStyle(seed: string): { background: string; color: string } {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const hue = Math.abs(hash) % 360
|
||||
return {
|
||||
background: `oklch(0.22 0.07 ${hue})`,
|
||||
color: `oklch(0.92 0.03 ${hue})`,
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useBooks(libraryId: Ref<number | null>) {
|
||||
const books = ref<Book[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
|
||||
const hasMore = computed(() => books.value.length < total.value)
|
||||
|
||||
async function load(reset = false) {
|
||||
if (loading.value || libraryId.value === null) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const currentPage = reset ? 0 : page.value
|
||||
const params = new URLSearchParams({
|
||||
libraryId: String(libraryId.value),
|
||||
page: String(currentPage),
|
||||
size: String(PAGE_SIZE),
|
||||
})
|
||||
if (search.value.trim()) params.set('search', search.value.trim())
|
||||
|
||||
const res = await fetch(`/api/books?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const data: BooksPage = await res.json()
|
||||
|
||||
if (reset) {
|
||||
books.value = data.items
|
||||
page.value = 1
|
||||
} else {
|
||||
books.value.push(...data.items)
|
||||
page.value = currentPage + 1
|
||||
}
|
||||
total.value = data.total
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load books'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
load(true)
|
||||
}
|
||||
|
||||
return { books, total, loading, error, search, hasMore, load, onSearch }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import { useBooks } from '@/features/book/composables/useBooks'
|
||||
|
||||
const coverSize = ref(150)
|
||||
const libraryId = ref<number | null>(null)
|
||||
|
||||
// Fetch the first available library
|
||||
async function loadLibrary() {
|
||||
const res = await fetch('/api/libraries')
|
||||
if (!res.ok) return
|
||||
const libs: { id: number }[] = await res.json()
|
||||
if (libs.length > 0) libraryId.value = libs[0]!.id
|
||||
}
|
||||
|
||||
const { books, total, loading, error, search, hasMore, load, onSearch } = useBooks(libraryId)
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLibrary()
|
||||
load()
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !loading.value) load()
|
||||
},
|
||||
{ rootMargin: '300px' },
|
||||
)
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => observer?.disconnect())
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(search, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(onSearch, 300)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-background">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 px-4 py-3 border-b border-border flex items-center gap-4">
|
||||
<h1 class="text-sm font-semibold text-foreground">Library</h1>
|
||||
<span class="text-xs text-muted-foreground"> {{ books.length.toLocaleString() }} / {{ total.toLocaleString() }} </span>
|
||||
|
||||
<!-- Search -->
|
||||
<input
|
||||
v-model="search"
|
||||
placeholder="Search..."
|
||||
class="w-52 px-3 py-1.5 text-sm rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
|
||||
<!-- Cover size slider -->
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">Cover</span>
|
||||
<input v-model.number="coverSize" type="range" min="80" max="280" step="10" class="w-24 accent-primary cursor-pointer" />
|
||||
<span class="text-xs text-muted-foreground w-8">{{ coverSize }}px</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Grid -->
|
||||
<main class="flex-1 overflow-y-auto px-4 py-4">
|
||||
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
|
||||
|
||||
<div class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))` }">
|
||||
<BookCoverCard v-for="book in books" :key="book.id" :book="book" />
|
||||
</div>
|
||||
|
||||
<!-- Infinite scroll sentinel -->
|
||||
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
|
||||
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
|
||||
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> All {{ total.toLocaleString() }} books loaded </span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
|
||||
// Override to include only test files and clear exclusions.
|
||||
// Application code imported in tests is automatically included via module resolution.
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
|
||||
"compilerOptions": {
|
||||
// Vitest runs in a different environment than the application code.
|
||||
// Adjust lib and types accordingly.
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"],
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
name: projectx
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# Not exposed externally in prod
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
NODE_ENV: production
|
||||
env_file:
|
||||
- ./server/.env
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- books_data:/books
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
books_data:
|
||||
@@ -0,0 +1,24 @@
|
||||
name: projectx-dev
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-projectx}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-projectx}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U projectx"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Initial database setup
|
||||
-- Migrations are handled by Drizzle Kit, this file is for any
|
||||
-- Postgres extensions or initial configuration needed before migrations run.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- for fuzzy text search
|
||||
Generated
+331
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"name": "projectx",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "projectx",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "projectx",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently --names \"server,client\" --prefix-colors \"blue,green\" \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:client": "cd client && npm run dev -- --host",
|
||||
"dev:server": "cd server && npm run start:dev",
|
||||
"build:client": "pnpm --filter client build",
|
||||
"build:server": "pnpm --filter server build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@projectx/types",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- 'client'
|
||||
- 'server'
|
||||
- 'packages/*'
|
||||
@@ -0,0 +1,14 @@
|
||||
# Database
|
||||
DATABASE_URL=postgres://projectx:projectx@localhost:5432/projectx
|
||||
|
||||
# App
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Storage
|
||||
BOOKS_PATH=/books
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 150
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ pnpm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgres://projectx:projectx@postgres:5432/projectx',
|
||||
},
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"builder": "swc"
|
||||
}
|
||||
}
|
||||
Generated
+14317
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:seed": "ts-node -r tsconfig-paths/register src/db/seed.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-fastify": "^11.1.10",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.7.9",
|
||||
"@swc/core": "^1.15.7",
|
||||
"@types/chokidar": "^1.7.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { appConfig, authConfig, dbConfig, storageConfig } from './config/config';
|
||||
import { DbModule } from './db/db.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { BookModule } from './modules/book/book.module';
|
||||
import { KoboModule } from './modules/kobo/kobo.module';
|
||||
import { LibraryModule } from './modules/library/library.module';
|
||||
import { MetadataModule } from './modules/metadata/metadata.module';
|
||||
import { ScannerModule } from './modules/scanner/scanner.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig],
|
||||
}),
|
||||
DbModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
LibraryModule,
|
||||
BookModule,
|
||||
ScannerModule,
|
||||
MetadataModule,
|
||||
KoboModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const reply = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error';
|
||||
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
this.logger.error(exception);
|
||||
}
|
||||
|
||||
reply.status(status).send({
|
||||
statusCode: status,
|
||||
message,
|
||||
path: request.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const appConfig = registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
}));
|
||||
|
||||
export const dbConfig = registerAs('db', () => ({
|
||||
url: process.env.DATABASE_URL ?? 'postgres://projectx:projectx@localhost:5432/projectx',
|
||||
}));
|
||||
|
||||
export const authConfig = registerAs('auth', () => ({
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'change-me-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
|
||||
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
|
||||
}));
|
||||
|
||||
export const storageConfig = registerAs('storage', () => ({
|
||||
booksPath: process.env.BOOKS_PATH ?? '/data/books',
|
||||
}));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const DB = Symbol('DB');
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DB,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
const pool = new Pool({ connectionString: config.get('db.url') });
|
||||
return drizzle(pool, { schema });
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DB],
|
||||
})
|
||||
export class DbModule {}
|
||||
@@ -0,0 +1 @@
|
||||
export { DB } from './db.module';
|
||||
@@ -0,0 +1,108 @@
|
||||
CREATE TABLE "authors" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(500) NOT NULL,
|
||||
"sort_name" varchar(500)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_authors" (
|
||||
"book_id" integer NOT NULL,
|
||||
"author_id" integer NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "book_authors_book_id_author_id_pk" PRIMARY KEY("book_id","author_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_files" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"book_id" integer NOT NULL,
|
||||
"library_folder_id" integer NOT NULL,
|
||||
"absolute_path" varchar(4096) NOT NULL,
|
||||
"rel_path" varchar(4096),
|
||||
"ino" bigint NOT NULL,
|
||||
"size_bytes" bigint,
|
||||
"mtime" timestamp,
|
||||
"hash" varchar(64),
|
||||
"format" varchar(20),
|
||||
"role" varchar(20) DEFAULT 'primary' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_metadata" (
|
||||
"book_id" integer PRIMARY KEY NOT NULL,
|
||||
"title" varchar(1000),
|
||||
"subtitle" varchar(1000),
|
||||
"description" text,
|
||||
"isbn10" varchar(10),
|
||||
"isbn13" varchar(13),
|
||||
"publisher" varchar(500),
|
||||
"published_year" integer,
|
||||
"language" varchar(10),
|
||||
"page_count" integer,
|
||||
"series_name" varchar(500),
|
||||
"series_index" real,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_tags" (
|
||||
"book_id" integer NOT NULL,
|
||||
"tag_id" integer NOT NULL,
|
||||
CONSTRAINT "book_tags_book_id_tag_id_pk" PRIMARY KEY("book_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"library_id" integer NOT NULL,
|
||||
"library_folder_id" integer NOT NULL,
|
||||
"folder_path" varchar(4096) NOT NULL,
|
||||
"status" varchar(20) DEFAULT 'present' NOT NULL,
|
||||
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "libraries" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"icon" varchar(100),
|
||||
"scan_mode" varchar(20) DEFAULT 'auto' NOT NULL,
|
||||
"poll_interval_seconds" integer DEFAULT 300,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "library_folders" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"library_id" integer NOT NULL,
|
||||
"path" varchar(4096) NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scan_jobs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"library_id" integer NOT NULL,
|
||||
"status" varchar(20) DEFAULT 'running' NOT NULL,
|
||||
"triggered_by" varchar(20) NOT NULL,
|
||||
"added_count" integer DEFAULT 0 NOT NULL,
|
||||
"updated_count" integer DEFAULT 0 NOT NULL,
|
||||
"missing_count" integer DEFAULT 0 NOT NULL,
|
||||
"error_message" text,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(200) NOT NULL,
|
||||
CONSTRAINT "tags_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> 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_files" ADD CONSTRAINT "book_files_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_files" ADD CONSTRAINT "book_files_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE no action 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
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,725 @@
|
||||
{
|
||||
"id": "0ef7cbbe-fd22-4de4-8490-422e3ab29b4f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"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_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": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"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
|
||||
},
|
||||
"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.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": "no action",
|
||||
"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
|
||||
},
|
||||
"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.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": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1766482574065,
|
||||
"tag": "0000_init_schema",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { bigint, integer, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraryFolders, libraries } from './libraries';
|
||||
|
||||
export const books = pgTable('books', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
libraryFolderId: integer('library_folder_id')
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id),
|
||||
folderPath: varchar('folder_path', { length: 4096 }).notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('present'),
|
||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const bookFiles = pgTable('book_files', {
|
||||
id: serial('id').primaryKey(),
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
libraryFolderId: integer('library_folder_id')
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id),
|
||||
absolutePath: varchar('absolute_path', { length: 4096 }).notNull(),
|
||||
relPath: varchar('rel_path', { length: 4096 }),
|
||||
ino: bigint('ino', { mode: 'number' }).notNull(),
|
||||
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
||||
mtime: timestamp('mtime'),
|
||||
hash: varchar('hash', { length: 64 }),
|
||||
format: varchar('format', { length: 20 }),
|
||||
role: varchar('role', { length: 20 }).notNull().default('primary'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Book = typeof books.$inferSelect;
|
||||
export type NewBook = typeof books.$inferInsert;
|
||||
|
||||
export type BookFile = typeof bookFiles.$inferSelect;
|
||||
export type NewBookFile = typeof bookFiles.$inferInsert;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './libraries';
|
||||
export * from './books';
|
||||
export * from './metadata';
|
||||
export * from './scanner';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { integer, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const libraries = pgTable('libraries', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
icon: varchar('icon', { length: 100 }),
|
||||
scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'),
|
||||
pollInterval: integer('poll_interval_seconds').default(300),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const libraryFolders = pgTable('library_folders', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
path: varchar('path', { length: 4096 }).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Library = typeof libraries.$inferSelect;
|
||||
export type NewLibrary = typeof libraries.$inferInsert;
|
||||
|
||||
export type LibraryFolder = typeof libraryFolders.$inferSelect;
|
||||
export type NewLibraryFolder = typeof libraryFolders.$inferInsert;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { books } from './books';
|
||||
|
||||
export const bookMetadata = pgTable('book_metadata', {
|
||||
bookId: integer('book_id')
|
||||
.primaryKey()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
title: varchar('title', { length: 1000 }),
|
||||
subtitle: varchar('subtitle', { length: 1000 }),
|
||||
description: text('description'),
|
||||
isbn10: varchar('isbn10', { length: 10 }),
|
||||
isbn13: varchar('isbn13', { length: 13 }),
|
||||
publisher: varchar('publisher', { length: 500 }),
|
||||
publishedYear: integer('published_year'),
|
||||
language: varchar('language', { length: 10 }),
|
||||
pageCount: integer('page_count'),
|
||||
seriesName: varchar('series_name', { length: 500 }),
|
||||
seriesIndex: real('series_index'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const authors = pgTable('authors', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
sortName: varchar('sort_name', { length: 500 }),
|
||||
});
|
||||
|
||||
export const bookAuthors = pgTable(
|
||||
'book_authors',
|
||||
{
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
authorId: integer('author_id')
|
||||
.notNull()
|
||||
.references(() => authors.id),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.bookId, t.authorId] })],
|
||||
);
|
||||
|
||||
export const tags = pgTable('tags', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull().unique(),
|
||||
});
|
||||
|
||||
export const bookTags = pgTable(
|
||||
'book_tags',
|
||||
{
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
tagId: integer('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.bookId, t.tagId] })],
|
||||
);
|
||||
|
||||
export type BookMetadata = typeof bookMetadata.$inferSelect;
|
||||
export type NewBookMetadata = typeof bookMetadata.$inferInsert;
|
||||
|
||||
export type Author = typeof authors.$inferSelect;
|
||||
export type NewAuthor = typeof authors.$inferInsert;
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraries } from './libraries';
|
||||
|
||||
export const scanJobs = pgTable('scan_jobs', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id),
|
||||
status: varchar('status', { length: 20 }).notNull().default('running'),
|
||||
triggeredBy: varchar('triggered_by', { length: 20 }).notNull(),
|
||||
addedCount: integer('added_count').notNull().default(0),
|
||||
updatedCount: integer('updated_count').notNull().default(0),
|
||||
missingCount: integer('missing_count').notNull().default(0),
|
||||
errorMessage: text('error_message'),
|
||||
startedAt: timestamp('started_at').defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at'),
|
||||
});
|
||||
|
||||
export type ScanJob = typeof scanJobs.$inferSelect;
|
||||
export type NewScanJob = typeof scanJobs.$inferInsert;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import * as schema from './schema';
|
||||
import { authors, bookAuthors, bookMetadata, books, libraryFolders, libraries } from './schema';
|
||||
|
||||
async function seed() {
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL ?? 'postgres://projectx:projectx@postgres:5432/projectx',
|
||||
});
|
||||
const db = drizzle(pool, { schema });
|
||||
|
||||
console.log('Clearing existing data...');
|
||||
await db.delete(bookAuthors);
|
||||
await db.delete(bookMetadata);
|
||||
await db.delete(books);
|
||||
await db.delete(authors);
|
||||
await db.delete(libraryFolders);
|
||||
await db.delete(libraries);
|
||||
|
||||
console.log('Seeding library...');
|
||||
const [library] = await db.insert(libraries).values({ name: 'My Library', scanMode: 'manual' }).returning();
|
||||
|
||||
const [folder] = await db.insert(libraryFolders).values({ libraryId: library.id, path: '/books' }).returning();
|
||||
|
||||
console.log('Seeding authors...');
|
||||
const authorNames = [
|
||||
{ name: 'Andy Weir', sortName: 'Weir, Andy' },
|
||||
{ name: 'N.K. Jemisin', sortName: 'Jemisin, N.K.' },
|
||||
{ name: 'Becky Chambers', sortName: 'Chambers, Becky' },
|
||||
{ name: 'Liu Cixin', sortName: 'Liu, Cixin' },
|
||||
];
|
||||
const insertedAuthors = await db.insert(authors).values(authorNames).returning();
|
||||
|
||||
console.log('Seeding books...');
|
||||
const seedBooks = [
|
||||
{ title: 'Project Hail Mary', authorIdx: 0, year: 2021 },
|
||||
{ title: 'The Fifth Season', authorIdx: 1, year: 2015 },
|
||||
{ title: 'A Long Way to a Small Angry Planet', authorIdx: 2, year: 2014 },
|
||||
{ title: 'The Three-Body Problem', authorIdx: 3, year: 2008 },
|
||||
];
|
||||
|
||||
const BATCH = 500;
|
||||
const allBooks = seedBooks.map((b, i) => ({
|
||||
libraryId: library.id,
|
||||
libraryFolderId: folder.id,
|
||||
folderPath: `/books/${b.title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
status: 'present' as const,
|
||||
}));
|
||||
|
||||
const insertedBooks: (typeof books.$inferSelect)[] = [];
|
||||
for (let i = 0; i < allBooks.length; i += BATCH) {
|
||||
const batch = await db
|
||||
.insert(books)
|
||||
.values(allBooks.slice(i, i + BATCH))
|
||||
.returning();
|
||||
insertedBooks.push(...batch);
|
||||
}
|
||||
|
||||
const metaValues = seedBooks.map((b, i) => ({
|
||||
bookId: insertedBooks[i]!.id,
|
||||
title: b.title,
|
||||
publishedYear: b.year,
|
||||
}));
|
||||
await db.insert(bookMetadata).values(metaValues);
|
||||
|
||||
const authorJoins = seedBooks.map((b, i) => ({
|
||||
bookId: insertedBooks[i]!.id,
|
||||
authorId: insertedAuthors[b.authorIdx]!.id,
|
||||
displayOrder: 0,
|
||||
}));
|
||||
await db.insert(bookAuthors).values(authorJoins);
|
||||
|
||||
await pool.end();
|
||||
console.log(`Done. Seeded ${insertedBooks.length} books.`);
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { join } from 'path';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter({ logger: true }));
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await app.register(require('@fastify/static'), {
|
||||
root: join(__dirname, '..', 'public'),
|
||||
prefix: '/',
|
||||
decorateReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query, Res } from '@nestjs/common';
|
||||
import { createReadStream } from 'fs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { BookService } from './book.service';
|
||||
import { GetBooksDto } from './dto/get-books.dto';
|
||||
|
||||
@Controller('books')
|
||||
export class BookController {
|
||||
constructor(private readonly bookService: BookService) {}
|
||||
|
||||
@Get()
|
||||
getCards(@Query() dto: GetBooksDto) {
|
||||
return this.bookService.getCards(dto);
|
||||
}
|
||||
|
||||
@Get(':id/cover')
|
||||
async getCover(@Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
|
||||
const coverPath = await this.bookService.getCoverPath(id);
|
||||
if (!coverPath) throw new NotFoundException(`No cover for book ${id}`);
|
||||
|
||||
const ext = coverPath.split('.').pop()?.toLowerCase();
|
||||
const contentType = ext === 'png' ? 'image/png' : 'image/jpeg';
|
||||
reply.type(contentType);
|
||||
reply.send(createReadStream(coverPath));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getDetail(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.bookService.getDetail(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BookController } from './book.controller';
|
||||
import { BookRepository } from './book.repository';
|
||||
import { BookService } from './book.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BookController],
|
||||
providers: [BookService, BookRepository],
|
||||
})
|
||||
export class BookModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, count, eq, ilike, inArray } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, tags } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class BookRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findCards(libraryId: number, opts: { page: number; size: number; search?: string }) {
|
||||
const { page, size, search } = opts;
|
||||
const offset = page * size;
|
||||
|
||||
const searchFilter = search?.trim() ? ilike(bookMetadata.title, `%${search.trim()}%`) : undefined;
|
||||
|
||||
const where = and(eq(books.libraryId, libraryId), searchFilter);
|
||||
|
||||
const [rows, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
status: books.status,
|
||||
folderPath: books.folderPath,
|
||||
title: bookMetadata.title,
|
||||
seriesName: bookMetadata.seriesName,
|
||||
seriesIndex: bookMetadata.seriesIndex,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(where)
|
||||
.orderBy(bookMetadata.title)
|
||||
.limit(size)
|
||||
.offset(offset),
|
||||
this.db.select({ total: count() }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where),
|
||||
]);
|
||||
|
||||
const bookIds = rows.map((r) => r.id);
|
||||
const authorRows =
|
||||
bookIds.length > 0
|
||||
? await this.db
|
||||
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, bookIds))
|
||||
.orderBy(bookAuthors.displayOrder)
|
||||
: [];
|
||||
|
||||
return { rows, authorRows, total: Number(total) };
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
const [book] = await this.db.select().from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(eq(books.id, id)).limit(1);
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
const [authorRows, 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: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, id)),
|
||||
this.db
|
||||
.select({
|
||||
id: bookFiles.id,
|
||||
format: bookFiles.format,
|
||||
role: bookFiles.role,
|
||||
sizeBytes: bookFiles.sizeBytes,
|
||||
absolutePath: bookFiles.absolutePath,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.where(eq(bookFiles.bookId, id)),
|
||||
]);
|
||||
|
||||
return { book, authorRows, tagRows, fileRows };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readdir } from 'fs/promises';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import { BookRepository } from './book.repository';
|
||||
import { BookCardDto } from './dto/book-card.dto';
|
||||
import { BookDetailDto } from './dto/book-detail.dto';
|
||||
import { GetBooksDto } from './dto/get-books.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BookService {
|
||||
private readonly booksPath: string;
|
||||
|
||||
constructor(
|
||||
private readonly bookRepo: BookRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.booksPath = this.config.get<string>('storage.booksPath')!;
|
||||
}
|
||||
|
||||
async getCards(dto: GetBooksDto): Promise<{ items: BookCardDto[]; total: number; page: number; size: number }> {
|
||||
const { libraryId, page = 0, size = 50, search } = dto;
|
||||
const { rows, authorRows, total } = await this.bookRepo.findCards(libraryId, { page, size, search });
|
||||
|
||||
const authorsByBook = new Map<number, string[]>();
|
||||
for (const row of authorRows) {
|
||||
const list = authorsByBook.get(row.bookId) ?? [];
|
||||
list.push(row.name);
|
||||
authorsByBook.set(row.bookId, list);
|
||||
}
|
||||
|
||||
const items: BookCardDto[] = rows.map((row) => ({
|
||||
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) ?? [],
|
||||
}));
|
||||
|
||||
return { items, total, page, size };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path of the stored cover file for a book,
|
||||
* or null if no cover exists on disk.
|
||||
*/
|
||||
async getCoverPath(id: number): Promise<string | null> {
|
||||
const dir = join(this.booksPath, 'covers', String(id));
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
const cover = files.find((f) => f.startsWith('cover.'));
|
||||
return cover ? join(dir, cover) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getDetail(id: number): Promise<BookDetailDto> {
|
||||
const result = await this.bookRepo.findById(id);
|
||||
if (!result) throw new NotFoundException(`Book ${id} not found`);
|
||||
|
||||
const { book, authorRows, tagRows, fileRows } = result;
|
||||
const meta = book.book_metadata;
|
||||
|
||||
return {
|
||||
id: book.books.id,
|
||||
status: book.books.status,
|
||||
folderPath: book.books.folderPath,
|
||||
title: meta?.title ?? null,
|
||||
subtitle: meta?.subtitle ?? null,
|
||||
description: meta?.description ?? null,
|
||||
isbn10: meta?.isbn10 ?? null,
|
||||
isbn13: meta?.isbn13 ?? null,
|
||||
publisher: meta?.publisher ?? null,
|
||||
publishedYear: meta?.publishedYear ?? null,
|
||||
language: meta?.language ?? null,
|
||||
pageCount: meta?.pageCount ?? null,
|
||||
seriesName: meta?.seriesName ?? null,
|
||||
seriesIndex: meta?.seriesIndex ?? null,
|
||||
authors: authorRows,
|
||||
tags: tagRows.map((t) => t.name),
|
||||
files: fileRows,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class BookCardDto {
|
||||
id: number;
|
||||
status: string;
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export class BookFileDto {
|
||||
id: number;
|
||||
format: string | null;
|
||||
role: string;
|
||||
sizeBytes: number | null;
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
export class BookDetailDto {
|
||||
id: number;
|
||||
status: string;
|
||||
folderPath: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
description: string | null;
|
||||
isbn10: string | null;
|
||||
isbn13: string | null;
|
||||
publisher: string | null;
|
||||
publishedYear: number | null;
|
||||
language: string | null;
|
||||
pageCount: number | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
authors: { id: number; name: string; sortName: string | null }[];
|
||||
tags: string[];
|
||||
files: BookFileDto[];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class GetBooksDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
libraryId: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
page?: number = 0;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
size?: number = 50;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { KoboService } from './kobo.service';
|
||||
|
||||
@Controller('kobo')
|
||||
export class KoboController {
|
||||
constructor(private readonly koboService: KoboService) {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KoboController } from './kobo.controller';
|
||||
import { KoboService } from './kobo.service';
|
||||
|
||||
@Module({
|
||||
controllers: [KoboController],
|
||||
providers: [KoboService],
|
||||
})
|
||||
export class KoboModule {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class KoboService {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Controller('libraries')
|
||||
export class LibraryController {
|
||||
constructor(private readonly libraryService: LibraryService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.libraryService.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LibraryController } from './library.controller';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LibraryController],
|
||||
providers: [LibraryService, LibraryRepository],
|
||||
exports: [LibraryService],
|
||||
})
|
||||
export class LibraryModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Inject, Injectable } 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 { libraryFolders, libraries } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class LibraryRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
findAll() {
|
||||
return this.db.select().from(libraries).orderBy(libraries.name);
|
||||
}
|
||||
|
||||
findById(id: number) {
|
||||
return this.db.select().from(libraries).where(eq(libraries.id, id)).limit(1);
|
||||
}
|
||||
|
||||
findFoldersByLibrary(libraryId: number) {
|
||||
return this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, libraryId));
|
||||
}
|
||||
|
||||
insert(data: typeof libraries.$inferInsert) {
|
||||
return this.db.insert(libraries).values(data).returning();
|
||||
}
|
||||
|
||||
insertFolder(data: typeof libraryFolders.$inferInsert) {
|
||||
return this.db.insert(libraryFolders).values(data).returning();
|
||||
}
|
||||
|
||||
delete(id: number) {
|
||||
return this.db.delete(libraries).where(eq(libraries.id, id));
|
||||
}
|
||||
|
||||
deleteFolder(id: number) {
|
||||
return this.db.delete(libraryFolders).where(eq(libraryFolders.id, id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { LibraryRepository } from './library.repository';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService {
|
||||
constructor(private readonly libraryRepo: LibraryRepository) {}
|
||||
|
||||
findAll() {
|
||||
return this.libraryRepo.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { inflateRawSync } from 'zlib';
|
||||
|
||||
// ── ZIP binary constants ──────────────────────────────────────────────────────
|
||||
|
||||
const LFH_SIG = 0x04034b50; // local file header
|
||||
const EOCD_SIG = 0x06054b50; // end of central directory
|
||||
|
||||
export interface ParsedCbzMetadata {
|
||||
title: string | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
description: string | null;
|
||||
publisher: string | null;
|
||||
publishedYear: number | null;
|
||||
language: string | null;
|
||||
authors: { name: string; sortName: string | null }[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// ── ZIP helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract a file by name from a ZIP buffer using local file headers (no central directory). */
|
||||
function extractZipFile(buf: Buffer, targetName: string): Buffer | null {
|
||||
let pos = 0;
|
||||
while (pos < buf.length - 30) {
|
||||
const sigPos = buf.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]), pos);
|
||||
if (sigPos === -1) break;
|
||||
|
||||
const compression = buf.readUInt16LE(sigPos + 8);
|
||||
const compressedSize = buf.readUInt32LE(sigPos + 18);
|
||||
const fileNameLen = buf.readUInt16LE(sigPos + 26);
|
||||
const extraLen = buf.readUInt16LE(sigPos + 28);
|
||||
|
||||
const dataStart = sigPos + 30 + fileNameLen + extraLen;
|
||||
const fileName = buf.subarray(sigPos + 30, sigPos + 30 + fileNameLen).toString('utf-8');
|
||||
|
||||
if (fileName.toLowerCase() === targetName.toLowerCase()) {
|
||||
const compressed = buf.subarray(dataStart, dataStart + compressedSize);
|
||||
if (compression === 0) return compressed;
|
||||
if (compression === 8) return inflateRawSync(compressed);
|
||||
}
|
||||
|
||||
pos = dataStart + compressedSize;
|
||||
if (compressedSize === 0) pos = sigPos + 4;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract the ZIP comment from the End of Central Directory record. */
|
||||
function readZipComment(buf: Buffer): string | null {
|
||||
// Search backwards for EOCD signature (max ZIP comment = 65535 bytes)
|
||||
const searchFrom = Math.max(0, buf.length - 65557);
|
||||
for (let i = buf.length - 22; i >= searchFrom; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) {
|
||||
const commentLen = buf.readUInt16LE(i + 20);
|
||||
if (i + 22 + commentLen === buf.length) {
|
||||
return buf.subarray(i + 22, i + 22 + commentLen).toString('utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Parsers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
||||
|
||||
function splitDelimited(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(/[,;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null {
|
||||
try {
|
||||
const parsed = xmlParser.parse(xmlBuf.toString('utf-8')) as Record<string, unknown>;
|
||||
const ci = parsed['ComicInfo'] as Record<string, unknown> | undefined;
|
||||
if (!ci) return null;
|
||||
|
||||
const str = (key: string) => {
|
||||
const v = ci[key];
|
||||
return v != null && v !== '' ? String(v).trim() : null;
|
||||
};
|
||||
const num = (key: string) => {
|
||||
const v = parseFloat(str(key) ?? '');
|
||||
return isNaN(v) ? null : v;
|
||||
};
|
||||
|
||||
// Writers are the "authors" for a book-like view
|
||||
const writers = splitDelimited(str('Writer'));
|
||||
const year = num('Year');
|
||||
|
||||
const tags = [...splitDelimited(str('Genre')), ...splitDelimited(str('Tags'))];
|
||||
const uniqueTags = [...new Set(tags)];
|
||||
|
||||
return {
|
||||
title: str('Title'),
|
||||
seriesName: str('Series'),
|
||||
seriesIndex: num('Number'),
|
||||
description: str('Summary') ?? str('Description'),
|
||||
publisher: str('Publisher'),
|
||||
publishedYear: year != null ? Math.floor(year) : null,
|
||||
language: str('LanguageISO'),
|
||||
authors: writers.map((name) => ({ name, sortName: null })),
|
||||
tags: uniqueTags,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
|
||||
try {
|
||||
const data = JSON.parse(comment) as Record<string, unknown>;
|
||||
const cbi = data['ComicBookInfo/1.0'] as Record<string, unknown> | undefined;
|
||||
if (!cbi) return null;
|
||||
|
||||
const writers = ((cbi['credits'] as { person: string; role: string }[]) ?? [])
|
||||
.filter((c) => c.role === 'Writer')
|
||||
.map((c) => ({ name: c.person, sortName: null }));
|
||||
|
||||
const tags = ((cbi['tags'] as string[]) ?? []).filter(Boolean);
|
||||
|
||||
return {
|
||||
title: (cbi['title'] as string) ?? null,
|
||||
seriesName: (cbi['series'] as string) ?? null,
|
||||
seriesIndex: cbi['issue'] != null ? Number(cbi['issue']) || null : null,
|
||||
description: (cbi['comments'] as string) ?? null,
|
||||
publisher: (cbi['publisher'] as string) ?? null,
|
||||
publishedYear: (cbi['publicationYear'] as number) ?? null,
|
||||
language: null,
|
||||
authors: writers,
|
||||
tags,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract metadata from a CBZ file.
|
||||
* Tries ComicInfo.xml first (embedded file), then ComicBookInfo JSON (ZIP comment).
|
||||
*/
|
||||
export async function extractCbzMetadata(absolutePath: string): Promise<ParsedCbzMetadata | null> {
|
||||
try {
|
||||
const buf = await readFile(absolutePath);
|
||||
|
||||
const comicInfoBuf = extractZipFile(buf, 'ComicInfo.xml');
|
||||
if (comicInfoBuf) {
|
||||
const parsed = parseComicInfoXml(comicInfoBuf);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
const comment = readZipComment(buf);
|
||||
if (comment) {
|
||||
const parsed = parseComicBookInfoJson(comment);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
// ZIP local file header signature
|
||||
const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
|
||||
|
||||
function isImage(name: string): boolean {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot !== -1 && IMAGE_EXTS.has(name.substring(dot).toLowerCase());
|
||||
}
|
||||
|
||||
function isHidden(name: string): boolean {
|
||||
return name.split('/').some((part) => part.startsWith('.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first image from a CBZ file by scanning local file headers directly.
|
||||
* This bypasses the central directory, which unzipper fails to read when the ZIP
|
||||
* has a large comment (e.g. ComicTagger JSON metadata appended to the archive).
|
||||
*
|
||||
* Supports STORED (0) and DEFLATE (8) compression.
|
||||
*/
|
||||
export async function extractCbzCover(absolutePath: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const buf = await readFile(absolutePath);
|
||||
|
||||
let pos = 0;
|
||||
while (pos < buf.length - 30) {
|
||||
// Find next local file header signature
|
||||
const sigPos = buf.indexOf(LFH_SIG, pos);
|
||||
if (sigPos === -1) break;
|
||||
|
||||
const offset = sigPos;
|
||||
const compression = buf.readUInt16LE(offset + 8);
|
||||
const compressedSize = buf.readUInt32LE(offset + 18);
|
||||
const fileNameLen = buf.readUInt16LE(offset + 26);
|
||||
const extraLen = buf.readUInt16LE(offset + 28);
|
||||
|
||||
const dataStart = offset + 30 + fileNameLen + extraLen;
|
||||
const fileName = buf.subarray(offset + 30, offset + 30 + fileNameLen).toString('utf-8');
|
||||
|
||||
// Skip directories, hidden files, and non-images
|
||||
if (!fileName.endsWith('/') && !isHidden(fileName) && isImage(fileName)) {
|
||||
if (compression === 0) {
|
||||
// STORED — data is uncompressed
|
||||
return buf.subarray(dataStart, dataStart + compressedSize);
|
||||
} else if (compression === 8) {
|
||||
// DEFLATE
|
||||
const { inflateRawSync } = await import('zlib');
|
||||
const compressed = buf.subarray(dataStart, dataStart + compressedSize);
|
||||
return inflateRawSync(compressed);
|
||||
}
|
||||
}
|
||||
|
||||
pos = dataStart + compressedSize;
|
||||
if (compressedSize === 0) pos = sigPos + 4; // guard against infinite loop
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as unzipper from 'unzipper';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
||||
|
||||
/**
|
||||
* Extract the cover image bytes from an EPUB file.
|
||||
* Looks for cover-image in manifest (EPUB3 properties or EPUB2 meta cover).
|
||||
* Returns null if no cover is found or the file is not a valid EPUB.
|
||||
*/
|
||||
export async function extractEpubCover(absolutePath: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const zip = await unzipper.Open.file(absolutePath);
|
||||
|
||||
// Read container.xml to find OPF path
|
||||
const containerFile = zip.files.find((f) => f.path === 'META-INF/container.xml');
|
||||
if (!containerFile) return null;
|
||||
|
||||
const containerXml = (await containerFile.buffer()).toString('utf-8');
|
||||
const container = parser.parse(containerXml) as Record<string, unknown>;
|
||||
const rootfile = (container['container'] as Record<string, unknown>)?.['rootfiles'];
|
||||
const rf = (rootfile as Record<string, unknown>)?.['rootfile'];
|
||||
const opfPath = ((Array.isArray(rf) ? rf[0] : rf) as Record<string, unknown>)?.['@_full-path'];
|
||||
if (typeof opfPath !== 'string') return null;
|
||||
|
||||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
|
||||
|
||||
const opfFile = zip.files.find((f) => f.path === opfPath);
|
||||
if (!opfFile) return null;
|
||||
|
||||
const opfXml = (await opfFile.buffer()).toString('utf-8');
|
||||
const opf = parser.parse(opfXml) as Record<string, unknown>;
|
||||
|
||||
const pkg = opf['package'] as Record<string, unknown> | undefined;
|
||||
const manifest = pkg?.['manifest'] as Record<string, unknown> | undefined;
|
||||
const items = manifest?.['item'];
|
||||
const itemList: Record<string, unknown>[] = Array.isArray(items) ? items : items ? [items] : [];
|
||||
|
||||
// Try EPUB3: item with properties="cover-image"
|
||||
let coverItem = itemList.find((i) => {
|
||||
const props = String(i['@_properties'] ?? '');
|
||||
return props.split(' ').includes('cover-image');
|
||||
});
|
||||
|
||||
// Try EPUB2: <meta name="cover" content="id"/> → find item by that id
|
||||
if (!coverItem) {
|
||||
const metadata = pkg?.['metadata'] as Record<string, unknown> | undefined;
|
||||
const metaRaw = metadata?.['meta'];
|
||||
const metaList: Record<string, unknown>[] = Array.isArray(metaRaw) ? metaRaw : metaRaw ? [metaRaw] : [];
|
||||
const coverMeta = metaList.find((m) => String(m['@_name'] ?? '').toLowerCase() === 'cover');
|
||||
if (coverMeta) {
|
||||
const coverId = String(coverMeta['@_content'] ?? '');
|
||||
coverItem = itemList.find((i) => String(i['@_id'] ?? '') === coverId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: item whose id is "cover" or "cover-image"
|
||||
if (!coverItem) {
|
||||
coverItem = itemList.find((i) => {
|
||||
const id = String(i['@_id'] ?? '').toLowerCase();
|
||||
return id === 'cover' || id === 'cover-image';
|
||||
});
|
||||
}
|
||||
|
||||
if (!coverItem) return null;
|
||||
|
||||
const href = String(coverItem['@_href'] ?? '');
|
||||
if (!href) return null;
|
||||
|
||||
const imagePath = opfDir + href;
|
||||
const imageFile = zip.files.find((f) => f.path === imagePath || f.path === '/' + imagePath);
|
||||
if (!imageFile) return null;
|
||||
|
||||
return await imageFile.buffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { extractCbzCover } from './cover-cbz';
|
||||
import { extractEpubCover } from './cover-epub';
|
||||
import { extractMobiCover } from './mobi-parser';
|
||||
|
||||
/** Returns extension based on magic bytes, defaulting to 'jpg'. */
|
||||
function imageExt(bytes: Buffer): string {
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'png';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cover bytes from a book file based on its format.
|
||||
* Returns null if no cover can be extracted.
|
||||
*/
|
||||
export async function extractCover(absolutePath: string, format: string): Promise<Buffer | null> {
|
||||
switch (format.toLowerCase()) {
|
||||
case 'epub':
|
||||
return extractEpubCover(absolutePath);
|
||||
case 'mobi':
|
||||
case 'azw3':
|
||||
case 'azw':
|
||||
return extractMobiCover(absolutePath);
|
||||
case 'cbz':
|
||||
return extractCbzCover(absolutePath);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cover from a book file and save it under {booksPath}/covers/{bookId}/cover.{ext}.
|
||||
* Returns the saved file path, or null if no cover was found.
|
||||
*/
|
||||
export async function extractAndSaveCover(absolutePath: string, format: string, bookId: number, booksPath: string): Promise<string | null> {
|
||||
const bytes = await extractCover(absolutePath, format);
|
||||
if (!bytes || bytes.length === 0) return null;
|
||||
|
||||
const dir = join(booksPath, 'covers', String(bookId));
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = imageExt(bytes);
|
||||
const filePath = join(dir, `cover.${ext}`);
|
||||
await writeFile(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Resolve the cover path for a book on disk without extracting. */
|
||||
export function coverDirPath(booksPath: string, bookId: number): string {
|
||||
return join(booksPath, 'covers', String(bookId));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as unzipper from 'unzipper';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
import { ParsedOpf, parseOpf } from './opf-parser';
|
||||
|
||||
const containerParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
async function readFileFromZip(zip: unzipper.CentralDirectory, filePath: string): Promise<string> {
|
||||
const file = zip.files.find((f) => f.path === filePath || f.path === filePath.replace(/^\//, ''));
|
||||
if (!file) throw new Error(`File not found in EPUB: ${filePath}`);
|
||||
const buf = await file.buffer();
|
||||
return buf.toString('utf-8');
|
||||
}
|
||||
|
||||
async function findOpfPath(zip: unzipper.CentralDirectory): Promise<string> {
|
||||
const containerXml = await readFileFromZip(zip, 'META-INF/container.xml');
|
||||
const parsed = containerParser.parse(containerXml) as Record<string, unknown>;
|
||||
|
||||
const container = parsed['container'] as Record<string, unknown>;
|
||||
const rootfiles = (container?.['rootfiles'] as Record<string, unknown>)?.['rootfile'];
|
||||
const rootfile = Array.isArray(rootfiles) ? rootfiles[0] : rootfiles;
|
||||
|
||||
const opfPath = (rootfile as Record<string, unknown> | undefined)?.['@_full-path'];
|
||||
if (typeof opfPath !== 'string' || !opfPath) {
|
||||
throw new Error('Cannot locate OPF path in container.xml');
|
||||
}
|
||||
|
||||
return opfPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an EPUB file and extract metadata from its OPF.
|
||||
* Returns null if the file is not a valid EPUB or parsing fails.
|
||||
*/
|
||||
export async function extractEpubMetadata(absolutePath: string): Promise<ParsedOpf | null> {
|
||||
try {
|
||||
const zip = await unzipper.Open.file(absolutePath);
|
||||
const opfPath = await findOpfPath(zip);
|
||||
const opfXml = await readFileFromZip(zip, opfPath);
|
||||
return parseOpf(opfXml);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
// ── PalmDB / MOBI binary offsets ──────────────────────────────────────────────
|
||||
//
|
||||
// PalmDB header (file start):
|
||||
// 0 : name (32 bytes, null-terminated)
|
||||
// 76 : numRecords (uint16BE)
|
||||
// 78 : record list — each entry is 8 bytes: offset(uint32BE) + attrs/id(uint32BE)
|
||||
//
|
||||
// Record 0 = MOBI header:
|
||||
// 0 : PalmDOC header (16 bytes)
|
||||
// 16 : "MOBI" magic
|
||||
// 20 : MOBI header length (uint32BE) — measured from byte 16
|
||||
// 24 : type (uint32BE) — 2 = KF8/AZW3
|
||||
// 84 : full_name_offset (uint32BE) — offset from start of record 0
|
||||
// 88 : full_name_length (uint32BE)
|
||||
// 108: first_image_index (uint32BE) — record index of first image
|
||||
// 128: exth_flags (uint32BE) — bit 6 (0x40) = EXTH present
|
||||
//
|
||||
// EXTH header starts at record0[16 + MOBI_header_length]:
|
||||
// 0 : "EXTH" magic
|
||||
// 4 : length (uint32BE)
|
||||
// 8 : record_count (uint32BE)
|
||||
// 12+: records — type(4) + length(4) + data(length-8)
|
||||
|
||||
// EXTH type IDs
|
||||
const EXTH = {
|
||||
AUTHOR: 100,
|
||||
PUBLISHER: 101,
|
||||
DESCRIPTION: 103,
|
||||
ISBN: 104,
|
||||
SUBJECT: 105,
|
||||
PUBLISHED_DATE: 106,
|
||||
LANGUAGE: 524,
|
||||
UPDATED_TITLE: 503,
|
||||
COVER_OFFSET: 201,
|
||||
THUMB_OFFSET: 202,
|
||||
} as const;
|
||||
|
||||
interface ExthRecord {
|
||||
type: number;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface MobiParsed {
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
publisher: string | null;
|
||||
description: string | null;
|
||||
isbn: string | null;
|
||||
tags: string[];
|
||||
publishedDate: string | null;
|
||||
language: string | null;
|
||||
/** Record index of the cover image, or null if not found. */
|
||||
coverRecordIndex: number | null;
|
||||
/** Record offsets (from start of file) for all records. */
|
||||
recordOffsets: number[];
|
||||
}
|
||||
|
||||
function readRecords(buf: Buffer): number[] {
|
||||
const numRecords = buf.readUInt16BE(76);
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < numRecords; i++) {
|
||||
offsets.push(buf.readUInt32BE(78 + i * 8));
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function parseExth(rec0: Buffer, mobiHeaderLength: number): ExthRecord[] {
|
||||
const exthStart = 16 + mobiHeaderLength;
|
||||
if (exthStart + 12 > rec0.length) return [];
|
||||
if (rec0.slice(exthStart, exthStart + 4).toString('ascii') !== 'EXTH') return [];
|
||||
|
||||
const recordCount = rec0.readUInt32BE(exthStart + 8);
|
||||
const records: ExthRecord[] = [];
|
||||
let pos = exthStart + 12;
|
||||
|
||||
for (let i = 0; i < recordCount; i++) {
|
||||
if (pos + 8 > rec0.length) break;
|
||||
const type = rec0.readUInt32BE(pos);
|
||||
const length = rec0.readUInt32BE(pos + 4);
|
||||
if (length < 8 || pos + length > rec0.length) break;
|
||||
records.push({ type, data: rec0.slice(pos + 8, pos + length) });
|
||||
pos += length;
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function exthString(records: ExthRecord[], type: number): string | null {
|
||||
const rec = records.find((r) => r.type === type);
|
||||
return rec ? rec.data.toString('utf8').replace(/\0+$/, '').trim() || null : null;
|
||||
}
|
||||
|
||||
function exthStrings(records: ExthRecord[], type: number): string[] {
|
||||
const raw = records
|
||||
.filter((r) => r.type === type)
|
||||
.map((r) => r.data.toString('utf8').replace(/\0+$/, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Some tools write all values as a single semicolon-separated string; split them.
|
||||
return raw.flatMap((v) => v.split(/\s*;\s*/).filter(Boolean));
|
||||
}
|
||||
|
||||
function exthUint32(records: ExthRecord[], type: number): number | null {
|
||||
const rec = records.find((r) => r.type === type);
|
||||
if (!rec || rec.data.length < 4) return null;
|
||||
return rec.data.readUInt32BE(0);
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, '').trim();
|
||||
}
|
||||
|
||||
export function parseMobiBuffer(buf: Buffer): MobiParsed {
|
||||
const recordOffsets = readRecords(buf);
|
||||
if (recordOffsets.length === 0) throw new Error('No records in PalmDB');
|
||||
|
||||
const rec0Start = recordOffsets[0]!;
|
||||
const rec0End = recordOffsets[1] ?? buf.length;
|
||||
const rec0 = buf.slice(rec0Start, rec0End);
|
||||
|
||||
if (rec0.length < 20 || rec0.slice(16, 20).toString('ascii') !== 'MOBI') {
|
||||
throw new Error('MOBI magic not found');
|
||||
}
|
||||
|
||||
const mobiHeaderLength = rec0.readUInt32BE(20);
|
||||
const exthFlags = rec0.readUInt32BE(128);
|
||||
const firstImageIndex = rec0.readUInt32BE(108);
|
||||
|
||||
// Full name (title) from MOBI header
|
||||
const fullNameOffset = rec0.readUInt32BE(84);
|
||||
const fullNameLength = rec0.readUInt32BE(88);
|
||||
const palmTitle =
|
||||
fullNameLength > 0 && fullNameOffset + fullNameLength <= rec0.length
|
||||
? rec0
|
||||
.slice(fullNameOffset, fullNameOffset + fullNameLength)
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
: null;
|
||||
|
||||
const hasExth = (exthFlags & 0x40) !== 0;
|
||||
const exthRecords = hasExth ? parseExth(rec0, mobiHeaderLength) : [];
|
||||
|
||||
const title = exthString(exthRecords, EXTH.UPDATED_TITLE) ?? palmTitle;
|
||||
const authors = exthStrings(exthRecords, EXTH.AUTHOR);
|
||||
const publisher = exthString(exthRecords, EXTH.PUBLISHER);
|
||||
const rawDescription = exthString(exthRecords, EXTH.DESCRIPTION);
|
||||
const description = rawDescription ? stripHtml(rawDescription) : null;
|
||||
const isbn = exthString(exthRecords, EXTH.ISBN);
|
||||
const tags = exthStrings(exthRecords, EXTH.SUBJECT);
|
||||
const publishedDate = exthString(exthRecords, EXTH.PUBLISHED_DATE);
|
||||
const language = exthString(exthRecords, EXTH.LANGUAGE);
|
||||
|
||||
const coverOffset = exthUint32(exthRecords, EXTH.COVER_OFFSET);
|
||||
const coverRecordIndex = coverOffset != null && coverOffset !== 0xffffffff ? firstImageIndex + coverOffset : null;
|
||||
|
||||
return {
|
||||
title,
|
||||
authors,
|
||||
publisher,
|
||||
description,
|
||||
isbn,
|
||||
tags,
|
||||
publishedDate,
|
||||
language,
|
||||
coverRecordIndex,
|
||||
recordOffsets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseMobiFile(absolutePath: string): Promise<MobiParsed | null> {
|
||||
try {
|
||||
const buf = await readFile(absolutePath);
|
||||
return parseMobiBuffer(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract raw cover image bytes from a MOBI/AZW3 file. Returns null if not found. */
|
||||
export async function extractMobiCover(absolutePath: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const buf = await readFile(absolutePath);
|
||||
const parsed = parseMobiBuffer(buf);
|
||||
if (parsed.coverRecordIndex === null) return null;
|
||||
|
||||
const idx = parsed.coverRecordIndex;
|
||||
if (idx >= parsed.recordOffsets.length) return null;
|
||||
|
||||
const start = parsed.recordOffsets[idx]!;
|
||||
const end = parsed.recordOffsets[idx + 1] ?? buf.length;
|
||||
const imageData = buf.slice(start, end);
|
||||
|
||||
// Validate magic bytes: JPEG (FFD8) or PNG (89504E47)
|
||||
const isJpeg = imageData[0] === 0xff && imageData[1] === 0xd8;
|
||||
const isPng = imageData[0] === 0x89 && imageData[1] === 0x50 && imageData[2] === 0x4e && imageData[3] === 0x47;
|
||||
|
||||
if (!isJpeg && !isPng) return null;
|
||||
return imageData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export interface ParsedOpf {
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
description: string | null;
|
||||
isbn10: string | null;
|
||||
isbn13: string | null;
|
||||
publisher: string | null;
|
||||
publishedYear: number | null;
|
||||
language: string | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
authors: { name: string; sortName: string | null }[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
isArray: (name) => ['dc:creator', 'dc:identifier', 'dc:subject', 'dc:title', 'meta', 'item'].includes(name),
|
||||
textNodeName: '#text',
|
||||
allowBooleanAttributes: true,
|
||||
});
|
||||
|
||||
function toArray<T>(val: T | T[] | undefined): T[] {
|
||||
if (val === undefined || val === null) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
||||
|
||||
function getText(node: unknown): string {
|
||||
if (typeof node === 'string') return node.trim();
|
||||
if (typeof node === 'object' && node !== null && '#text' in node) {
|
||||
return String((node as Record<string, unknown>)['#text']).trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseIsbn(raw: string): {
|
||||
isbn10: string | null;
|
||||
isbn13: string | null;
|
||||
} {
|
||||
const digits = raw.replace(/[^\dX]/gi, '');
|
||||
if (digits.length === 13) return { isbn10: null, isbn13: digits };
|
||||
if (digits.length === 10) return { isbn10: digits, isbn13: null };
|
||||
return { isbn10: null, isbn13: null };
|
||||
}
|
||||
|
||||
function parseYear(raw: string): number | null {
|
||||
const match = raw.match(/\d{4}/);
|
||||
return match ? parseInt(match[0]!, 10) : null;
|
||||
}
|
||||
|
||||
// Build a map of id → array of refining meta nodes (EPUB 3).
|
||||
function buildRefineMap(metas: unknown[]): Map<string, unknown[]> {
|
||||
const map = new Map<string, unknown[]>();
|
||||
for (const meta of metas) {
|
||||
if (typeof meta !== 'object' || meta === null) continue;
|
||||
const m = meta as Record<string, unknown>;
|
||||
const refines = m['@_refines'];
|
||||
if (typeof refines === 'string') {
|
||||
const id = refines.startsWith('#') ? refines.slice(1) : refines;
|
||||
if (!map.has(id)) map.set(id, []);
|
||||
map.get(id)!.push(meta);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getRefineValue(refines: Map<string, unknown[]>, id: string, property: string): string | null {
|
||||
const nodes = refines.get(id) ?? [];
|
||||
for (const node of nodes) {
|
||||
const m = node as Record<string, unknown>;
|
||||
if (m['@_property'] === property) return getText(m);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOpf(xml: string): ParsedOpf {
|
||||
const root = parser.parse(xml) as Record<string, unknown>;
|
||||
const pkg = (root['package'] ?? root['opf:package'] ?? {}) as Record<string, unknown>;
|
||||
const metadata = (pkg['metadata'] ?? pkg['opf:metadata'] ?? {}) as Record<string, unknown>;
|
||||
|
||||
const rawMetas = toArray(metadata['meta']);
|
||||
const refineMap = buildRefineMap(rawMetas);
|
||||
|
||||
// ── Calibre/EPUB2 named metas ──────────────────────────────────────────────
|
||||
function namedMeta(name: string): string | null {
|
||||
for (const m of rawMetas) {
|
||||
if (typeof m !== 'object' || m === null) continue;
|
||||
const mo = m as Record<string, unknown>;
|
||||
if (mo['@_name'] === name) return String(mo['@_content'] ?? '').trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Titles ─────────────────────────────────────────────────────────────────
|
||||
let title: string | null = null;
|
||||
let subtitle: string | null = null;
|
||||
|
||||
const rawTitles = toArray(metadata['dc:title']);
|
||||
if (rawTitles.length === 1) {
|
||||
title = getText(rawTitles[0]);
|
||||
} else if (rawTitles.length > 1) {
|
||||
// EPUB 3: look for title-type refinements.
|
||||
for (const t of rawTitles) {
|
||||
const mo = (typeof t === 'object' && t !== null ? t : {}) as Record<string, unknown>;
|
||||
const id = mo['@_id'] as string | undefined;
|
||||
const type = id ? getRefineValue(refineMap, id, 'title-type') : null;
|
||||
if (type === 'subtitle') subtitle = getText(t);
|
||||
else title ??= getText(t);
|
||||
}
|
||||
title ??= getText(rawTitles[0]);
|
||||
}
|
||||
|
||||
// ── Authors ────────────────────────────────────────────────────────────────
|
||||
const authors: { name: string; sortName: string | null }[] = [];
|
||||
const rawCreators = toArray(metadata['dc:creator']);
|
||||
|
||||
for (const c of rawCreators) {
|
||||
const mo = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>;
|
||||
const name = getText(c);
|
||||
if (!name) continue;
|
||||
|
||||
const id = mo['@_id'] as string | undefined;
|
||||
// EPUB 3: role via refines
|
||||
const role3 = id ? getRefineValue(refineMap, id, 'role') : null;
|
||||
// EPUB 2: opf:role attribute
|
||||
const role2 = (mo['@_opf:role'] ?? mo['@_role']) as string | undefined;
|
||||
const role = role3 ?? role2 ?? 'aut';
|
||||
if (role !== 'aut' && role !== '') continue; // skip editors, illustrators, etc.
|
||||
|
||||
// EPUB 3: file-as via refines; EPUB 2: opf:file-as attribute
|
||||
const sortName = (id ? getRefineValue(refineMap, id, 'file-as') : null) ?? ((mo['@_opf:file-as'] ?? mo['@_file-as'] ?? null) as string | null);
|
||||
|
||||
authors.push({ name, sortName: sortName?.trim() || null });
|
||||
}
|
||||
|
||||
// ── Identifiers → ISBN ─────────────────────────────────────────────────────
|
||||
let isbn10: string | null = null;
|
||||
let isbn13: string | null = null;
|
||||
|
||||
for (const ident of toArray(metadata['dc:identifier'])) {
|
||||
const mo = (typeof ident === 'object' && ident !== null ? ident : {}) as Record<string, unknown>;
|
||||
const scheme = ((mo['@_opf:scheme'] ?? mo['@_scheme'] ?? '') as string).toLowerCase();
|
||||
const value = getText(ident);
|
||||
if (!value) continue;
|
||||
|
||||
if (scheme === 'isbn' || value.toLowerCase().includes('isbn')) {
|
||||
const parsed = parseIsbn(value);
|
||||
isbn10 ??= parsed.isbn10;
|
||||
isbn13 ??= parsed.isbn13;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tags ───────────────────────────────────────────────────────────────────
|
||||
const tags = toArray(metadata['dc:subject']).map(getText).filter(Boolean);
|
||||
|
||||
// ── Series (Calibre EPUB2, then EPUB3) ────────────────────────────────────
|
||||
let seriesName: string | null = namedMeta('calibre:series');
|
||||
let seriesIndex: number | null = null;
|
||||
const rawSeriesIdx = namedMeta('calibre:series_index');
|
||||
if (rawSeriesIdx) seriesIndex = parseFloat(rawSeriesIdx) || null;
|
||||
|
||||
if (!seriesName) {
|
||||
// EPUB 3: belongs-to-collection
|
||||
for (const m of rawMetas) {
|
||||
if (typeof m !== 'object' || m === null) continue;
|
||||
const mo = m as Record<string, unknown>;
|
||||
if (mo['@_property'] === 'belongs-to-collection') {
|
||||
seriesName = getText(m);
|
||||
const id = mo['@_id'] as string | undefined;
|
||||
if (id) {
|
||||
const pos = getRefineValue(refineMap, id, 'group-position');
|
||||
if (pos) seriesIndex = parseFloat(pos) || null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scalar fields ──────────────────────────────────────────────────────────
|
||||
const description = getText(metadata['dc:description']) || null;
|
||||
const publisher = getText(metadata['dc:publisher']) || null;
|
||||
const language = getText(metadata['dc:language']) || null;
|
||||
|
||||
const rawDate = toArray(metadata['dc:date'])[0];
|
||||
const publishedYear = rawDate ? parseYear(getText(rawDate)) : null;
|
||||
|
||||
return {
|
||||
title: title || null,
|
||||
subtitle: subtitle || null,
|
||||
description,
|
||||
isbn10,
|
||||
isbn13,
|
||||
publisher,
|
||||
publishedYear,
|
||||
language: language || null,
|
||||
seriesName: seriesName || null,
|
||||
seriesIndex,
|
||||
authors,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pdfParse: (buf: Buffer, opts?: { max?: number }) => Promise<{ info: unknown; numpages: number }> = require('pdf-parse');
|
||||
|
||||
export interface PdfParsed {
|
||||
title: string | null;
|
||||
authors: { name: string; sortName: string | null }[];
|
||||
subject: string | null;
|
||||
keywords: string[];
|
||||
publisher: string | null;
|
||||
pageCount: number | null;
|
||||
coverBuffer: Buffer | null;
|
||||
}
|
||||
|
||||
function clean(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const s = value.trim();
|
||||
return s.length > 0 ? s : null;
|
||||
}
|
||||
|
||||
function splitKeywords(value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(/[,;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the cover image (first page rendered to JPEG) is not feasible
|
||||
* without a headless browser. Instead we extract the first embedded image
|
||||
* from the PDF binary using a lightweight xref scan.
|
||||
*
|
||||
* Looks for the first inline JPEG stream (SOI marker FF D8) embedded in
|
||||
* the binary. Returns null if none found within a reasonable scan.
|
||||
*/
|
||||
function extractFirstJpeg(buf: Buffer): Buffer | null {
|
||||
const MAX_SCAN = Math.min(buf.length, 5 * 1024 * 1024); // scan first 5 MB
|
||||
for (let i = 0; i < MAX_SCAN - 1; i++) {
|
||||
if (buf[i] === 0xff && buf[i + 1] === 0xd8) {
|
||||
// Find matching EOI (FF D9)
|
||||
for (let j = i + 2; j < buf.length - 1; j++) {
|
||||
if (buf[j] === 0xff && buf[j + 1] === 0xd9) {
|
||||
return buf.subarray(i, j + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | null> {
|
||||
try {
|
||||
const buf = await readFile(absolutePath);
|
||||
|
||||
// pdf-parse can throw on malformed PDFs; run without text extraction for speed
|
||||
const data = await pdfParse(buf, { max: 1 });
|
||||
|
||||
const info = (data.info ?? {}) as Record<string, unknown>;
|
||||
|
||||
const title = clean(info['Title']);
|
||||
const authorRaw = clean(info['Author']);
|
||||
const subject = clean(info['Subject']);
|
||||
const keywords = splitKeywords(clean(info['Keywords']));
|
||||
// PDFs rarely store publisher; some use 'Creator' for the authoring app
|
||||
// and 'Producer' for the PDF engine — neither is a publisher field.
|
||||
const publisher: string | null = null;
|
||||
const pageCount = typeof data.numpages === 'number' ? data.numpages : null;
|
||||
|
||||
const authors: PdfParsed['authors'] = authorRaw
|
||||
? authorRaw
|
||||
.split(/[,;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name, sortName: null }))
|
||||
: [];
|
||||
|
||||
const coverBuffer = extractFirstJpeg(buf);
|
||||
|
||||
return { title, authors, subject, keywords, publisher, pageCount, coverBuffer };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MetadataService } from './metadata.service';
|
||||
|
||||
@Module({
|
||||
providers: [MetadataService],
|
||||
exports: [MetadataService],
|
||||
})
|
||||
export class MetadataModule {}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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, bookMetadata, bookTags, tags } from '../../db/schema';
|
||||
import { extractCbzMetadata } from './lib/cbz-metadata';
|
||||
import { extractAndSaveCover } from './lib/cover';
|
||||
import { extractEpubMetadata } from './lib/epub';
|
||||
import { parseMobiFile } from './lib/mobi-parser';
|
||||
import { parsePdfFile } from './lib/pdf-parser';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
private readonly logger = new Logger(MetadataService.name);
|
||||
private readonly booksPath: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.booksPath = this.config.get<string>('storage.booksPath')!;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
async extractAndSave(bookId: number, absolutePath: string, format: string): Promise<void> {
|
||||
await Promise.all([this.extractMetadata(bookId, absolutePath, format), this.extractCover(bookId, absolutePath, format)]);
|
||||
}
|
||||
|
||||
// ── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async extractMetadata(bookId: number, absolutePath: string, format: string): Promise<void> {
|
||||
let parsed: {
|
||||
title?: string | null;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
isbn10?: string | null;
|
||||
isbn13?: string | null;
|
||||
publisher?: string | null;
|
||||
publishedYear?: number | null;
|
||||
language?: string | null;
|
||||
seriesName?: string | null;
|
||||
seriesIndex?: number | null;
|
||||
authors: { name: string; sortName: string | null }[];
|
||||
tags: string[];
|
||||
} | null = null;
|
||||
|
||||
if (format === 'epub') {
|
||||
parsed = await extractEpubMetadata(absolutePath);
|
||||
} else if (format === 'cbz' || format === 'cbr') {
|
||||
const cbz = await extractCbzMetadata(absolutePath);
|
||||
if (cbz) {
|
||||
parsed = {
|
||||
title: cbz.title,
|
||||
subtitle: null,
|
||||
description: cbz.description,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: cbz.publisher,
|
||||
publishedYear: cbz.publishedYear,
|
||||
language: cbz.language,
|
||||
seriesName: cbz.seriesName,
|
||||
seriesIndex: cbz.seriesIndex,
|
||||
authors: cbz.authors,
|
||||
tags: cbz.tags,
|
||||
};
|
||||
}
|
||||
} else if (format === 'mobi' || format === 'azw3' || format === 'azw') {
|
||||
const mobi = await parseMobiFile(absolutePath);
|
||||
if (mobi) {
|
||||
const year = mobi.publishedDate ? parseInt(mobi.publishedDate.substring(0, 4), 10) || null : null;
|
||||
parsed = {
|
||||
title: mobi.title,
|
||||
subtitle: null,
|
||||
description: mobi.description,
|
||||
isbn10: null,
|
||||
isbn13: mobi.isbn,
|
||||
publisher: mobi.publisher,
|
||||
publishedYear: year,
|
||||
language: mobi.language,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
authors: mobi.authors.map((name) => ({ name, sortName: null })),
|
||||
tags: mobi.tags,
|
||||
};
|
||||
}
|
||||
} else if (format === 'pdf') {
|
||||
const pdf = await parsePdfFile(absolutePath);
|
||||
if (pdf) {
|
||||
parsed = {
|
||||
title: pdf.title,
|
||||
subtitle: null,
|
||||
description: pdf.subject,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
authors: pdf.authors,
|
||||
tags: pdf.keywords,
|
||||
};
|
||||
if (pdf.pageCount !== null) {
|
||||
await this.db.update(bookMetadata).set({ pageCount: pdf.pageCount }).where(eq(bookMetadata.bookId, bookId));
|
||||
}
|
||||
if (pdf.coverBuffer) {
|
||||
await this.savePdfCover(bookId, pdf.coverBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) return;
|
||||
|
||||
await this.db
|
||||
.update(bookMetadata)
|
||||
.set({
|
||||
title: parsed.title,
|
||||
subtitle: parsed.subtitle,
|
||||
description: parsed.description,
|
||||
isbn10: parsed.isbn10,
|
||||
isbn13: parsed.isbn13,
|
||||
publisher: parsed.publisher,
|
||||
publishedYear: parsed.publishedYear,
|
||||
language: parsed.language,
|
||||
seriesName: parsed.seriesName,
|
||||
seriesIndex: parsed.seriesIndex,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bookMetadata.bookId, bookId));
|
||||
|
||||
await this.saveAuthors(bookId, parsed.authors);
|
||||
await this.saveTags(bookId, parsed.tags);
|
||||
|
||||
this.logger.debug(`Metadata saved for book ${bookId}: "${parsed.title}"`);
|
||||
}
|
||||
|
||||
// ── Cover ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async extractCover(bookId: number, absolutePath: string, format: string): Promise<void> {
|
||||
try {
|
||||
const saved = await extractAndSaveCover(absolutePath, format, bookId, this.booksPath);
|
||||
if (saved) {
|
||||
this.logger.debug(`Cover saved for book ${bookId}: ${saved}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Cover extraction failed for book ${bookId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PDF cover ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async savePdfCover(bookId: number, jpeg: Buffer): Promise<void> {
|
||||
try {
|
||||
const { mkdir, writeFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
const dir = join(this.booksPath, 'covers', String(bookId));
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'cover.jpg'), jpeg);
|
||||
this.logger.debug(`PDF cover saved for book ${bookId}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async saveAuthors(bookId: number, parsedAuthors: { name: string; sortName: string | null }[]) {
|
||||
if (parsedAuthors.length === 0) return;
|
||||
|
||||
await this.db.delete(bookAuthors).where(eq(bookAuthors.bookId, bookId));
|
||||
|
||||
for (let i = 0; i < parsedAuthors.length; i++) {
|
||||
const { name, sortName } = parsedAuthors[i]!;
|
||||
|
||||
let [author] = await this.db.select().from(authors).where(eq(authors.name, name)).limit(1);
|
||||
|
||||
if (!author) {
|
||||
[author] = await this.db.insert(authors).values({ name, sortName }).returning();
|
||||
}
|
||||
|
||||
await this.db.insert(bookAuthors).values({ bookId, authorId: author!.id, displayOrder: i });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tags ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async saveTags(bookId: number, parsedTags: 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;
|
||||
|
||||
for (const rawName of uniqueTags) {
|
||||
const name = rawName;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { extname, basename } from 'path';
|
||||
|
||||
const PRIMARY_FORMATS = new Set([
|
||||
'epub',
|
||||
'pdf',
|
||||
'cbz',
|
||||
'cbr',
|
||||
'cb7',
|
||||
'cbt',
|
||||
'mobi',
|
||||
'azw3',
|
||||
'azw',
|
||||
'fb2',
|
||||
'djvu',
|
||||
'djv',
|
||||
'txt',
|
||||
'docx',
|
||||
'rtf',
|
||||
'lit',
|
||||
]);
|
||||
|
||||
const COVER_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp']);
|
||||
const COVER_BASENAMES = new Set(['cover', 'folder', 'thumbnail', 'artwork', 'front']);
|
||||
const METADATA_EXTENSIONS = new Set(['opf', 'nfo']);
|
||||
|
||||
export type FileRole = 'primary' | 'cover' | 'metadata' | 'supplementary';
|
||||
|
||||
export interface Classification {
|
||||
format: string | null;
|
||||
role: FileRole;
|
||||
}
|
||||
|
||||
export function classifyFile(absolutePath: string): Classification {
|
||||
const ext = extname(absolutePath).toLowerCase().slice(1);
|
||||
const stem = basename(absolutePath, extname(absolutePath)).toLowerCase();
|
||||
|
||||
if (PRIMARY_FORMATS.has(ext)) return { format: ext, role: 'primary' };
|
||||
if (METADATA_EXTENSIONS.has(ext)) return { format: ext, role: 'metadata' };
|
||||
if (COVER_EXTENSIONS.has(ext)) return { format: ext, role: COVER_BASENAMES.has(stem) ? 'cover' : 'supplementary' };
|
||||
|
||||
return { format: ext || null, role: 'supplementary' };
|
||||
}
|
||||
|
||||
export function isPrimaryFormat(absolutePath: string): boolean {
|
||||
return PRIMARY_FORMATS.has(extname(absolutePath).toLowerCase().slice(1));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { createReadStream } from 'fs';
|
||||
|
||||
/**
|
||||
* SHA-256 a file by streaming it — avoids loading large files into memory.
|
||||
* Only called when path and inode lookups both fail (cross-filesystem move).
|
||||
*/
|
||||
export function sha256File(absolutePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(absolutePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user