Compare commits

..

27 Commits

Author SHA1 Message Date
Garret Patti
fbcd592609 Use game cover as series cover if series cover is not available
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
2026-04-18 12:44:01 -04:00
7b76e3d900 Merge pull request 'maintainability' (#30) from maintainability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/30
2026-04-18 15:55:54 +00:00
Garret Patti
2ea02b197b expand user permissions 2026-04-18 11:48:01 -04:00
Garret Patti
8f84da7e2f add keyboard navigation 2026-04-18 11:18:40 -04:00
Garret Patti
625e256944 reduce repeated tag selector code 2026-04-18 11:10:26 -04:00
152bc12427 Merge pull request 'more-ui-adjustments' (#29) from more-ui-adjustments into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 58s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/29
2026-04-18 04:38:33 +00:00
Garret Patti
345a05e42a fix TV show metadata refresh 2026-04-18 00:38:04 -04:00
Garret Patti
0de839393a fix tv navigation 2026-04-18 00:22:02 -04:00
Garret Patti
0ff3ed8ac9 add gameview series navigation 2026-04-18 00:14:18 -04:00
Garret Patti
b2e9df8ab8 add gameview navigation 2026-04-17 23:55:33 -04:00
b774cba046 Merge pull request 'consistent-ui-across-libraries' (#28) from consistent-ui-across-libraries into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/28
2026-04-15 12:32:04 +00:00
Garret Patti
5b5c3453d2 add download buttons to tv 2026-04-15 08:30:41 -04:00
Garret Patti
37dcb79546 fix tv view 2026-04-15 08:16:38 -04:00
c2135747b5 Merge pull request 'image-viewer-improvements' (#27) from image-viewer-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/27
2026-04-14 23:56:16 +00:00
Garret Patti
afcf740f63 update ai buttons 2026-04-14 19:55:44 -04:00
Garret Patti
dae33a36bc remember tag selector state 2026-04-14 19:17:22 -04:00
Garret Patti
a379e94bce media viewer consistency 2026-04-14 18:45:06 -04:00
Garret Patti
0b03b937e0 update dockerfile
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
2026-04-14 08:31:30 -04:00
Garret Patti
19756c9eab docker fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
2026-04-14 08:25:12 -04:00
b25774d928 Merge pull request 'responsiveness' (#26) from responsiveness into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/26
2026-04-14 02:14:02 +00:00
Garret Patti
db2e446ef4 feat: per-extraction OCR language override
Allow users to specify a Tesseract language string (e.g. jpn+jpn_vert)
on a per-extraction basis, overriding the global OCR language setting.

- Add payload column to ai_jobs table (migration) to carry per-call data
- Thread ocrLanguages payload through enqueueJob → processNextJob → extractItemText
- New GET /api/ai-settings/ocr endpoint (requireAuth) returns { ocrMode, ocrLanguages }
- ImageLightbox fetches OCR settings and shows a language input next to the
  Extract Text button when mode is hybrid or tesseract (hidden for llm-only)
- MixedView fetches OCR settings and passes them down to EntryTile; kebab
  Extract Text on images shows an inline language prompt before dispatching the job

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:55:07 -04:00
Garret Patti
96cfb8aae7 UI polish: live job polling, panel layout, pending button states
- Poll /api/ai-tagging/fields every 2s after any 202 (queued) response in
  ImageLightbox and DoomScrollView so extraction, translation, and description
  results appear automatically without a page refresh
- DoomScrollView extract button now turns accent-coloured while a job is
  queued instead of flashing red; red is reserved for genuine errors
- Kebab menu "Translate" option is now gated on entry.hasExtractedText
  (populated via a batch DB query in the browse API) so it only appears
  when there is text to translate
- Tag panel redesigned: toolbar collapses to just the filename when open;
  panel header holds hide (›), AI Tagger (), and Close (✕) buttons;
  sections ordered Description → Text Extraction → Tags; description
  state and generate handler moved from TagSelector into ImageLightbox
- VideoPlayerModal receives the same toolbar/panel restructure
- TagSelector gains hideDescription prop so the parent can own description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:37:20 -04:00
Garret Patti
d754f85717 update gitignore 2026-04-13 19:45:20 -04:00
9d73459f48 Merge pull request 'customize-context-length' (#25) from customize-context-length into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m7s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/25
2026-04-13 23:41:09 +00:00
Garret Patti
9b2690f639 add tesseract ocr 2026-04-13 19:40:25 -04:00
Garret Patti
1350a6f94b separate text extraction and translation 2026-04-13 17:45:00 -04:00
Garret Patti
2fc9a34626 add configurable max_tokens per AI activity
Allows users to configure the max_tokens sent to the AI endpoint for
each activity (tagging, description, extraction, translation) individually,
with per-library overrides following the same pattern as model overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:57:07 -04:00
46 changed files with 2918 additions and 1749 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ medialore.db-wal
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.session_secret .session_secret
.vscode/ .vscode/
*.traineddata

View File

@@ -45,6 +45,11 @@ COPY --from=builder /app/.next/static ./.next/static
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
COPY --from=deps /app/node_modules/@img ./node_modules/@img COPY --from=deps /app/node_modules/@img ./node_modules/@img
# tesseract.js loads its worker via worker_threads using a runtime-constructed path,
# so the standalone file tracer never discovers src/worker-script/node/. Copy the
# full package so that path resolves correctly at runtime.
COPY --from=deps /app/node_modules/tesseract.js ./node_modules/tesseract.js
COPY --from=deps /app/node_modules/tesseract.js-core ./node_modules/tesseract.js-core
# Create thumbnail cache directory (mounted as a volume in production) # Create thumbnail cache directory (mounted as a volume in production)
RUN mkdir -p /app/.thumbnails RUN mkdir -p /app/.thumbnails

View File

@@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
serverExternalPackages: ['better-sqlite3', 'sharp'], serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'],
} }
export default nextConfig export default nextConfig

117
package-lock.json generated
View File

@@ -17,7 +17,8 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sharp": "^0.34.5" "sharp": "^0.34.5",
"tesseract.js": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
@@ -2950,6 +2951,12 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4803,6 +4810,12 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5288,6 +5301,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6167,6 +6186,26 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -6315,6 +6354,15 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6747,6 +6795,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7585,6 +7639,30 @@
"streamx": "^2.12.5" "streamx": "^2.12.5"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/text-decoder": { "node_modules/text-decoder": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -7655,6 +7733,12 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -7955,6 +8039,28 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8237,6 +8343,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@@ -20,7 +20,8 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sharp": "^0.34.5" "sharp": "^0.34.5",
"tesseract.js": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",

View File

@@ -38,6 +38,10 @@ export async function PUT(
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined, promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined, promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined, promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
maxTokensTag: typeof body.maxTokensTag === 'number' ? body.maxTokensTag : (body.maxTokensTag === null ? null : undefined),
maxTokensDescribe: typeof body.maxTokensDescribe === 'number' ? body.maxTokensDescribe : (body.maxTokensDescribe === null ? null : undefined),
maxTokensExtract: typeof body.maxTokensExtract === 'number' ? body.maxTokensExtract : (body.maxTokensExtract === null ? null : undefined),
maxTokensTranslate: typeof body.maxTokensTranslate === 'number' ? body.maxTokensTranslate : (body.maxTokensTranslate === null ? null : undefined),
}) })
return NextResponse.json(getLibraryAiOverrides(id)) return NextResponse.json(getLibraryAiOverrides(id))

View File

@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAiConfig } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
const { ocrMode, ocrLanguages } = getAiConfig()
return NextResponse.json({ ocrMode, ocrLanguages })
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth' import { requireAdmin } from '@/lib/auth'
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries } from '@/lib/app-settings' import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } from '@/lib/app-settings'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = await requireAdmin(request) const auth = await requireAdmin(request)
@@ -30,6 +30,13 @@ export async function PUT(request: NextRequest) {
promptExtract?: string promptExtract?: string
promptTranslate?: string promptTranslate?: string
maxRetries?: number maxRetries?: number
maxTokensTag?: number
maxTokensDescribe?: number
maxTokensExtract?: number
maxTokensTranslate?: number
ocrMode?: string
ocrLanguages?: string
ocrConfidenceThreshold?: number
} }
try { try {
body = await request.json() body = await request.json()
@@ -42,6 +49,8 @@ export async function PUT(request: NextRequest) {
modelTagging, modelDescribe, modelExtract, modelTranslate, modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate, promptDescribe, promptTagger, promptExtract, promptTranslate,
maxRetries, maxRetries,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
} = body } = body
if (typeof endpoint !== 'string') { if (typeof endpoint !== 'string') {
@@ -66,6 +75,13 @@ export async function PUT(request: NextRequest) {
typeof promptTagger === 'string' ? promptTagger : undefined, typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : undefined, typeof promptExtract === 'string' ? promptExtract : undefined,
typeof promptTranslate === 'string' ? promptTranslate : undefined, typeof promptTranslate === 'string' ? promptTranslate : undefined,
typeof maxTokensTag === 'number' ? maxTokensTag : undefined,
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
(ocrMode === 'hybrid' || ocrMode === 'tesseract' || ocrMode === 'llm') ? (ocrMode as OcrMode) : undefined,
typeof ocrLanguages === 'string' ? ocrLanguages : undefined,
typeof ocrConfidenceThreshold === 'number' ? ocrConfidenceThreshold : undefined,
) )
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) { if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs' import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS) const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'describe', libraryId) const jobId = enqueueJob(itemKey, 'describe', libraryId)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs' import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS) const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)

View File

@@ -1,24 +1,33 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let body: { itemKey?: string } let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
const { itemKey } = body const { itemKey, ocrLanguages, ocrMode } = body
if (!itemKey || typeof itemKey !== 'string') { if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'extract', libraryId) const payload: Record<string, string> = {}
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
if (ocrMode) payload.ocrMode = ocrMode
const jobId = enqueueJob(
itemKey,
'extract',
libraryId,
undefined,
Object.keys(payload).length ? payload : undefined,
)
return NextResponse.json({ jobId }, { status: 202 }) return NextResponse.json({ jobId }, { status: 202 })
} }

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getAiFields, updateExtractedText } from '@/lib/ai-tagger' import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
@@ -19,25 +19,37 @@ export async function GET(request: NextRequest) {
} }
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
let body: { itemKey?: string; extractedText?: string } let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
const { itemKey, extractedText } = body const { itemKey, extractedText, aiDescription } = body
if (!itemKey || typeof itemKey !== 'string') { if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
} }
if (typeof extractedText !== 'string') { if (extractedText === undefined && aiDescription === undefined) {
return NextResponse.json({ error: 'extractedText is required' }, { status: 400 }) return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
updateExtractedText(itemKey, extractedText) if (extractedText !== undefined) {
if (typeof extractedText !== 'string') {
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
}
updateExtractedText(itemKey, extractedText)
}
if (aiDescription !== undefined) {
if (typeof aiDescription !== 'string') {
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
}
updateAiDescription(itemKey, aiDescription)
}
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'tag', libraryId) const jobId = enqueueJob(itemKey, 'tag', libraryId)

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
import { getDb } from '@/lib/db'
export async function POST(request: NextRequest) {
let body: { libraryId?: string; path?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, path: dirPath } = body
if (!libraryId || typeof libraryId !== 'string') {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const prefix = dirPath
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
: `${libraryId}:mixed_file:`
// Only enqueue translate jobs for items that already have extracted text
const items = db
.prepare(
'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL'
)
.all(`${prefix}%`, 'mixed_file') as { item_key: string }[]
const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId))
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined) const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)

View File

@@ -1,4 +1,5 @@
import fs from 'fs' import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
@@ -31,6 +32,40 @@ export async function GET(request: NextRequest) {
const listing = recursive const listing = recursive
? scanDirectoryRecursive(root, libraryId, subpath) ? scanDirectoryRecursive(root, libraryId, subpath)
: scanDirectory(root, libraryId, subpath) : scanDirectory(root, libraryId, subpath)
// Annotate image files with hasExtractedText, and directories if any descendant has extracted text
const db = getDb()
const rows = db
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
.all(libraryId) as { item_key: string }[]
const withText = new Set(rows.map((r) => r.item_key))
// Build a set of all ancestor directory relative paths that contain at least one item with text
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
const dirsWithText = new Set<string>()
const keyPrefix = `${libraryId}:mixed_file:`
for (const key of withText) {
const decoded = decodeURIComponent(key.slice(keyPrefix.length))
const parts = decoded.split('/')
for (let i = 1; i < parts.length; i++) {
dirsWithText.add(parts.slice(0, i).join('/'))
}
}
listing.entries = listing.entries.map((e) => {
if (e.type === 'file') {
if (e.mediaType !== 'image') return e
const relPath = subpath ? path.join(subpath, e.name) : e.name
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
return { ...e, hasExtractedText: withText.has(itemKey) }
}
if (e.type === 'directory') {
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
if (dirsWithText.has(dirRel)) return { ...e, hasExtractedText: true }
}
return e
})
return NextResponse.json(listing) return NextResponse.json(listing)
} }

View File

@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
try { try {
const libraries = const libraries =
session.role === 'admin' session.role === 'admin'
? getLibraries() ? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' }))
: getLibrariesForUser(session.userId, session.role) : getLibrariesForUser(session.userId, session.role)
return NextResponse.json(libraries) return NextResponse.json(libraries)
} catch (err) { } catch (err) {

View File

@@ -120,7 +120,46 @@ export async function POST(request: NextRequest) {
status: nfo.status ?? null, status: nfo.status ?? null,
}), }),
}) })
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
// Optionally also refresh every episode NFO in this series
let episodesUpdated = 0
const includeEpisodes = searchParams.get('includeEpisodes') === 'true'
if (includeEpisodes) {
type EpRow = { item_key: string; file_path: string | null; metadata: string | null }
const episodeRows = db
.prepare(`SELECT item_key, file_path, metadata FROM media_items WHERE item_type = 'tv_episode' AND item_key LIKE ?`)
.all(`${libraryId}:tv_episode:${encodedDirName}:%`) as EpRow[]
const updateEp = db.prepare(`
UPDATE media_items SET title = @title, plot = @plot, metadata = @metadata WHERE item_key = @item_key
`)
db.transaction(() => {
for (const ep of episodeRows) {
if (!ep.file_path) continue
const epDir = path.join(libraryRoot, path.dirname(ep.file_path))
const baseName = path.basename(ep.file_path, path.extname(ep.file_path))
const epNfo = parseEpisodeNfo(path.join(epDir, `${baseName}.nfo`))
if (!epNfo) continue
const epMeta = ep.metadata ? JSON.parse(ep.metadata) : {}
updateEp.run({
item_key: ep.item_key,
title: epNfo.title ?? null,
plot: epNfo.plot ?? null,
metadata: JSON.stringify({
...epMeta,
episodeNumber: epNfo.episode ?? epMeta.episodeNumber ?? null,
seasonNumber: epNfo.season ?? epMeta.seasonNumber ?? null,
aired: epNfo.aired ?? null,
rating: epNfo.rating ?? null,
}),
})
episodesUpdated++
}
})()
}
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year, episodesUpdated })
} }
if (itemType === 'tv_episode') { if (itemType === 'tv_episode') {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags' import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
function extractLibraryId(itemKey: string): string | null { function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':') const colonIdx = itemKey.indexOf(':')
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
addTagToItem(itemKey, tagId) addTagToItem(itemKey, tagId)
@@ -60,7 +60,7 @@ export async function DELETE(request: NextRequest) {
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
removeTagFromItem(itemKey, tagId) removeTagFromItem(itemKey, tagId)

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth' import { requireAdmin } from '@/lib/auth'
import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users' import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } from '@/lib/users'
import { getLibraries } from '@/lib/libraries' import { getLibraries } from '@/lib/libraries'
export async function GET( export async function GET(
@@ -17,8 +17,8 @@ export async function GET(
return NextResponse.json({ error: 'User not found' }, { status: 404 }) return NextResponse.json({ error: 'User not found' }, { status: 404 })
} }
const libraryIds = getPermittedLibraryIds(id) const permissions = getLibraryPermissions(id)
return NextResponse.json({ libraryIds }) return NextResponse.json({ permissions })
} }
export async function PUT( export async function PUT(
@@ -35,24 +35,41 @@ export async function PUT(
return NextResponse.json({ error: 'User not found' }, { status: 404 }) return NextResponse.json({ error: 'User not found' }, { status: 404 })
} }
let body: { libraryIds?: unknown } let body: { permissions?: unknown }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) { if (!Array.isArray(body.permissions)) {
return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 }) return NextResponse.json({ error: 'permissions must be an array' }, { status: 400 })
} }
const validAccessLevels = new Set(['read', 'write'])
for (const item of body.permissions) {
if (
typeof item !== 'object' ||
item === null ||
typeof (item as Record<string, unknown>).libraryId !== 'string' ||
!validAccessLevels.has((item as Record<string, unknown>).accessLevel as string)
) {
return NextResponse.json(
{ error: 'Each permission must have libraryId (string) and accessLevel ("read" | "write")' },
{ status: 400 }
)
}
}
const permissions = body.permissions as LibraryPermission[]
const allLibraries = getLibraries() const allLibraries = getLibraries()
const validIds = new Set(allLibraries.map((l) => l.id)) const validIds = new Set(allLibraries.map((l) => l.id))
const invalid = body.libraryIds.filter((id) => !validIds.has(id)) const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId)
if (invalid.length > 0) { if (invalid.length > 0) {
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 }) return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
} }
setLibraryPermissions(id, body.libraryIds) setLibraryPermissions(id, permissions)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

@@ -1,7 +1,7 @@
import { getLibrary } from '@/lib/libraries' import { getLibrary } from '@/lib/libraries'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth' import { getServerSession } from '@/lib/auth'
import { getPermittedLibraryIds } from '@/lib/users' import { getLibraryAccessLevel } from '@/lib/users'
import GamesView from '@/components/games/GamesView' import GamesView from '@/components/games/GamesView'
import MixedView from '@/components/mixed/MixedView' import MixedView from '@/components/mixed/MixedView'
import MoviesView from '@/components/movies/MoviesView' import MoviesView from '@/components/movies/MoviesView'
@@ -23,32 +23,41 @@ export default async function LibraryPage({ params, searchParams }: Props) {
const library = getLibrary(id) const library = getLibrary(id)
if (!library) notFound() if (!library) notFound()
let readOnly = false
if (session.role !== 'admin') { if (session.role !== 'admin') {
const permitted = getPermittedLibraryIds(session.userId) const accessLevel = getLibraryAccessLevel(session.userId, id)
if (!permitted.includes(id)) notFound() if (!accessLevel) notFound()
readOnly = accessLevel === 'read'
} }
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-6"> {library.type !== 'mixed' && (
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}> <div className="flex items-center gap-2 mb-6">
Libraries <a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
</a> Libraries
<span style={{ color: 'var(--text-secondary)' }}>/</span> </a>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <span style={{ color: 'var(--text-secondary)' }}>/</span>
{library.name} <span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
</span> {library.name}
{session.role === 'admin' && ( </span>
<div className="ml-auto"> {session.role === 'admin' && (
<ScanLibraryButton libraryId={id} /> <div className="ml-auto">
</div> <ScanLibraryButton libraryId={id} />
)} </div>
</div> )}
</div>
)}
{library.type === 'mixed' && session.role === 'admin' && (
<div className="flex justify-end mb-2">
<ScanLibraryButton libraryId={id} />
</div>
)}
{library.type === 'games' && <GamesView libraryId={id} />} {library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />} {library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
{library.type === 'movies' && <MoviesView libraryId={id} />} {library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
{library.type === 'tv' && <TvView libraryId={id} />} {library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
</div> </div>
) )
} }

View File

@@ -16,6 +16,13 @@ interface AiSettings {
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxRetries: number maxRetries: number
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
ocrMode: 'hybrid' | 'tesseract' | 'llm'
ocrLanguages: string
ocrConfidenceThreshold: number
} }
interface AiJob { interface AiJob {
@@ -47,6 +54,10 @@ interface LibraryOverride {
promptTagger: string promptTagger: string
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
} }
function formatElapsed(startedAt: number): string { function formatElapsed(startedAt: number): string {
@@ -67,6 +78,8 @@ export default function AiTaggingPage() {
enabled: false, preferredLanguage: 'English', enabled: false, preferredLanguage: 'English',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '', promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
maxRetries: 3, maxRetries: 3,
maxTokensTag: 8192, maxTokensDescribe: 8192, maxTokensExtract: 8192, maxTokensTranslate: 8192,
ocrMode: 'hybrid', ocrLanguages: 'eng', ocrConfidenceThreshold: 70,
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -296,7 +309,7 @@ export default function AiTaggingPage() {
} }
} }
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => { const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string | number | null) => {
setLibraryOverrides((prev) => ({ setLibraryOverrides((prev) => ({
...prev, ...prev,
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value }, [libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
@@ -544,6 +557,25 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Tagging Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensTag}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensTag: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Description Model"> <Field label="Description Model">
<input <input
type="text" type="text"
@@ -561,6 +593,25 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Description Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensDescribe}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensDescribe: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Text Extraction Model"> <Field label="Text Extraction Model">
<input <input
type="text" type="text"
@@ -578,6 +629,91 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Text Extraction Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensExtract}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensExtract: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="OCR Mode">
<div className="flex gap-2">
{(['hybrid', 'tesseract', 'llm'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setSettings((s) => ({ ...s, ocrMode: mode }))}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{
backgroundColor: settings.ocrMode === mode ? 'var(--accent)' : 'var(--surface)',
color: settings.ocrMode === mode ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
>
{mode === 'hybrid' ? 'Hybrid' : mode === 'tesseract' ? 'Tesseract only' : 'LLM only'}
</button>
))}
</div>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
Hybrid runs local OCR first and falls back to the LLM when confidence is low. Tesseract only never calls the LLM. LLM only uses the original behaviour.
</p>
</Field>
<Field label="OCR Languages">
<input
type="text"
value={settings.ocrLanguages}
onChange={(e) => setSettings((s) => ({ ...s, ocrLanguages: e.target.value }))}
placeholder="eng"
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{`Tesseract language packs to use, joined with '+'. For Japanese manga use jpn+jpn_vert. Language data is downloaded automatically on first use.`}
</p>
</Field>
<Field label="OCR Confidence Threshold">
<input
type="number"
min={0}
max={100}
value={settings.ocrConfidenceThreshold}
onChange={(e) =>
setSettings((s) => ({ ...s, ocrConfidenceThreshold: Math.max(0, Math.min(100, parseInt(e.target.value) || 70)) }))
}
className="w-24 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
In hybrid mode, Tesseract results below this confidence score (0100) fall back to the LLM. Default is 70.
</p>
</Field>
<Field label="Translation Model"> <Field label="Translation Model">
<input <input
type="text" type="text"
@@ -595,6 +731,25 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Translation Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensTranslate}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensTranslate: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Automatic Tagging"> <Field label="Automatic Tagging">
<label className="flex items-center gap-3 cursor-pointer select-none"> <label className="flex items-center gap-3 cursor-pointer select-none">
<div <div
@@ -890,7 +1045,7 @@ export default function AiTaggingPage() {
<Field key={field} label={label}> <Field key={field} label={label}>
<input <input
type="text" type="text"
value={overrides[field]} value={overrides[field] as string}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)} onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`} placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2" className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
@@ -906,6 +1061,39 @@ export default function AiTaggingPage() {
))} ))}
</div> </div>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Max Tokens</p>
{(
[
['maxTokensTag', 'Tagging', 'maxTokensTag'] as const,
['maxTokensDescribe', 'Description', 'maxTokensDescribe'] as const,
['maxTokensExtract', 'Text Extraction', 'maxTokensExtract'] as const,
['maxTokensTranslate', 'Translation', 'maxTokensTranslate'] as const,
]
).map(([field, label, globalField]) => (
<Field key={field} label={label}>
<input
type="number"
min={1}
value={overrides[field] ?? ''}
placeholder={`Leave blank to use global default (${settings[globalField]})`}
onChange={(e) => {
const raw = e.target.value
updateLibraryOverride(lib.id, field, raw === '' ? null : Math.max(1, parseInt(raw) || 1))
}}
className="w-40 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
))}
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p> <p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
{( {(
@@ -919,7 +1107,7 @@ export default function AiTaggingPage() {
<Field key={field} label={label}> <Field key={field} label={label}>
<textarea <textarea
rows={3} rows={3}
value={overrides[field]} value={overrides[field] as string}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)} onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'} placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y" className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
@@ -1010,6 +1198,7 @@ function emptyOverride(): LibraryOverride {
return { return {
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '', promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
maxTokensTag: null, maxTokensDescribe: null, maxTokensExtract: null, maxTokensTranslate: null,
} }
} }

View File

@@ -216,32 +216,39 @@ function UserRow({
// ─── Permissions Panel ──────────────────────────────────────────────────────── // ─── Permissions Panel ────────────────────────────────────────────────────────
type AccessLevel = 'none' | 'read' | 'write'
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) { function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
const [permitted, setPermitted] = useState<string[]>([]) const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
useEffect(() => { useEffect(() => {
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`) fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
.then((r) => r.json()) .then((r) => r.json())
.then((data: { libraryIds: string[] }) => { .then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
setPermitted(data.libraryIds) const map: Record<string, AccessLevel> = {}
for (const p of data.permissions) {
map[p.libraryId] = p.accessLevel
}
setLevels(map)
setLoaded(true) setLoaded(true)
}) })
}, [userId]) }, [userId])
const toggle = (libraryId: string) => { const setLevel = (libraryId: string, level: AccessLevel) => {
setPermitted((prev) => setLevels((prev) => ({ ...prev, [libraryId]: level }))
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
)
} }
const save = async () => { const save = async () => {
setSaving(true) setSaving(true)
const permissions = Object.entries(levels)
.filter(([, level]) => level !== 'none')
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, { await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryIds: permitted }), body: JSON.stringify({ permissions }),
}) })
setSaving(false) setSaving(false)
} }
@@ -265,23 +272,40 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li
{libraries.length === 0 ? ( {libraries.length === 0 ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p> <p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-2">
{libraries.map((lib) => ( {libraries.map((lib) => {
<label key={lib.id} className="flex items-center gap-2 cursor-pointer"> const current = levels[lib.id] ?? 'none'
<input return (
type="checkbox" <div key={lib.id} className="flex items-center justify-between gap-3">
checked={permitted.includes(lib.id)} <div className="flex items-center gap-1.5 min-w-0">
onChange={() => toggle(lib.id)} <span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
className="rounded" {lib.name}
/> </span>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}> <span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
{lib.name} ({lib.type})
</span> </span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}> </div>
({lib.type}) <div
</span> className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
</label> style={{ border: '1px solid var(--border)' }}
))} >
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(lib.id, lvl)}
className="px-2.5 py-1 transition-colors capitalize"
style={{
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
}}
>
{lvl}
</button>
))}
</div>
</div>
)
})}
</div> </div>
)} )}
<button <button

View File

@@ -48,8 +48,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
const [extracting, setExtracting] = useState(false) const [extracting, setExtracting] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null) const [extractError, setExtractError] = useState<string | null>(null)
const [extractPending, setExtractPending] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cooldownRef = useRef(false) const cooldownRef = useRef(false)
const touchStartY = useRef<number | null>(null) const touchStartY = useRef<number | null>(null)
@@ -126,14 +128,19 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
return () => clearTimeout(id) return () => clearTimeout(id)
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext]) }, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
// Fetch extracted text for current item // Fetch extracted text for current item; clear any in-flight poll on item change
useEffect(() => { useEffect(() => {
if (extractPollRef.current) {
clearInterval(extractPollRef.current)
extractPollRef.current = null
}
setExtractedText(null) setExtractedText(null)
setTranslatedText(null) setTranslatedText(null)
setShowTextOverlay(false) setShowTextOverlay(false)
setShowOriginal(false) setShowOriginal(false)
setExtracting(false) setExtracting(false)
setExtractError(null) setExtractError(null)
setExtractPending(false)
if (!current?.itemKey) return if (!current?.itemKey) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`) fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -144,6 +151,13 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
.catch(() => {}) .catch(() => {})
}, [current?.itemKey]) }, [current?.itemKey])
// Clean up poll on unmount
useEffect(() => {
return () => {
if (extractPollRef.current) clearInterval(extractPollRef.current)
}
}, [])
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return } if (e.key === 'Escape') { onClose(); return }
@@ -184,23 +198,44 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const handleExtractText = async () => { const handleExtractText = async () => {
if (!current?.itemKey) return if (!current?.itemKey) return
const itemKey = current.itemKey
setExtracting(true) setExtracting(true)
setExtractError(null) setExtractError(null)
setExtractPending(false)
try { try {
const res = await fetch('/api/ai-tagging/extract-text', { const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: current.itemKey }), body: JSON.stringify({ itemKey }),
}) })
if (res.status === 202) {
// Job queued — poll until it completes (up to 5 min)
setExtractPending(true)
const deadline = Date.now() + 5 * 60 * 1000
extractPollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
if (extractPollRef.current) clearInterval(extractPollRef.current)
setExtractPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
if (data.extractedText) {
if (extractPollRef.current) clearInterval(extractPollRef.current)
setExtractPending(false)
setExtractedText(data.extractedText)
setTranslatedText(data.extractedTextTranslated)
setShowTextOverlay(true)
}
} catch { /* ignore */ }
}, 2000)
return
}
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Extraction failed') throw new Error((data as { error?: string }).error ?? 'Extraction failed')
} }
if (res.status === 202) {
setExtractError('Queued — check AI Integrations for progress')
setTimeout(() => setExtractError(null), 4000)
return
}
const result = await res.json() const result = await res.json()
setExtractedText(result.extractedText || null) setExtractedText(result.extractedText || null)
setTranslatedText(result.translatedText || null) setTranslatedText(result.translatedText || null)
@@ -301,7 +336,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
{/* Text overlay */} {/* Text overlay */}
{showTextOverlay && displayText && ( {showTextOverlay && displayText && (
<div <div
className="absolute bottom-16 left-4 right-4 z-20 rounded-xl p-4" className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }} style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
) : current?.itemKey && current?.mediaType === 'image' ? ( ) : current?.itemKey && current?.mediaType === 'image' ? (
<button <button
onClick={handleExtractText} onClick={handleExtractText}
disabled={extracting} disabled={extracting || extractPending}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40" className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
style={{ style={{
backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)', backgroundColor: extractPending
? 'var(--accent)'
: extractError
? 'rgba(127,29,29,0.8)'
: 'rgba(0,0,0,0.5)',
color: extractError ? '#fca5a5' : '#fff', color: extractError ? '#fca5a5' : '#fff',
}} }}
aria-label="Extract text" aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
> >
{extracting ? ( {extracting || extractPending ? (
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}></span> <span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}></span>
) : ( ) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View File

@@ -2,7 +2,8 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game, GameFile, GamePlatform } from '@/types' import type { Game, GameFile, GamePlatform } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
// Import SVG icons // Import SVG icons
import WindowsIcon from '@/app/icons/windows.svg' import WindowsIcon from '@/app/icons/windows.svg'
@@ -29,12 +30,15 @@ interface Props {
game: Game game: Game
libraryId: string libraryId: string
onClose: () => void onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void onTagsChanged?: () => void
onCoverUploaded?: () => void onCoverUploaded?: () => void
onDeleted?: (gameId: string) => void onDeleted?: (gameId: string) => void
readOnly?: boolean
} }
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) { export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null) const screenshotInputRef = useRef<HTMLInputElement>(null)
@@ -46,6 +50,9 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
const [renameName, setRenameName] = useState('') const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null) const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false) const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [aiDescription, setAiDescription] = useState<string | null>(null)
// Screenshots state // Screenshots state
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([]) const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
@@ -54,6 +61,8 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null) const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
const [uploadingCount, setUploadingCount] = useState(0) const [uploadingCount, setUploadingCount] = useState(0)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
const fetchScreenshots = useCallback(() => { const fetchScreenshots = useCallback(() => {
setScreenshotsLoading(true) setScreenshotsLoading(true)
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`) fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
@@ -65,6 +74,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
useEffect(() => { fetchScreenshots() }, [fetchScreenshots]) useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
useEffect(() => {
if (!game.item_key) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(game.item_key)}`)
.then((r) => r.json())
.then((d: { aiDescription: string | null }) => setAiDescription(d.aiDescription ?? null))
.catch(() => {})
}, [game.item_key])
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []) const files = Array.from(e.target.files ?? [])
if (files.length === 0) return if (files.length === 0) return
@@ -106,11 +123,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return } if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
return return
} }
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
if (renaming) { setRenaming(false); return } if (renaming) { setRenaming(false); return }
if (editingImages) { setEditingImages(false); return } if (editingImages) { setEditingImages(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose() onClose()
} }
} }
@@ -120,7 +140,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length]) }, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -153,306 +173,372 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4" className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }} style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div {/* Outer flex — row on md+, col on mobile when panel open */}
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl" <div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> {/* ── Left pane — relative container for floating controls ── */}
{editingImages ? ( <div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
<ImageEditor {/* Scrollable card area */}
game={game} <div className="h-full overflow-y-auto flex items-center justify-center p-4">
libraryId={libraryId} <div
onBack={() => setEditingImages(false)} className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
onUploaded={onCoverUploaded} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
/> onClick={(e) => e.stopPropagation()}
) : ( >
<> {editingImages ? (
{/* Close button */} <ImageEditor
game={game}
libraryId={libraryId}
onBack={() => setEditingImages(false)}
onUploaded={onCoverUploaded}
/>
) : (
<>
{/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{heroImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
) : (
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
)}
</div>
{/* Info */}
<div className="p-5">
{/* Title row with kebab menu */}
<div className="flex items-center gap-2 mb-2">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{game.title}
</h2>
{/* Kebab menu */}
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit images
</button>
<button
onClick={() => {
setMenuOpen(false)
setRenameName(decodeURIComponent(game.id))
setRenameError(null)
setRenaming(true)
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
{onDeleted && (
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete game
</button>
)}
</div>
)}
</div>}
</div>
{/* AI description (read-only) */}
{aiDescription && (
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
{aiDescription}
</p>
)}
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.() // triggers refetch
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
if (e.key === 'Escape') setRenaming(false)
}}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{/* Delete confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this game and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={() => {
setDeleting(true)
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
.then(() => onDeleted!(game.id))
.catch(() => setDeleting(false))
}}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Assigned tags (read-only) above download */}
{game.item_key && (
<div className="mb-3">
<AssignedTagBadges itemKey={game.item_key} refreshKey={tagRefreshKey} />
</div>
)}
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
{/* Screenshots */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Screenshots
</p>
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{screenshotsLoading && screenshots.length === 0 ? (
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
) : (
<>
{screenshots.map((shot, idx) => (
<div
key={shot.filename}
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
style={{ backgroundColor: 'var(--border)' }}
onClick={() => setLightboxIndex(idx)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
{deletingScreenshot !== shot.filename && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
aria-label="Delete screenshot"
>
</button>
)}
{deletingScreenshot === shot.filename && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Deleting</span>
</div>
)}
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading</span>
</div>
))}
<button
onClick={() => screenshotInputRef.current?.click()}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
aria-label="Add screenshot"
>
<span className="text-xl">+</span>
</button>
</>
)}
</div>
<input
ref={screenshotInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleScreenshotUpload}
/>
</div>
</div>
</>
)}
</div>
</div>
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{game.item_key && !showTagPanel && (
<button
onClick={() => setShowTagPanel(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" className={smallBtn}
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close" aria-label="Close"
> >
</button> </button>
</div>
{/* Hero image */} {/* Prev / Next */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}> {onPrev && (
{heroImage ? ( <button
// eslint-disable-next-line @next/next/no-img-element onClick={(e) => { e.stopPropagation(); onPrev() }}
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" /> className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
) : ( style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
<div className="h-40 flex items-center justify-center text-5xl">🎮</div> aria-label="Previous"
)} >
</div>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div>
{/* Info */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
<div className="p-5"> {showTagPanel && (
{/* Title row with kebab menu */} <MediaTagPanel
<div className="flex items-center gap-2 mb-4"> itemKey={game.item_key!}
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}> onHide={() => setShowTagPanel(false)}
{game.title} onClose={onClose}
</h2> onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
{/* Kebab menu */} />
<div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit images
</button>
<button
onClick={() => {
setMenuOpen(false)
setRenameName(decodeURIComponent(game.id))
setRenameError(null)
setRenaming(true)
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
{onDeleted && (
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete game
</button>
)}
</div>
)}
</div>
</div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.() // triggers refetch
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
if (e.key === 'Escape') setRenaming(false)
}}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{/* Delete confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this game and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={() => {
setDeleting(true)
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
.then(() => onDeleted!(game.id))
.catch(() => setDeleting(false))
}}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
{/* Screenshots */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Screenshots
</p>
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{screenshotsLoading && screenshots.length === 0 ? (
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
) : (
<>
{screenshots.map((shot, idx) => (
<div
key={shot.filename}
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
style={{ backgroundColor: 'var(--border)' }}
onClick={() => setLightboxIndex(idx)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
{deletingScreenshot !== shot.filename && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
aria-label="Delete screenshot"
>
</button>
)}
{deletingScreenshot === shot.filename && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Deleting</span>
</div>
)}
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading</span>
</div>
))}
<button
onClick={() => screenshotInputRef.current?.click()}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
aria-label="Add screenshot"
>
<span className="text-xl">+</span>
</button>
</>
)}
</div>
<input
ref={screenshotInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleScreenshotUpload}
/>
</div>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
</div>
</div>
</>
)} )}
</div> </div>
{/* Lightbox */} {/* Screenshot lightbox (z-60, sits above the modal) */}
{lightboxIndex !== null && ( {lightboxIndex !== null && (
<div <div
className="fixed inset-0 flex items-center justify-center" className="fixed inset-0 flex items-center justify-center"

View File

@@ -58,9 +58,10 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
export default function GamesView({ libraryId }: Props) { export default function GamesView({ libraryId, readOnly }: Props) {
const [items, setItems] = useState<(Game | GameSeries)[]>([]) const [items, setItems] = useState<(Game | GameSeries)[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -72,7 +73,10 @@ export default function GamesView({ libraryId }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedGameIndex, setSelectedGameIndex] = useState<number | null>(null)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -147,6 +151,9 @@ export default function GamesView({ libraryId }: Props) {
}) })
const filtersActive = search !== '' || selectedTagIds.size > 0 const filtersActive = search !== '' || selectedTagIds.size > 0
const filteredGames: Game[] = filtered.flatMap((item) =>
'games' in item ? item.games : [item as Game]
)
return ( return (
<> <>
@@ -220,7 +227,7 @@ export default function GamesView({ libraryId }: Props) {
<GameCard <GameCard
key={item.id} key={item.id}
game={item} game={item}
onClick={() => setSelected(item)} onClick={() => { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }}
/> />
) )
)} )}
@@ -231,11 +238,19 @@ export default function GamesView({ libraryId }: Props) {
<GameDetailModal <GameDetailModal
game={selected} game={selected}
libraryId={libraryId} libraryId={libraryId}
onClose={() => setSelected(null)} readOnly={readOnly}
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
: undefined}
onNext={selectedGameIndex !== null && selectedGameIndex < filteredGames.length - 1
? () => { const g = filteredGames[selectedGameIndex + 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex + 1) }
: undefined}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onCoverUploaded={() => fetchGames(true)} onCoverUploaded={() => fetchGames(true)}
onDeleted={() => { onDeleted={() => {
setSelected(null) setSelected(null)
setSelectedGameIndex(null)
fetchGames() fetchGames()
fetchAssignments() fetchAssignments()
}} }}
@@ -289,6 +304,7 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
const seriesPlatforms: GamePlatform[] = [ const seriesPlatforms: GamePlatform[] = [
...new Set(series.games.flatMap((g) => g.platforms)), ...new Set(series.games.flatMap((g) => g.platforms)),
] ]
const resolvedCover = series.coverUrl ?? series.games[0]?.coverUrl ?? null
return ( return (
<button <button
@@ -305,9 +321,9 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
}} }}
> >
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}> <div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{series.coverUrl ? ( {resolvedCover ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" /> <img src={resolvedCover} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div> <div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)} )}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props { interface Props {
url: string url: string
@@ -12,29 +12,48 @@ interface Props {
itemKey?: string itemKey?: string
onTagsChanged?: () => void onTagsChanged?: () => void
onAiTag?: () => Promise<void> onAiTag?: () => Promise<void>
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) { export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const [aiTagging, setAiTagging] = useState(false) const showTags = showTagsProp ?? showTagsLocal
const [aiTagError, setAiTagError] = useState<string | null>(null) const setShowTags = onShowTagsChange ?? setShowTagsLocal
const [tagRefreshKey, setTagRefreshKey] = useState(0)
// Text extraction state // Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null) const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null) const [translatedText, setTranslatedText] = useState<string | null>(null)
const [extracting, setExtracting] = useState(false) const [extracting, setExtracting] = useState(false)
const [extractPending, setExtractPending] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null) const [extractError, setExtractError] = useState<string | null>(null)
const [retranslating, setRetranslating] = useState(false) const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [editedExtractedText, setEditedExtractedText] = useState<string>('') const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false) const [savingText, setSavingText] = useState(false)
const [sourceLanguage, setSourceLanguage] = useState('') const [sourceLanguage, setSourceLanguage] = useState('')
// Description state
const [aiDescription, setAiDescription] = useState<string | null>(null)
const [editedDescription, setEditedDescription] = useState<string>('')
const [savingDesc, setSavingDesc] = useState(false)
const [generatingDesc, setGeneratingDesc] = useState(false)
const [descPending, setDescPending] = useState(false)
const [descError, setDescError] = useState<string | null>(null)
// OCR settings
const [ocrMode, setOcrMode] = useState<string | null>(null)
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
// Text overlay state // Text overlay state
const [showTextOverlay, setShowTextOverlay] = useState(false) const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
// Polling ref
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Determine if this is an image file (for text extraction controls) // Determine if this is an image file (for text extraction controls)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name) const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
@@ -42,18 +61,70 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
// Fetch existing AI fields on mount / item change // Fetch existing AI fields on mount / item change
useEffect(() => { const fetchAiFields = useCallback(() => {
if (!itemKey) return if (!itemKey) return Promise.resolve()
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => { .then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
setExtractedText(data.extractedText) setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '') setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated) setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
}) })
.catch(() => {}) .catch(() => {})
}, [itemKey]) }, [itemKey])
useEffect(() => {
fetchAiFields()
fetch('/api/ai-settings/ocr')
.then((r) => r.json())
.then((d: { ocrMode: string; ocrLanguages: string }) => {
setOcrMode(d.ocrMode)
setDefaultOcrLanguages(d.ocrLanguages)
})
.catch(() => {})
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [fetchAiFields])
// Start polling fields every 2s until data changes or 5-min timeout
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
if (!itemKey) return
if (pollRef.current) clearInterval(pollRef.current)
const deadline = Date.now() + 5 * 60 * 1000
pollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
const textChanged = data.extractedText !== snapshotText
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
const descChanged = data.aiDescription !== snapshotDesc
if (textChanged || translationChanged || descChanged) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
}
} catch { /* ignore */ }
}, 2000)
}, [itemKey])
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
@@ -72,24 +143,189 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const handleGenerateDescription = async () => {
if (!itemKey) return
setGeneratingDesc(true)
setDescError(null)
setDescPending(false)
try {
const res = await fetch('/api/ai-tagging/describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (res.status === 202) {
setDescPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
}
const { description } = await res.json()
setAiDescription(description)
} catch (err) {
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
setTimeout(() => setDescError(null), 4000)
} finally {
setGeneratingDesc(false)
}
}
const callExtract = async (modeOverride: string) => {
setExtracting(true)
setExtractError(null)
setExtractPending(false)
try {
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey,
ocrMode: modeOverride,
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
}),
})
if (res.status === 202) {
setExtractPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
}
const result = await res.json()
setExtractedText(result.extractedText || null)
setEditedExtractedText(result.extractedText || '')
setTranslatedText(result.translatedText || null)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen" className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Toolbar */} {/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}> <div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name} {/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
</span> <div className="relative flex-1 min-h-0 min-w-0">
<div className="flex items-center gap-2 flex-shrink-0"> {/* eslint-disable-next-line @next/next/no-img-element */}
{/* Text overlay button — only shown when extracted text exists */} <img
{extractedText && ( src={url}
alt={name}
className="absolute inset-0 w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
{/* ── Floating controls ── */}
{/* Filename pill — bottom-left */}
<div
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
>
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
{name}
</span>
</div>
{/* Tags + Close — top-right */}
<div
className="absolute top-4 right-4 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{itemKey && !showTags && (
<button
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
{/* Text display button — bottom-right, hidden when panel open */}
{!showTags && extractedText && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
className="w-12 h-12 rounded-full flex items-center justify-center transition-colors" className={`absolute bottom-4 right-4 ${smallBtn}`}
style={{ style={{
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)', backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)', color: showTextOverlay ? '#fff' : 'var(--text-primary)',
@@ -103,208 +339,156 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
aria-label={showTextOverlay ? 'Hide text' : 'Show text'} aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text" title="Display text"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/> <line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="15" y2="12"/> <line x1="3" y1="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/> <line x1="3" y1="18" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
)} )}
{itemKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
color: showTags ? '#fff' : 'var(--text-primary)',
fontSize: '1.5rem',
}}
onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags"
>
🏷
</button>
)}
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
fontSize: '1.5rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label="AI Tag this image"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? (
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}></span>
) : '✨'}
</button>
)}
<button
onClick={onClose}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div> </div>
</div>
{showTags ? ( {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit"> {showTags && (
{/* Image */} <MediaTagPanel
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative"> itemKey={itemKey!}
{/* eslint-disable-next-line @next/next/no-img-element */} onHide={() => setShowTags(false)}
<img onClose={onClose}
src={url} onTagsChanged={onTagsChanged}
alt={name} onAiTag={readOnly ? undefined : onAiTag}
className="max-w-full max-h-full w-auto h-auto object-contain rounded-lg" readOnly={readOnly}
onClick={(e) => e.stopPropagation()}
/>
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
</div>
{/* Tag panel */}
<div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
> >
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> {/* Description section */}
Tags <div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
</p> <div className="flex items-center justify-between mb-2">
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} /> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Description
</p>
<button
onClick={handleGenerateDescription}
disabled={generatingDesc || descPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: descPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<textarea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
placeholder="No description yet…"
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: '3.5rem',
maxHeight: '8rem',
fontFamily: 'inherit',
}}
/>
{editedDescription !== (aiDescription ?? '') && (
<button
onClick={async () => {
setSavingDesc(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
})
setAiDescription(editedDescription)
} finally {
setSavingDesc(false)
}
}}
disabled={savingDesc}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingDesc ? '⟳ Saving…' : 'Save'}
</button>
)}
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
</div>
{/* Text extraction section — only for images */} {/* Text extraction section — only for images */}
{isImage && ( {isImage && (
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> <div className="flex items-center justify-between">
Text Extraction <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
</p> Text Extraction
</p>
<button
onClick={() => callExtract('llm')}
disabled={extracting || extractPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
color: extractPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="Extract text with AI"
title="Extract with AI (skips OCR)"
>
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<button <div className="flex items-center gap-2 flex-wrap">
onClick={async () => { <button
setExtracting(true) onClick={() => callExtract('tesseract')}
setExtractError(null) disabled={extracting || extractPending}
try { className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
const res = await fetch('/api/ai-tagging/extract-text', { style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
method: 'POST', onMouseEnter={(e) => {
headers: { 'Content-Type': 'application/json' }, if (!extracting && !extractPending) {
body: JSON.stringify({ itemKey }), ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}) ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
} }
if (res.status === 202) { }}
setExtractError('Queued — check AI Integrations for progress') onMouseLeave={(e) => {
setTimeout(() => setExtractError(null), 4000) ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
return ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
} }}
const result = await res.json() >
setExtractedText(result.extractedText || null) {extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
setEditedExtractedText(result.extractedText || '') </button>
setTranslatedText(result.translatedText || null) <input
} catch (err) { type="text"
setExtractError(err instanceof Error ? err.message : 'Failed to extract text') value={ocrLanguageInput}
setTimeout(() => setExtractError(null), 4000) onChange={(e) => setOcrLanguageInput(e.target.value)}
} finally { placeholder={defaultOcrLanguages}
setExtracting(false) className="text-xs px-2 py-0.5 rounded-full outline-none"
} style={{
}} backgroundColor: 'var(--background)',
disabled={extracting} border: '1px solid var(--border)',
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2" color: 'var(--text-primary)',
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} width: 120,
onMouseEnter={(e) => { }}
if (!extracting) { title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' />
;(e.currentTarget as HTMLElement).style.color = 'var(--background)' </div>
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
</button>
{extractError && ( {extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
)}
{extractedText && ( {extractedText && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -363,7 +547,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</div> </div>
)} )}
<<<<<<< Updated upstream
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<input <input
type="text" type="text"
@@ -381,143 +564,59 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
<button <button
onClick={async () => { onClick={async () => {
setRetranslating(true) setRetranslating(true)
setTranslatePending(false)
try { try {
const res = await fetch('/api/ai-tagging/translate', { const res = await fetch('/api/ai-tagging/translate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }), body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
}) })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
=======
<div className="flex items-center gap-1.5 flex-wrap">
<input
type="text"
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="Source lang…"
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 100,
}}
/>
<button
onClick={async () => {
setRetranslating(true)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
}
if (res.status === 202) { if (res.status === 202) {
setExtractError('Queued — check AI Integrations for progress') setTranslatePending(true)
setTimeout(() => setExtractError(null), 4000) startPolling(extractedText, translatedText, aiDescription)
} else { return
const result = await res.json()
setTranslatedText(result.translatedText || null)
} }
} catch { if (!res.ok) {
// ignore const data = await res.json().catch(() => ({}))
} finally { throw new Error((data as { error?: string }).error ?? 'Failed to translate')
setRetranslating(false)
>>>>>>> Stashed changes
}
if (res.status !== 202) {
const result = await res.json()
setTranslatedText(result.translatedText || null)
} }
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch { } catch {
// ignore // ignore
} finally { } finally {
setRetranslating(false) setRetranslating(false)
} }
}} }}
disabled={retranslating} disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50" className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!retranslating) { if (!retranslating && !translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)' ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' if (!translatePending) {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}} }}
> >
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'} {retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </MediaTagPanel>
</div> )}
) : ( </div>
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="max-w-full max-h-full object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -11,7 +11,9 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
libraryName: string
initialPath: string initialPath: string
readOnly?: boolean
} }
type ModalState = type ModalState =
@@ -21,18 +23,21 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, initialPath }: Props) { export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath) const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null) const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>(null) const [modal, setModal] = useState<ModalState>(null)
const [modalShowTags, setModalShowTags] = useState(false)
const [tagPanel, setTagPanel] = useState<TagPanelState>(null) const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([]) const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
const [recursiveLoading, setRecursiveLoading] = useState(false) const [recursiveLoading, setRecursiveLoading] = useState(false)
const [recursiveLoaded, setRecursiveLoaded] = useState(false) const [recursiveLoaded, setRecursiveLoaded] = useState(false)
@@ -83,6 +88,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
setDoomScrollLoading(false) setDoomScrollLoading(false)
}, [currentPath]) }, [currentPath])
const [ocrMode, setOcrMode] = useState<string | null>(null)
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const fetchAssignments = useCallback(() => { const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -92,6 +100,16 @@ export default function MixedView({ libraryId, initialPath }: Props) {
useEffect(() => { fetchAssignments() }, [fetchAssignments]) useEffect(() => { fetchAssignments() }, [fetchAssignments])
useEffect(() => {
fetch('/api/ai-settings/ocr')
.then((r) => r.json())
.then((d: { ocrMode: string; ocrLanguages: string }) => {
setOcrMode(d.ocrMode)
setDefaultOcrLanguages(d.ocrLanguages)
})
.catch(() => {})
}, [])
const filtersActive = search !== '' || selectedTagIds.size > 0 const filtersActive = search !== '' || selectedTagIds.size > 0
const fetchRecursive = useCallback(() => { const fetchRecursive = useCallback(() => {
@@ -325,12 +343,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm"> <nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<a
href="/"
className="transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Libraries
</a>
<span style={{ color: 'var(--border)' }}>/</span>
<button <button
onClick={() => loadPath('')} onClick={() => loadPath('')}
className="transition-colors" className="transition-colors"
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }} style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
> >
Root {libraryName}
</button> </button>
{breadcrumbs.map((segment, i) => { {breadcrumbs.map((segment, i) => {
const isLast = i === breadcrumbs.length - 1 const isLast = i === breadcrumbs.length - 1
@@ -387,6 +413,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
entry={entry} entry={entry}
onOpen={handleEntry} onOpen={handleEntry}
onTag={handleTagEntry} onTag={handleTagEntry}
ocrMode={ocrMode}
defaultOcrLanguages={defaultOcrLanguages}
onAiTag={async (e) => { onAiTag={async (e) => {
const itemKey = itemKeyFor(e) const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
@@ -401,7 +429,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
fetchAssignments() fetchAssignments()
setFilterRefreshKey((k) => k + 1) setFilterRefreshKey((k) => k + 1)
}} }}
onExtractText={async (e) => { onExtractText={async (e, ocrLanguages) => {
if (e.type === 'directory') { if (e.type === 'directory') {
// Bulk extract for directory // Bulk extract for directory
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
@@ -420,7 +448,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const res = await fetch('/api/ai-tagging/extract-text', { const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }), body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }),
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
@@ -453,6 +481,31 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} }
} }
}} }}
onTranslate={async (e) => {
if (e.type === 'directory') {
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/translate-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, path: dirRel }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
} else {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
}
}}
onDelete={(e) => { onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' }) fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
@@ -493,10 +546,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
name={modal.name} name={modal.name}
itemKey={modal.itemKey} itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)} onClose={() => { setModal(null); setModalShowTags(false) }}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined} onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={modal.itemKey ? async () => { showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -517,10 +573,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
name={modal.name} name={modal.name}
itemKey={modal.itemKey} itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)} onClose={() => { setModal(null); setModalShowTags(false) }}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined} onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={async () => { showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -582,7 +641,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
) )
} }
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void> }) { function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate, ocrMode, defaultOcrLanguages }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry, ocrLanguages?: string) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void>; ocrMode?: string | null; defaultOcrLanguages?: string }) {
type ImgState = 'loading' | 'loaded' | 'error' type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState<ImgState>( const [imgState, setImgState] = useState<ImgState>(
entry.thumbnailUrl ? 'loading' : 'error' entry.thumbnailUrl ? 'loading' : 'error'
@@ -601,6 +660,10 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
const [textExtractError, setTextExtractError] = useState<string | null>(null) const [textExtractError, setTextExtractError] = useState<string | null>(null)
const [describing, setDescribing] = useState(false) const [describing, setDescribing] = useState(false)
const [describeError, setDescribeError] = useState<string | null>(null) const [describeError, setDescribeError] = useState<string | null>(null)
const [translating, setTranslating] = useState(false)
const [translateError, setTranslateError] = useState<string | null>(null)
const [showOcrPrompt, setShowOcrPrompt] = useState(false)
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
useEffect(() => { useEffect(() => {
if (!menuOpen) return if (!menuOpen) return
@@ -715,7 +778,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
</button> </button>
{/* Kebab menu — bottom-right, shown on hover */} {/* Kebab menu — bottom-right, shown on hover */}
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && ( {(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory')) || (onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText)) && (
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}> <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
<button <button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }} onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
@@ -790,16 +853,21 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
📝 Describe Folder 📝 Describe Folder
</button> </button>
)} )}
{onExtractText && entry.mediaType === 'image' && ( {onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setMenuOpen(false) if (ocrMode && ocrMode !== 'llm') {
setTextExtracting(true) setOcrLanguageInput('')
setTextExtractError(null) setShowOcrPrompt(true)
onExtractText(entry) } else {
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed')) setMenuOpen(false)
.finally(() => setTextExtracting(false)) setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}
}} }}
disabled={textExtracting} disabled={textExtracting}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50" className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
@@ -810,6 +878,57 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text 🔍 Extract Text
</button> </button>
)} )}
{onExtractText && entry.mediaType === 'image' && showOcrPrompt && (
<div className="px-4 py-2 flex flex-col gap-2" onClick={(e) => e.stopPropagation()}>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>OCR language</p>
<input
autoFocus
type="text"
value={ocrLanguageInput}
onChange={(e) => setOcrLanguageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') { setShowOcrPrompt(false) }
if (e.key === 'Enter') {
setShowOcrPrompt(false)
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry, ocrLanguageInput.trim() || undefined)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}
}}
placeholder={defaultOcrLanguages ?? 'eng'}
className="text-xs px-2 py-1 rounded-lg outline-none w-full"
style={{ backgroundColor: 'var(--background)', border: '1px solid var(--border)', color: 'var(--text-primary)' }}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
<div className="flex gap-2">
<button
onClick={() => {
setShowOcrPrompt(false)
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry, ocrLanguageInput.trim() || undefined)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
className="text-xs px-2 py-1 rounded-lg"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Extract
</button>
<button
onClick={() => setShowOcrPrompt(false)}
className="text-xs px-2 py-1"
style={{ color: 'var(--text-secondary)' }}
>
Cancel
</button>
</div>
</div>
)}
{onExtractText && entry.type === 'directory' && ( {onExtractText && entry.type === 'directory' && (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -830,6 +949,26 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text for Folder 🔍 Extract Text for Folder
</button> </button>
)} )}
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTranslating(true)
setTranslateError(null)
onTranslate(entry)
.catch((err) => setTranslateError(err instanceof Error ? err.message : 'Translation failed'))
.finally(() => setTranslating(false))
}}
disabled={translating}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
</button>
)}
{onRename && ( {onRename && (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -929,6 +1068,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
</div> </div>
)} )}
{/* Translation status overlay */}
{(translating || translateError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: translateError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: translateError ? '#fca5a5' : 'var(--text-secondary)' }}>
{translateError ?? 'Translating…'}
</span>
{translateError && (
<button
onClick={() => setTranslateError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Delete confirmation overlay */} {/* Delete confirmation overlay */}
{confirming && ( {confirming && (
<div <div

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import { useUserSettings } from '@/hooks/useUserSettings' import { useUserSettings } from '@/hooks/useUserSettings'
interface Props { interface Props {
@@ -14,18 +14,21 @@ interface Props {
onTagsChanged?: () => void onTagsChanged?: () => void
onAiTag?: () => Promise<void> onAiTag?: () => Promise<void>
context?: 'mixed' | 'movies' | 'tv' context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed' }: Props) { export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const settings = useUserSettings() const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const [aiTagging, setAiTagging] = useState(false) const showTags = showTagsProp ?? showTagsLocal
const [aiTagError, setAiTagError] = useState<string | null>(null) const setShowTags = onShowTagsChange ?? setShowTagsLocal
const [tagRefreshKey, setTagRefreshKey] = useState(0)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -45,93 +48,58 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen" className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Toolbar */} {/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}> <div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{itemKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
color: showTags ? '#fff' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags"
>
🏷
</button>
)}
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label="AI Tag this video"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? (
<span className="animate-spin" style={{ display: 'inline-block' }}></span>
) : '✨'}
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
</div>
{showTags ? ( {/* ── Video column ── */}
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
{/* Video */}
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative"> {/* Toolbar — scoped to this column's width */}
<div className="flex items-center justify-between px-3 py-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-sm truncate mr-2" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{itemKey && !showTags && (
<button
onClick={() => setShowTags(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
</div>
{/* Video area — single element, never remounts on panel toggle */}
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
<video <video
key={url} key={url}
src={url} src={url}
@@ -139,60 +107,18 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
autoPlay={autoPlay} autoPlay={autoPlay}
muted={muted} muted={muted}
loop={loop} loop={loop}
className="w-full h-full object-contain rounded-lg" playsInline
className="w-full h-full object-contain"
style={{ backgroundColor: '#000' }} style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/> />
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
{/* Tag panel */}
<div {/* Prev/Next — positioned relative to the full column height (incl. toolbar)
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4" so they align with ImageLightbox's buttons which span the full viewport */}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
</div>
</div>
) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
<video
key={url}
src={url}
controls
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full max-w-4xl object-contain rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
{onPrev && ( {onPrev && (
<button <button
onClick={(e) => { e.stopPropagation(); onPrev() }} onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70" className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous" aria-label="Previous"
> >
@@ -202,7 +128,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
{onNext && ( {onNext && (
<button <button
onClick={(e) => { e.stopPropagation(); onNext() }} onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70" className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next" aria-label="Next"
> >
@@ -210,7 +136,19 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
</button> </button>
)} )}
</div> </div>
)}
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
{showTags && (
<MediaTagPanel
itemKey={itemKey!}
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
/>
)}
</div>
</div> </div>
) )
} }

View File

@@ -2,7 +2,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Movie } from '@/types' import type { Movie } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
interface Props { interface Props {
@@ -14,9 +15,10 @@ interface Props {
onTagsChanged?: () => void onTagsChanged?: () => void
onDeleted: (movieId: string) => void onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void onMetadataRefreshed?: () => void
readOnly?: boolean
} }
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) { export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
@@ -32,15 +34,22 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
const [renameName, setRenameName] = useState('') const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null) const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false) const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
if (warnRefresh) { setWarnRefresh(false); return } if (warnRefresh) { setWarnRefresh(false); return }
if (editing) { setEditing(false); return } if (editing) { setEditing(false); return }
if (renaming) { setRenaming(false); return } if (renaming) { setRenaming(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose() onClose()
} }
} }
@@ -50,7 +59,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming]) }, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -132,7 +141,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
const handleStartRename = () => { const handleStartRename = () => {
setMenuOpen(false) setMenuOpen(false)
// movie.id is the encoded folder name
setRenameName(decodeURIComponent(movie.id)) setRenameName(decodeURIComponent(movie.id))
setRenameError(null) setRenameError(null)
setRenaming(true) setRenaming(true)
@@ -187,339 +195,387 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4" className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }} style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div {/* Outer flex — row on md+, col on mobile when panel open */}
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl" <div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close"
>
</button>
{/* Prev / Next buttons on the detail card */} {/* ── Left pane — relative container for floating controls ── */}
{onPrev && ( <div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
<button {/* Scrollable card area */}
onClick={onPrev} <div className="h-full overflow-y-auto flex items-center justify-center p-4">
className="absolute top-3 left-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" <div
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }} className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')} onClick={(e) => e.stopPropagation()}
aria-label="Previous movie"
> >
</button>
)}
{onNext && (
<button
onClick={onNext}
className="absolute top-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)', right: onPrev ? '3rem' : undefined }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Next movie"
>
</button>
)}
{/* Hero image */} {/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}> <div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{heroUrl ? ( {heroUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={heroUrl} src={heroUrl}
alt={movie.title} alt={movie.title}
className="w-full object-cover max-h-64" className="w-full object-cover max-h-64"
/> />
) : ( ) : (
<div className="h-40 flex items-center justify-center text-5xl">🎬</div> <div className="h-40 flex items-center justify-center text-5xl">🎬</div>
)} )}
</div> </div>
{/* Info */} {/* Info */}
<div className="p-5"> <div className="p-5">
{/* Title row with kebab menu */} {/* Title row with kebab menu */}
<div className="flex items-start gap-2 mb-1"> <div className="flex items-start gap-2 mb-1">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}> <h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{movie.title} {movie.title}
</h2> </h2>
{movie.year && ( {movie.year && (
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{movie.year} {movie.year}
</span> </span>
)} )}
{/* Kebab menu */} {/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}> {!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button <button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }} onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors" className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }} style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options" aria-label="More options"
> >
</button> </button>
{menuOpen && ( {menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={handleRefreshMetadata}
disabled={refreshing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button
onClick={handleStartEditing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit metadata
</button>
<button
onClick={handleStartRename}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete movie
</button>
</div>
)}
</div>}
</div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-3">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{editing ? (
<div className="flex flex-col gap-3 mb-4">
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
<input
type="number"
value={editForm.year}
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
<textarea
rows={3}
value={editForm.plot}
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
<input
type="text"
value={editForm.genres}
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleSaveMetadata}
disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
) : (
<>
{/* Meta row */}
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{movie.rating !== null && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{movie.rating.toFixed(1)}
</span>
)}
{movie.runtime !== null && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{movie.runtime} min
</span>
)}
{movie.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{g}
</span>
))}
</div>
)}
{movie.plot && (
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
{movie.plot}
</p>
)}
</>
)}
{/* NFO refresh warning */}
{warnRefresh && (
<div <div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max" className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
> >
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
Refreshing from NFO will overwrite your manual edits.
</p>
<button <button
onClick={handleRefreshMetadata} onClick={() => setWarnRefresh(false)}
disabled={refreshing} className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
> >
{refreshing ? 'Refreshing…' : 'Refresh metadata'} Cancel
</button> </button>
<button <button
onClick={handleStartEditing} onClick={doRefreshMetadata}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors" className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-primary)' }} style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
> >
Edit metadata Overwrite
</button>
<button
onClick={handleStartRename}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete movie
</button> </button>
</div> </div>
)} )}
</div>
</div>
{/* Rename inline input */} {/* Confirmation banner */}
{renaming && ( {confirming && (
<div className="flex flex-col gap-2 mb-3"> <div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this movie and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={handleConfirmDelete}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Assigned tags (read-only) above action buttons */}
{movie.item_key && (
<div className="mb-3">
<AssignedTagBadges itemKey={movie.item_key} refreshKey={tagRefreshKey} />
</div>
)}
{/* Action buttons row: Play + Download */}
<div className="flex gap-2"> <div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button <button
onClick={() => setRenaming(false)} onClick={() => setPlaying(true)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors" className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }} style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
> >
{renameSaving ? '…' : 'Rename'} <span></span>
Play
</button> </button>
</div> <a
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>} href={videoUrl}
</div> download
)} className="flex items-center justify-center px-3 py-2.5 rounded-lg text-sm font-medium transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
{editing ? ( onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
<div className="flex flex-col gap-3 mb-4"> onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
<div> onClick={(e) => e.stopPropagation()}
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label> title="Download"
<input aria-label="Download"
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
<input
type="number"
value={editForm.year}
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
<textarea
rows={3}
value={editForm.plot}
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
<input
type="text"
value={editForm.genres}
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
> >
Cancel
</button> </a>
<button
onClick={handleSaveMetadata}
disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div> </div>
</div> </div>
) : (
<>
{/* Meta row */}
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{movie.rating !== null && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{movie.rating.toFixed(1)}
</span>
)}
{movie.runtime !== null && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{movie.runtime} min
</span>
)}
{movie.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{g}
</span>
))}
</div>
)}
{movie.plot && (
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
{movie.plot}
</p>
)}
</>
)}
{/* NFO refresh warning */}
{warnRefresh && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
>
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
Refreshing from NFO will overwrite your manual edits.
</p>
<button
onClick={() => setWarnRefresh(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={doRefreshMetadata}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
>
Overwrite
</button>
</div>
)}
{/* Confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this movie and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={handleConfirmDelete}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Play button */}
<button
onClick={() => setPlaying(true)}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Play
</button>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={movie.item_key!} onTagsChanged={onTagsChanged} />
</div> </div>
</div>
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{movie.item_key && !showTagPanel && (
<button
onClick={() => setShowTagPanel(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && (
<MediaTagPanel
itemKey={movie.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div> </div>
</div> </div>
) )

View File

@@ -9,9 +9,10 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
export default function MoviesView({ libraryId }: Props) { export default function MoviesView({ libraryId, readOnly }: Props) {
const [movies, setMovies] = useState<Movie[]>([]) const [movies, setMovies] = useState<Movie[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -20,7 +21,9 @@ export default function MoviesView({ libraryId }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([]) const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
@@ -201,6 +204,7 @@ export default function MoviesView({ libraryId }: Props) {
<MovieDetailModal <MovieDetailModal
movie={selected} movie={selected}
libraryId={libraryId} libraryId={libraryId}
readOnly={readOnly}
onClose={() => setSelectedIndex(null)} onClose={() => setSelectedIndex(null)}
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined} onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}

View File

@@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import type { Tag, TagCategory } from '@/types'
interface Props {
itemKey: string
refreshKey?: number
}
export default function AssignedTagBadges({ itemKey, refreshKey }: Props) {
const [tags, setTags] = useState<Tag[]>([])
const [categories, setCategories] = useState<TagCategory[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { tags: Tag[]; categories: TagCategory[] }) => {
setTags(data.tags ?? [])
setCategories(data.categories ?? [])
})
.catch(() => {})
.finally(() => setLoading(false))
}, [itemKey, refreshKey])
if (loading) {
return (
<div className="flex flex-wrap gap-1.5">
{[60, 80, 50].map((w) => (
<div
key={w}
className="h-5 rounded-full animate-pulse"
style={{ width: w, backgroundColor: 'var(--border)' }}
/>
))}
</div>
)
}
if (tags.length === 0) return null
const catMap = new Map(categories.map((c) => [c.id, c.name]))
// Group by category
const grouped = new Map<string | null, Tag[]>()
for (const tag of tags) {
const key = tag.categoryId ?? null
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key)!.push(tag)
}
return (
<div className="flex flex-wrap gap-1.5">
{Array.from(grouped.entries()).map(([catId, catTags]) => {
const catName = catId ? catMap.get(catId) : null
return catTags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{catName && (
<span style={{ color: 'var(--text-secondary)' }}>{catName}:</span>
)}
{tag.name}
</span>
))
})}
</div>
)
}

View File

@@ -0,0 +1,138 @@
'use client'
import { useState } from 'react'
import TagSelector from './TagSelector'
interface Props {
itemKey: string
onHide: () => void
onClose: () => void
onTagsChanged?: () => void
externalRefreshKey?: number
onAiTag?: () => Promise<void>
disabled?: boolean
disabledMessage?: string
readOnly?: boolean
children?: React.ReactNode
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
export default function MediaTagPanel({
itemKey,
onHide,
onClose,
onTagsChanged,
externalRefreshKey = 0,
onAiTag,
disabled,
disabledMessage,
readOnly,
children,
}: Props) {
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
const handleAiTag = async () => {
if (!onAiTag) return
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setInternalRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}
return (
<div
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide | ✕ close */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={onHide}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
{children}
{disabled || !itemKey ? (
disabledMessage ? (
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
{disabledMessage}
</p>
) : null
) : (
<>
{/* Tags section heading + optional AI button */}
<div className="flex items-center justify-between mt-4 mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{onAiTag && (
<button
onClick={handleAiTag}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
</div>
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
<TagSelector
itemKey={itemKey}
onTagsChanged={onTagsChanged}
refreshKey={internalRefreshKey + externalRefreshKey}
hideDescription
readOnly={readOnly}
/>
</>
)}
</div>
</div>
)
}

View File

@@ -8,6 +8,8 @@ interface Props {
itemKey: string itemKey: string
onTagsChanged?: () => void onTagsChanged?: () => void
refreshKey?: number refreshKey?: number
hideDescription?: boolean
readOnly?: boolean
} }
interface AllTags { interface AllTags {
@@ -15,7 +17,7 @@ interface AllTags {
tags: Tag[] tags: Tag[]
} }
export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) { export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [], tags: [],
categories: [], categories: [],
@@ -210,37 +212,39 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* AI description */} {/* AI description */}
<div className="flex flex-col gap-1"> {!hideDescription && (
{aiDescription && ( <div className="flex flex-col gap-1">
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}> {aiDescription && (
{aiDescription} <p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
</p> {aiDescription}
)} </p>
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!generatingDesc) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)} )}
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!generatingDesc) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)}
</div>
</div> </div>
</div> )}
{/* Assigned tags grouped by category */} {/* Assigned tags grouped by category */}
{assigned.tags.length > 0 && ( {assigned.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -274,23 +278,25 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
style={{ backgroundColor: 'var(--surface-hover)' }} style={{ backgroundColor: 'var(--surface-hover)' }}
> >
{tag.name} {tag.name}
<button {!readOnly && (
onClick={() => toggleTag(tag)} <button
className="ml-0.5 leading-none transition-colors" onClick={() => toggleTag(tag)}
style={{ color: 'var(--text-secondary)' }} className="ml-0.5 leading-none transition-colors"
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')} style={{ color: 'var(--text-secondary)' }}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
aria-label={`Remove tag ${tag.name}`} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
> aria-label={`Remove tag ${tag.name}`}
>
</button>
</button>
)}
</span> </span>
))} ))}
</span> </span>
) )
})} })}
{ungrouped.map((tag) => ( {ungrouped.map((tag) => (
<TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} /> <TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} />
))} ))}
</> </>
) )
@@ -299,7 +305,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
)} )}
{/* Tag picker grouped by category */} {/* Tag picker grouped by category */}
<div className="flex flex-col gap-2"> {!readOnly && <div className="flex flex-col gap-2">
{all.categories.map((category) => { {all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id) const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? '' const search = categorySearches[category.id] ?? ''
@@ -528,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
</button> </button>
)} )}
</div> </div>
</div> </div>}
</div> </div>
) )
} }

View File

@@ -9,9 +9,10 @@ interface Props {
onTag?: () => void onTag?: () => void
onDelete?: () => void onDelete?: () => void
onRename?: (newName: string) => Promise<boolean> onRename?: (newName: string) => Promise<boolean>
downloadUrl?: string
} }
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename }: Props) { export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename, downloadUrl }: Props) {
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
@@ -79,7 +80,7 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
</button> </button>
)} )}
{/* Kebab menu */} {/* Kebab menu */}
{onDelete && ( {(onDelete || downloadUrl) && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}> <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button <button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }} onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
@@ -94,6 +95,19 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max" className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> >
{downloadUrl && (
<a
href={downloadUrl}
download
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Download
</a>
)}
{onRename && ( {onRename && (
<button <button
onClick={(e) => { onClick={(e) => {

View File

@@ -5,18 +5,21 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import EpisodeCard from './EpisodeCard' import EpisodeCard from './EpisodeCard'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media' import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
type ViewLevel = 'series' | 'seasons' | 'episodes' type ViewLevel = 'series' | 'seasons' | 'episodes'
export default function TvView({ libraryId }: Props) { export default function TvView({ libraryId, readOnly }: Props) {
const [view, setView] = useState<ViewLevel>('series') const [view, setView] = useState<ViewLevel>('series')
const [series, setSeries] = useState<TvSeries[]>([]) const [series, setSeries] = useState<TvSeries[]>([])
const [seasons, setSeasons] = useState<TvSeason[]>([]) const [seasons, setSeasons] = useState<TvSeason[]>([])
@@ -31,7 +34,11 @@ export default function TvView({ libraryId }: Props) {
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({}) const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedSeriesIndex, setSelectedSeriesIndex] = useState<number | null>(null)
const [selectedSeasonIndex, setSelectedSeasonIndex] = useState<number | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null) const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
@@ -48,7 +55,12 @@ export default function TvView({ libraryId }: Props) {
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([]) const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagPanelItemKey, setTagPanelItemKey] = useState<string | null>(null)
const [tagPanelDisabled, setTagPanelDisabled] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -87,6 +99,7 @@ export default function TvView({ libraryId }: Props) {
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags]) useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
const openSeries = (s: TvSeries) => { const openSeries = (s: TvSeries) => {
setSelectedSeriesIndex(filteredSeries.indexOf(s))
setSelectedSeries(s) setSelectedSeries(s)
setView('seasons') setView('seasons')
setLoading(true) setLoading(true)
@@ -96,18 +109,17 @@ export default function TvView({ libraryId }: Props) {
.then((data: TvSeason[]) => { .then((data: TvSeason[]) => {
setSeasons(data) setSeasons(data)
setLoading(false) setLoading(false)
// Flat series: a single synthetic season (id='.') means episodes live
// directly in the series folder — skip the seasons screen automatically.
if (data.length === 1 && data[0].id === '.') {
openSeason(data[0])
}
}) })
.catch(() => { setError('Failed to load seasons'); setLoading(false) }) .catch(() => { setError('Failed to load seasons'); setLoading(false) })
} }
const openSeason = (season: TvSeason) => { const openSeason = (season: TvSeason, index?: number) => {
setSelectedSeasonIndex(index ?? seasons.indexOf(season))
setSelectedSeason(season) setSelectedSeason(season)
setView('episodes') setView('episodes')
if (showTagPanel) {
setTagPanelDisabled(true)
}
setLoading(true) setLoading(true)
setError(null) setError(null)
fetch( fetch(
@@ -134,14 +146,24 @@ export default function TvView({ libraryId }: Props) {
setView('series') setView('series')
setSelectedSeries(null) setSelectedSeries(null)
setSelectedSeason(null) setSelectedSeason(null)
setSelectedSeriesIndex(null)
setSelectedSeasonIndex(null)
setMenuOpen(false) setMenuOpen(false)
setConfirming(false) setConfirming(false)
setShowTagPanel(false)
setTagPanelItemKey(null)
setTagPanelDisabled(false)
} }
const goToSeasons = () => { const goToSeasons = () => {
setView('seasons') setView('seasons')
setSelectedSeason(null) setSelectedSeason(null)
setSelectedSeasonIndex(null)
setConfirming(false) setConfirming(false)
if (showTagPanel && selectedSeries?.item_key) {
setTagPanelItemKey(selectedSeries.item_key)
setTagPanelDisabled(false)
}
} }
const handleDeleteSeries = () => { const handleDeleteSeries = () => {
@@ -164,11 +186,18 @@ export default function TvView({ libraryId }: Props) {
setRefreshingMeta(true) setRefreshingMeta(true)
setWarnRefresh(false) setWarnRefresh(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}` const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
const currentId = selectedSeries.id
fetch( fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`, `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`,
{ method: 'POST' } { method: 'POST' }
) )
.then(() => fetchSeries()) .then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`))
.then((r) => r.json())
.then((data: TvSeries[]) => {
setSeries(data)
const updated = data.find((s) => s.id === currentId)
if (updated) setSelectedSeries(updated)
})
.finally(() => setRefreshingMeta(false)) .finally(() => setRefreshingMeta(false))
} }
@@ -312,6 +341,40 @@ export default function TvView({ libraryId }: Props) {
} }
} }
// Escape key + body scroll lock when modal is open
useEffect(() => {
if (view === 'series') return
const handleKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (menuOpen) { setMenuOpen(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
if (view === 'episodes') {
setView('seasons')
setSelectedSeason(null)
setConfirming(false)
if (selectedSeries?.item_key) {
setTagPanelItemKey(selectedSeries.item_key)
setTagPanelDisabled(false)
}
return
}
setView('series')
setSelectedSeries(null)
setSelectedSeason(null)
setMenuOpen(false)
setConfirming(false)
setShowTagPanel(false)
setTagPanelItemKey(null)
setTagPanelDisabled(false)
}
document.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [view, menuOpen, showTagPanel, selectedSeries])
const filtersActive = search !== '' || selectedTagIds.size > 0 const filtersActive = search !== '' || selectedTagIds.size > 0
const filteredSeries = series.filter((s) => { const filteredSeries = series.filter((s) => {
@@ -336,6 +399,28 @@ export default function TvView({ libraryId }: Props) {
return true return true
}) })
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
useEffect(() => {
if (view === 'series') return
const handleArrowKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex > 0)
openSeries(filteredSeries[selectedSeriesIndex - 1])
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex > 0)
openSeason(seasons[selectedSeasonIndex - 1], selectedSeasonIndex - 1)
}
if (e.key === 'ArrowRight') {
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1)
openSeries(filteredSeries[selectedSeriesIndex + 1])
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1)
openSeason(seasons[selectedSeasonIndex + 1], selectedSeasonIndex + 1)
}
}
document.addEventListener('keydown', handleArrowKey)
return () => document.removeEventListener('keydown', handleArrowKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, selectedSeriesIndex, selectedSeasonIndex, filteredSeries, seasons])
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
if (playingEpisode && playingEpisodeIndex !== null) { if (playingEpisode && playingEpisodeIndex !== null) {
@@ -350,6 +435,7 @@ export default function TvView({ libraryId }: Props) {
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined} onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
context="tv" context="tv"
readOnly={readOnly}
/> />
) )
} }
@@ -502,9 +588,76 @@ export default function TvView({ libraryId }: Props) {
)} )}
</div> </div>
</div> </div>
{tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div>
<button
onClick={() => setTagPanel(null)}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label="Close"
>
</button>
</div>
<div className="px-5 py-4">
<TagSelector
itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/>
</div>
</div>
</div>
)}
</> </>
)} )}
{(view === 'seasons' || view === 'episodes') && (
<div
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
>
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
<div className="flex-1 min-h-0 min-w-0 relative" onClick={goToSeries}>
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{view === 'episodes' && (
<div className="flex items-center gap-2 px-5 py-3 flex-shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={(e) => { e.stopPropagation(); goToSeasons() }}
className="text-sm transition-colors hover:underline"
style={{ color: 'var(--accent)' }}
>
{selectedSeries?.title}
</button>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>·</span>
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{selectedSeason?.title}
</span>
</div>
)}
{view === 'seasons' && selectedSeries && ( {view === 'seasons' && selectedSeries && (
<div> <div>
{/* Series info header */} {/* Series info header */}
@@ -682,6 +835,11 @@ export default function TvView({ libraryId }: Props) {
{selectedSeries.plot && ( {selectedSeries.plot && (
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p> <p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
)} )}
{selectedSeries.item_key && (
<div className="mt-2">
<AssignedTagBadges itemKey={selectedSeries.item_key} refreshKey={tagRefreshKey} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -756,7 +914,7 @@ export default function TvView({ libraryId }: Props) {
{seasons.map((season) => ( {seasons.map((season) => (
<button <button
key={season.id} key={season.id}
onClick={() => openSeason(season)} onClick={() => openSeason(season, seasons.indexOf(season))}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
@@ -792,7 +950,7 @@ export default function TvView({ libraryId }: Props) {
)} )}
{view === 'episodes' && selectedSeason && ( {view === 'episodes' && selectedSeason && (
<div> <div className="p-4">
{loading ? ( {loading ? (
<EpisodeLoadingGrid /> <EpisodeLoadingGrid />
) : error ? ( ) : error ? (
@@ -808,7 +966,8 @@ export default function TvView({ libraryId }: Props) {
key={ep.id} key={ep.id}
episode={ep} episode={ep}
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))} onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })} onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }}
downloadUrl={`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`}
onDelete={() => { onDelete={() => {
fetch( fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`, `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
@@ -838,42 +997,91 @@ export default function TvView({ libraryId }: Props) {
)} )}
</div> </div>
)} )}
{tagPanel && ( </div>
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div> </div>
<button
onClick={() => setTagPanel(null)} {/* Floating controls — tag + close */}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors" <div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} {view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')} <button
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')} onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
aria-label="Close" className={smallBtn}
> style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
</button> onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button
onClick={goToSeries}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
{/* Prev — series in seasons view, season in episodes view */}
{(view === 'seasons'
? selectedSeriesIndex !== null && selectedSeriesIndex > 0
: selectedSeasonIndex !== null && selectedSeasonIndex > 0) && (
<button
onClick={(e) => {
e.stopPropagation()
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! - 1])
else openSeason(seasons[selectedSeasonIndex! - 1], selectedSeasonIndex! - 1)
}}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{/* Next — series in seasons view, season in episodes view */}
{(view === 'seasons'
? selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1
: selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1) && (
<button
onClick={(e) => {
e.stopPropagation()
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! + 1])
else openSeason(seasons[selectedSeasonIndex! + 1], selectedSeasonIndex! + 1)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
<div className="px-5 py-4">
<TagSelector {/* Right tag panel */}
itemKey={tagPanel.itemKey} {showTagPanel && (
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} <MediaTagPanel
itemKey={tagPanelItemKey ?? ''}
onHide={() => setShowTagPanel(false)}
onClose={goToSeries}
onTagsChanged={() => {
setTagRefreshKey((k) => k + 1)
setFilterRefreshKey((k) => k + 1)
fetchAssignments()
fetchSeriesEpisodeTags()
}}
externalRefreshKey={tagRefreshKey}
disabled={tagPanelDisabled}
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
readOnly={readOnly}
/> />
</div> )}
</div> </div>
</div> </div>
)} )}

View File

@@ -34,6 +34,7 @@ interface AiJobRow {
started_at: number | null started_at: number | null
completed_at: number | null completed_at: number | null
item_title: string | null item_title: string | null
payload: string | null
} }
function rowToJob(row: AiJobRow): AiJob { function rowToJob(row: AiJobRow): AiJob {
@@ -75,6 +76,7 @@ export function enqueueJob(
jobType: AiJobType, jobType: AiJobType,
libraryId: string, libraryId: string,
sourceLanguage?: string, sourceLanguage?: string,
payload?: Record<string, string>,
): string { ): string {
const db = getDb() const db = getDb()
@@ -96,9 +98,9 @@ export function enqueueJob(
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
db.prepare( db.prepare(
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title) `INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)` VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title) ).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
// Wake the processor // Wake the processor
wakeProcessor() wakeProcessor()
@@ -251,13 +253,14 @@ async function processNextJob(): Promise<boolean> {
// Extract sourceLanguage for translate jobs (stored in error field at enqueue) // Extract sourceLanguage for translate jobs (stored in error field at enqueue)
const sourceLanguage = row.job_type === 'translate' ? row.error : null const sourceLanguage = row.job_type === 'translate' ? row.error : null
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
db.prepare( db.prepare(
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?" "UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
).run(now, row.id) ).run(now, row.id)
try { try {
console.log(`[ai-jobs] Processing job ${row.id}: ${row.job_type} for "${row.item_key}"`)
switch (row.job_type) { switch (row.job_type) {
case 'tag': case 'tag':
await tagSingleItem(row.item_key) await tagSingleItem(row.item_key)
@@ -266,358 +269,7 @@ async function processNextJob(): Promise<boolean> {
await generateItemDescription(row.item_key) await generateItemDescription(row.item_key)
break break
case 'extract': case 'extract':
await extractItemText(row.item_key) await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
break
case 'translate':
await translateItemText(row.item_key, sourceLanguage || undefined)
break
}
db.prepare(
"UPDATE ai_jobs SET status = 'completed', completed_at = ? WHERE id = ?"
).run(Date.now(), row.id)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
const attempt = row.attempt + 1
if (attempt < row.max_retries) {
// Re-queue for retry
db.prepare(
"UPDATE ai_jobs SET status = 'queued', attempt = ?, error = ?, started_at = NULL WHERE id = ?"
).run(attempt, errorMessage, row.id)
} else {
// Final failure
db.prepare(
"UPDATE ai_jobs SET status = 'failed', attempt = ?, error = ?, completed_at = ? WHERE id = ?"
).run(attempt, errorMessage, Date.now(), row.id)
}
console.warn(
`[ai-jobs] Job ${row.id} (${row.job_type} for "${row.item_key}") failed (attempt ${attempt}/${row.max_retries}):`,
errorMessage
)
}
return true
}
async function runProcessor(): Promise<void> {
if (processorRunning) return
processorRunning = true
console.log('[ai-jobs] Processor started')
try {
while (true) {
const hadWork = await processNextJob()
if (!hadWork) {
// Wait for a wake signal or timeout after 60s (then check again for safety)
await new Promise<void>((resolve) => {
processorWake = resolve
setTimeout(() => {
processorWake = null
resolve()
}, 60_000)
})
processorWake = null
}
}
} catch (err) {
console.error('[ai-jobs] Processor crashed:', err)
} finally {
processorRunning = false
console.log('[ai-jobs] Processor stopped')
}
}
/**
* Initialize the job processor. Called on server startup.
* Resets any jobs stuck in 'running' state (from a previous crash) back to 'queued'.
*/
export function initJobProcessor(): void {
const db = getDb()
const result = db
.prepare("UPDATE ai_jobs SET status = 'queued', started_at = NULL WHERE status = 'running'")
.run()
if (result.changes > 0) {
console.log(`[ai-jobs] Reset ${result.changes} stuck running job(s) to queued`)
}
// Check if there are any queued jobs and start the processor
const pending = db
.prepare("SELECT COUNT(*) as count FROM ai_jobs WHERE status = 'queued'")
.get() as { count: number }
if (pending.count > 0) {
runProcessor()
}
}
import crypto from 'crypto'
import { getDb } from './db'
import { getAiMaxRetries } from './app-settings'
import { tagSingleItem, generateItemDescription, extractItemText, translateItemText } from './ai-tagger'
export type AiJobType = 'tag' | 'describe' | 'extract' | 'translate'
export type AiJobStatus = 'queued' | 'running' | 'completed' | 'failed'
export interface AiJob {
id: string
itemKey: string
libraryId: string
jobType: AiJobType
status: AiJobStatus
error: string | null
attempt: number
maxRetries: number
createdAt: number
startedAt: number | null
completedAt: number | null
itemTitle: string | null
}
interface AiJobRow {
id: string
item_key: string
library_id: string
job_type: string
status: string
error: string | null
attempt: number
max_retries: number
created_at: number
started_at: number | null
completed_at: number | null
item_title: string | null
}
function rowToJob(row: AiJobRow): AiJob {
return {
id: row.id,
itemKey: row.item_key,
libraryId: row.library_id,
jobType: row.job_type as AiJobType,
status: row.status as AiJobStatus,
error: row.error,
attempt: row.attempt,
maxRetries: row.max_retries,
createdAt: row.created_at,
startedAt: row.started_at,
completedAt: row.completed_at,
itemTitle: row.item_title,
}
}
/**
* Look up the title of a media item for display purposes.
*/
function resolveItemTitle(itemKey: string): string | null {
const db = getDb()
const row = db
.prepare('SELECT title FROM media_items WHERE item_key = ?')
.get(itemKey) as { title: string | null } | undefined
return row?.title ?? null
}
// ─── Enqueue ─────────────────────────────────────────────────────────────────
/**
* Enqueue an AI job. Deduplicates: if a queued/running job with the same
* item_key + job_type already exists, returns its ID instead.
*/
export function enqueueJob(
itemKey: string,
jobType: AiJobType,
libraryId: string,
sourceLanguage?: string,
): string {
const db = getDb()
// Deduplication: check for existing queued/running job
const existing = db
.prepare(
`SELECT id FROM ai_jobs
WHERE item_key = ? AND job_type = ? AND status IN ('queued', 'running')`
)
.get(itemKey, jobType) as { id: string } | undefined
if (existing) return existing.id
const id = crypto.randomUUID()
const maxRetries = getAiMaxRetries()
const title = resolveItemTitle(itemKey)
// Store sourceLanguage in the error field temporarily for translate jobs
// (it's null at creation, so we repurpose it briefly — cleared when the job runs)
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
db.prepare(
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
// Wake the processor
wakeProcessor()
return id
}
/**
* Enqueue jobs for all media items in a directory (for bulk operations).
* Returns the list of job IDs created.
*/
export function enqueueBulkJobs(
libraryId: string,
dirPath: string,
jobType: AiJobType,
itemTypeFilter?: string,
extFilter?: Set<string>,
): string[] {
const db = getDb()
const prefix = dirPath
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
: `${libraryId}:mixed_file:`
const items = db
.prepare('SELECT item_key, item_type, file_path FROM media_items WHERE item_key LIKE ? AND item_type = ?')
.all(`${prefix}%`, itemTypeFilter ?? 'mixed_file') as Array<{ item_key: string; item_type: string; file_path: string | null }>
const path = require('path')
const jobIds: string[] = []
for (const item of items) {
if (!item.file_path) continue
if (extFilter) {
const ext = path.extname(item.file_path).toLowerCase()
if (!extFilter.has(ext)) continue
}
const jobId = enqueueJob(item.item_key, jobType, libraryId)
jobIds.push(jobId)
}
return jobIds
}
// ─── Query ───────────────────────────────────────────────────────────────────
export function getJobQueue(): AiJob[] {
const db = getDb()
const rows = db
.prepare(
`SELECT * FROM ai_jobs
WHERE status IN ('running', 'queued')
ORDER BY
CASE status WHEN 'running' THEN 0 ELSE 1 END,
created_at ASC`
)
.all() as AiJobRow[]
return rows.map(rowToJob)
}
export function getJobHistory(limit = 50): AiJob[] {
const db = getDb()
const rows = db
.prepare(
`SELECT * FROM ai_jobs
WHERE status IN ('completed', 'failed')
ORDER BY completed_at DESC
LIMIT ?`
)
.all(limit) as AiJobRow[]
return rows.map(rowToJob)
}
export function getJob(jobId: string): AiJob | null {
const db = getDb()
const row = db
.prepare('SELECT * FROM ai_jobs WHERE id = ?')
.get(jobId) as AiJobRow | undefined
return row ? rowToJob(row) : null
}
// ─── Actions ─────────────────────────────────────────────────────────────────
export function retryJob(jobId: string): boolean {
const db = getDb()
const result = db
.prepare(
`UPDATE ai_jobs SET status = 'queued', error = NULL, attempt = 0, started_at = NULL, completed_at = NULL
WHERE id = ? AND status = 'failed'`
)
.run(jobId)
if (result.changes > 0) {
wakeProcessor()
return true
}
return false
}
export function cancelJob(jobId: string): boolean {
const db = getDb()
const result = db
.prepare("DELETE FROM ai_jobs WHERE id = ? AND status = 'queued'")
.run(jobId)
return result.changes > 0
}
export function cancelAllQueued(): number {
const db = getDb()
const result = db
.prepare("DELETE FROM ai_jobs WHERE status = 'queued'")
.run()
return result.changes
}
export function clearJobHistory(): number {
const db = getDb()
const result = db
.prepare("DELETE FROM ai_jobs WHERE status IN ('completed', 'failed')")
.run()
return result.changes
}
// ─── Processor ───────────────────────────────────────────────────────────────
let processorRunning = false
let processorWake: (() => void) | null = null
function wakeProcessor(): void {
if (processorWake) {
processorWake()
} else if (!processorRunning) {
runProcessor()
}
}
async function processNextJob(): Promise<boolean> {
const db = getDb()
const row = db
.prepare(
`SELECT * FROM ai_jobs
WHERE status = 'queued'
ORDER BY created_at ASC
LIMIT 1`
)
.get() as AiJobRow | undefined
if (!row) return false
const now = Date.now()
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
const sourceLanguage = row.job_type === 'translate' ? row.error : null
db.prepare(
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
).run(now, row.id)
try {
switch (row.job_type) {
case 'tag':
await tagSingleItem(row.item_key)
break
case 'describe':
await generateItemDescription(row.item_key)
break
case 'extract':
await extractItemText(row.item_key)
break break
case 'translate': case 'translate':
await translateItemText(row.item_key, sourceLanguage || undefined) await translateItemText(row.item_key, sourceLanguage || undefined)

View File

@@ -4,7 +4,7 @@ import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db' import { getDb } from './db'
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings' import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags' import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getAiImagePath, getVideoFramePaths } from './thumbnails' import { getAiImagePath, getOcrImagePath, getVideoFramePaths } from './thumbnails'
import { findFile } from './media-utils' import { findFile } from './media-utils'
import { getLibrary, resolveLibraryRoot } from './libraries' import { getLibrary, resolveLibraryRoot } from './libraries'
@@ -171,7 +171,8 @@ async function callVisionApi(
endpoint: string, endpoint: string,
model: string, model: string,
base64Images: string[], base64Images: string[],
systemPrompt: string systemPrompt: string,
maxTokens: number,
): Promise<string[]> { ): Promise<string[]> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions' const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -195,7 +196,7 @@ async function callVisionApi(
})), })),
}, },
], ],
max_tokens: 8192, max_tokens: maxTokens,
temperature: 0.1, temperature: 0.1,
}), }),
}) })
@@ -338,7 +339,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
customInstruction: config.promptTagger || undefined, customInstruction: config.promptTagger || undefined,
}) })
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext) const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
const validIds = suggestedIds.filter((id) => validTagIds.has(id)) const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) { for (const tagId of validIds) {
@@ -359,7 +360,8 @@ async function callVisionApiText(
endpoint: string, endpoint: string,
model: string, model: string,
base64Images: string[], base64Images: string[],
systemPrompt: string systemPrompt: string,
maxTokens: number,
): Promise<string> { ): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions' const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -383,7 +385,7 @@ async function callVisionApiText(
})), })),
}, },
], ],
max_tokens: 8192, max_tokens: maxTokens,
temperature: 0.1, temperature: 0.1,
}), }),
}) })
@@ -410,7 +412,8 @@ async function callChatApiText(
endpoint: string, endpoint: string,
model: string, model: string,
systemPrompt: string, systemPrompt: string,
userMessage: string userMessage: string,
maxTokens: number,
): Promise<string> { ): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions' const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -428,7 +431,7 @@ async function callChatApiText(
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }, { role: 'user', content: userMessage },
], ],
max_tokens: 8192, max_tokens: maxTokens,
temperature: 0.1, temperature: 0.1,
}), }),
}) })
@@ -496,7 +499,7 @@ export async function generateItemDescription(itemKey: string): Promise<string>
: '' : ''
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}` const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt) const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe)
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey) db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
@@ -506,36 +509,38 @@ export async function generateItemDescription(itemKey: string): Promise<string>
// ─── Text extraction ───────────────────────────────────────────────────────── // ─── Text extraction ─────────────────────────────────────────────────────────
/** /**
* Extract text (OCR) from an image using the vision model. * Run Tesseract OCR on a preprocessed image file.
* Only works for images in mixed libraries. * Returns the extracted text and a mean confidence score (0100).
* If the extracted text is not in the user's preferred language, auto-translates it. * A confidence of 0 with empty text means no recognisable text was found.
* Returns { extractedText, translatedText }.
*/ */
/** async function extractWithTesseract(
* Parse a structured extraction response from the AI. imagePath: string,
* Returns null if the response cannot be parsed as valid JSON with the expected shape. languages: string,
*/ ): Promise<{ text: string; confidence: number }> {
function parseStructuredExtraction(raw: string): { text: string; needsTranslation: boolean } | null { const { createWorker } = await import('tesseract.js')
const jsonMatch = raw.match(/\{[\s\S]*\}/) const workerPath = path.join(process.cwd(), 'node_modules/tesseract.js/src/worker-script/node/index.js')
if (!jsonMatch) return null const worker = await createWorker(languages, 1, { workerPath })
try { try {
const parsed = JSON.parse(jsonMatch[0]) const { data } = await worker.recognize(imagePath)
if (typeof parsed.text === 'string' && typeof parsed.needsTranslation === 'boolean') { return { text: data.text.trim(), confidence: data.confidence }
return { text: parsed.text, needsTranslation: parsed.needsTranslation } } finally {
} await worker.terminate()
} catch {
// fall through
} }
return null
} }
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> { /**
* Extract text (OCR) from an image using the configured OCR mode:
* - hybrid: try Tesseract first; fall back to LLM if confidence is below threshold
* - tesseract: local Tesseract only, no LLM call
* - llm: LLM vision API only (original behaviour)
*
* Only works for images in mixed libraries.
* Translation is not performed automatically — call translateItemText() separately.
* Returns { extractedText, translatedText } where translatedText is always null.
*/
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId) const config = getEffectiveAiConfig(libraryId)
const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const db = getDb() const db = getDb()
const item = db const item = db
@@ -562,72 +567,51 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' }) throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
} }
const { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
const ocrMode = ocrModeOverride ?? configOcrMode
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
// ── Tesseract path ────────────────────────────────────────────────────────
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {
const ocrImagePath = await getOcrImagePath(resolvedMedia.path, libraryId)
const { text, confidence } = await extractWithTesseract(ocrImagePath, ocrLanguages)
const useTesseractResult = ocrMode === 'tesseract' || confidence >= ocrConfidenceThreshold
if (useTesseractResult) {
console.log(`[ocr] tesseract used for ${itemKey} (confidence=${confidence}, mode=${ocrMode})`)
if (!text) {
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
return { extractedText: '', translatedText: null }
}
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(text, itemKey)
return { extractedText: text, translatedText: null }
}
console.log(`[ocr] tesseract confidence too low (${confidence} < ${ocrConfidenceThreshold}), falling back to LLM for ${itemKey}`)
}
// ── LLM vision path ───────────────────────────────────────────────────────
const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId) const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')] const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
const preferredLanguage = getPreferredLanguage()
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : '' const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
// When a preferred language is configured, ask the AI to also flag whether translation is needed. console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`)
// This avoids a separate translation API call for text already in the target language. const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
let systemPrompt: string
if (preferredLanguage) {
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction}
Respond ONLY with a valid JSON object — no markdown, no explanation:
{"needsTranslation": boolean, "text": "extracted text"}
Rules:
- Set needsTranslation to true if the text is NOT already written in ${preferredLanguage}.
- Set needsTranslation to false if the text IS in ${preferredLanguage}, or if there is no text.
- If there is no text in the image, use exactly: {"needsTranslation": false, "text": "[NO TEXT]"}`
} else {
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
}
const rawResponse = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
// Parse the response — structured JSON when a preferred language is set, plain text otherwise
let extractedText: string
let needsTranslation: boolean
if (preferredLanguage) {
const parsed = parseStructuredExtraction(rawResponse)
if (parsed) {
extractedText = parsed.text
needsTranslation = parsed.needsTranslation
} else {
// Malformed JSON fallback: treat raw response as plain text and attempt translation
extractedText = rawResponse
needsTranslation = true
}
} else {
extractedText = rawResponse
needsTranslation = false
}
if (!extractedText || extractedText === '[NO TEXT]') { if (!extractedText || extractedText === '[NO TEXT]') {
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey) db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
return { extractedText: '', translatedText: null } return { extractedText: '', translatedText: null }
} }
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(extractedText, itemKey) db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(extractedText, itemKey)
// Only translate if the extraction step determined the text is not already in the preferred language return { extractedText, translatedText: null }
let translatedText: string | null = null
if (preferredLanguage && needsTranslation) {
const translateModel = config.modelTranslate || config.model
try {
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
} catch (err) {
console.warn(`[ai-tagger] Translation failed for "${itemKey}":`, err instanceof Error ? err.message : err)
}
}
return { extractedText, translatedText }
} }
/** /**
@@ -650,15 +634,13 @@ export async function translateItemText(itemKey: string, sourceLanguage?: string
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' }) throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
} }
if (!row.extracted_text) { if (!row.extracted_text) {
throw Object.assign(new Error('No extracted text to translate'), { code: 'NO_TEXT' }) return null
} }
const preferredLanguage = getPreferredLanguage() const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) { if (!preferredLanguage) return null
throw Object.assign(new Error('No preferred language configured'), { code: 'NO_LANGUAGE' })
}
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, sourceLanguage) const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage)
if (translatedText) { if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey) db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
} }
@@ -674,6 +656,14 @@ export function updateExtractedText(itemKey: string, text: string): void {
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey) db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
} }
/**
* Update the ai_description of an item.
*/
export function updateAiDescription(itemKey: string, description: string): void {
const db = getDb()
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
}
/** /**
* Translate text to a target language using the chat API. * Translate text to a target language using the chat API.
* Returns null if the text is already in the target language. * Returns null if the text is already in the target language.
@@ -684,6 +674,7 @@ async function translateText(
text: string, text: string,
targetLanguage: string, targetLanguage: string,
customInstruction = '', customInstruction = '',
maxTokens = 8192,
sourceLanguage?: string, sourceLanguage?: string,
): Promise<string | null> { ): Promise<string | null> {
let systemPrompt: string let systemPrompt: string
@@ -693,7 +684,7 @@ async function translateText(
systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}` systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
} }
const result = await callChatApiText(endpoint, model, systemPrompt, text) const result = await callChatApiText(endpoint, model, systemPrompt, text, maxTokens)
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) { if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
return null return null

View File

@@ -46,6 +46,8 @@ const DEFAULT_PROMPT_EXTRACT =
'Be mindful of different colors of text that may indicate different speakers or emphasis.' 'Be mindful of different colors of text that may indicate different speakers or emphasis.'
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.' const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
export interface AiConfig { export interface AiConfig {
endpoint: string endpoint: string
model: string model: string
@@ -58,6 +60,13 @@ export interface AiConfig {
promptTagger: string promptTagger: string
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
ocrMode: OcrMode
ocrLanguages: string
ocrConfidenceThreshold: number
} }
export function getAiConfig(): AiConfig { export function getAiConfig(): AiConfig {
@@ -76,9 +85,19 @@ export function getAiConfig(): AiConfig {
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_prompt_translate') const promptTranslateRaw = getSetting('ai_prompt_translate')
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
const maxTokensTag = parseInt(getSetting('ai_max_tokens_tag') ?? '8192', 10) || 8192
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '8192', 10) || 8192
const maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
const rawOcrMode = getSetting('ai_ocr_mode') ?? 'hybrid'
const ocrMode: OcrMode = rawOcrMode === 'tesseract' || rawOcrMode === 'llm' ? rawOcrMode : 'hybrid'
const ocrLanguages = getSetting('ai_ocr_languages') ?? 'eng'
const ocrConfidenceThreshold = parseInt(getSetting('ai_ocr_confidence_threshold') ?? '70', 10) || 70
return { return {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate, promptDescribe, promptTagger, promptExtract, promptTranslate,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
} }
} }
@@ -94,6 +113,13 @@ export function updateAiConfig(
promptTagger?: string, promptTagger?: string,
promptExtract?: string, promptExtract?: string,
promptTranslate?: string, promptTranslate?: string,
maxTokensTag?: number,
maxTokensDescribe?: number,
maxTokensExtract?: number,
maxTokensTranslate?: number,
ocrMode?: OcrMode,
ocrLanguages?: string,
ocrConfidenceThreshold?: number,
): void { ): void {
setSetting('ai_endpoint', endpoint) setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model) setSetting('ai_model', model)
@@ -106,6 +132,13 @@ export function updateAiConfig(
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger) if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract) if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate) if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
if (maxTokensTag !== undefined) setSetting('ai_max_tokens_tag', String(Math.max(1, Math.floor(maxTokensTag))))
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
if (maxTokensExtract !== undefined) setSetting('ai_max_tokens_extract', String(Math.max(1, Math.floor(maxTokensExtract))))
if (maxTokensTranslate !== undefined) setSetting('ai_max_tokens_translate', String(Math.max(1, Math.floor(maxTokensTranslate))))
if (ocrMode !== undefined) setSetting('ai_ocr_mode', ocrMode)
if (ocrLanguages !== undefined) setSetting('ai_ocr_languages', ocrLanguages.trim() || 'eng')
if (ocrConfidenceThreshold !== undefined) setSetting('ai_ocr_confidence_threshold', String(Math.max(0, Math.min(100, Math.floor(ocrConfidenceThreshold)))))
} }
export function getPreferredLanguage(): string { export function getPreferredLanguage(): string {
@@ -127,6 +160,10 @@ export interface LibraryAiOverrides {
promptTagger: string promptTagger: string
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
} }
interface LibraryAiSettingsRow { interface LibraryAiSettingsRow {
@@ -138,6 +175,10 @@ interface LibraryAiSettingsRow {
prompt_tagger: string | null prompt_tagger: string | null
prompt_extract: string | null prompt_extract: string | null
prompt_translate: string | null prompt_translate: string | null
max_tokens_tag: number | null
max_tokens_describe: number | null
max_tokens_extract: number | null
max_tokens_translate: number | null
} }
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides { export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
@@ -154,6 +195,10 @@ export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
promptTagger: row?.prompt_tagger ?? '', promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '', promptExtract: row?.prompt_extract ?? '',
promptTranslate: row?.prompt_translate ?? '', promptTranslate: row?.prompt_translate ?? '',
maxTokensTag: row?.max_tokens_tag ?? null,
maxTokensDescribe: row?.max_tokens_describe ?? null,
maxTokensExtract: row?.max_tokens_extract ?? null,
maxTokensTranslate: row?.max_tokens_translate ?? null,
} }
} }
@@ -164,7 +209,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)' 'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId) ).run(libraryId)
const fields: Record<string, string | undefined> = { const stringFields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging, model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe, model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract, model_extract: overrides.modelExtract,
@@ -175,7 +220,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
prompt_translate: overrides.promptTranslate, prompt_translate: overrides.promptTranslate,
} }
for (const [col, val] of Object.entries(fields)) { for (const [col, val] of Object.entries(stringFields)) {
if (val !== undefined) { if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run( db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val, val === '' ? null : val,
@@ -183,6 +228,22 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
) )
} }
} }
const numberFields: Record<string, number | null | undefined> = {
max_tokens_tag: overrides.maxTokensTag,
max_tokens_describe: overrides.maxTokensDescribe,
max_tokens_extract: overrides.maxTokensExtract,
max_tokens_translate: overrides.maxTokensTranslate,
}
for (const [col, val] of Object.entries(numberFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === null ? null : Math.max(1, Math.floor(val)),
libraryId,
)
}
}
} }
export function getEffectiveAiConfig(libraryId: string): AiConfig { export function getEffectiveAiConfig(libraryId: string): AiConfig {
@@ -200,6 +261,13 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
promptTagger: overrides.promptTagger || global.promptTagger, promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract, promptExtract: overrides.promptExtract || global.promptExtract,
promptTranslate: overrides.promptTranslate || global.promptTranslate, promptTranslate: overrides.promptTranslate || global.promptTranslate,
maxTokensTag: overrides.maxTokensTag ?? global.maxTokensTag,
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
ocrMode: global.ocrMode,
ocrLanguages: global.ocrLanguages,
ocrConfidenceThreshold: global.ocrConfidenceThreshold,
} }
} }

View File

@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
} }
// Auth guard result type // Auth guard result type
type AuthSuccess = { session: IronSession<SessionData> } type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
type AuthResult = AuthSuccess | NextResponse type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response) // Read-only session from an API route request (throwaway response)
@@ -100,13 +100,22 @@ export async function requireLibraryAccess(req: NextRequest, libraryId: string):
if (!session.userId) { if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (session.role === 'admin') return { session } if (session.role === 'admin') return { session, accessLevel: 'admin' }
// Lazy import to avoid pulling DB into edge contexts // Lazy import to avoid pulling DB into edge contexts
const { getPermittedLibraryIds } = await import('./users') const { getLibraryAccessLevel } = await import('./users')
const permitted = getPermittedLibraryIds(session.userId) const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
if (!permitted.includes(libraryId)) { if (!accessLevel) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
return { session } return { session, accessLevel }
}
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
const result = await requireLibraryAccess(req, libraryId)
if (result instanceof NextResponse) return result
if (result.accessLevel === 'read') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return result
} }

View File

@@ -106,6 +106,7 @@ function initDb(db: Database.Database): void {
migrateMediaItemsAiFields(db) migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db) migrateLibraryAiSettings(db)
migrateAiJobs(db) migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -119,6 +120,10 @@ function seedAppSettings(db: Database.Database): void {
ai_model: '', ai_model: '',
preferred_language: 'English', preferred_language: 'English',
ai_max_retries: '3', ai_max_retries: '3',
ai_max_tokens_tag: '8192',
ai_max_tokens_describe: '8192',
ai_max_tokens_extract: '8192',
ai_max_tokens_translate: '8192',
} }
const insert = db.prepare( const insert = db.prepare(
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)' 'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
@@ -276,6 +281,19 @@ function migrateLibraryAiSettings(db: Database.Database): void {
prompt_translate TEXT prompt_translate TEXT
); );
`) `)
// Add max_tokens columns if they don't exist yet
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('max_tokens_tag')) {
db.exec(`
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER;
`)
}
} }
function migrateLibrariesType(db: Database.Database): void { function migrateLibrariesType(db: Database.Database): void {
@@ -301,6 +319,15 @@ function migrateLibrariesType(db: Database.Database): void {
} }
} }
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('access_level')) {
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
}
}
function migrateAiJobs(db: Database.Database): void { function migrateAiJobs(db: Database.Database): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs ( CREATE TABLE IF NOT EXISTS ai_jobs (
@@ -321,4 +348,12 @@ function migrateAiJobs(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status); CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at); CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
`) `)
// Add payload column if not present
const aiJobsRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
.get() as { sql: string } | undefined
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
}
} }

View File

@@ -60,6 +60,19 @@ async function generateAiImage(src: string, dest: string): Promise<void> {
fs.renameSync(tmp, dest) fs.renameSync(tmp, dest)
} }
/** Generate a grayscale, contrast-normalised PNG for local OCR (Tesseract).
* PNG is lossless and avoids JPEG artefacts that can degrade OCR accuracy. */
async function generateOcrImage(src: string, dest: string): Promise<void> {
const tmp = dest + '.tmp'
await sharp(src)
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
.grayscale()
.normalise()
.png()
.toFile(tmp)
fs.renameSync(tmp, dest)
}
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */ /** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
function run(bin: string, args: string[]): Promise<void> { function run(bin: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -190,6 +203,24 @@ export async function getAiImagePath(
return cacheFile return cacheFile
} }
/**
* Returns the absolute path to a preprocessed PNG suitable for local OCR.
* The image is converted to grayscale and contrast-normalised for better
* Tesseract accuracy. Cached with an `_ocr` suffix.
*/
export async function getOcrImagePath(
absoluteFilePath: string,
libraryId: string
): Promise<string> {
ensureCacheDir()
const key = cacheKey(libraryId, absoluteFilePath)
const cacheFile = path.join(CACHE_DIR, key + '_ocr.png')
const cached = getCachedPath(cacheFile, absoluteFilePath)
if (cached) return cached
await generateOcrImage(absoluteFilePath, cacheFile)
return cacheFile
}
/** /**
* Returns the absolute path to a cached thumbnail JPEG for the given file. * Returns the absolute path to a cached thumbnail JPEG for the given file.
* Generates it on first call (or when the source has been modified). * Generates it on first call (or when the source has been modified).

View File

@@ -3,6 +3,7 @@ import path from 'path'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import { getDb } from './db' import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
import { parseTvShowNfo } from './nfo'
function isVideoFile(name: string): boolean { function isVideoFile(name: string): boolean {
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase()) return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
@@ -52,6 +53,7 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
const posterFile = findFile(seriesPath, /^(poster|folder)$/i) const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i) const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
const nfo = parseTvShowNfo(path.join(seriesPath, 'tvshow.nfo'))
const seasonDirs = readDirs(seriesPath) const seasonDirs = readDirs(seriesPath)
const seasonDirCount = seasonDirs.filter((sd) => { const seasonDirCount = seasonDirs.filter((sd) => {
@@ -67,11 +69,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
series.push({ series.push({
id, id,
title: dirName, title: nfo?.title ?? dirName,
year: null, year: nfo?.year ?? null,
plot: null, plot: nfo?.plot ?? null,
genres: [], genres: nfo?.genres ?? [],
status: null, status: nfo?.status ?? null,
posterUrl: posterFile posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null, : null,

View File

@@ -77,43 +77,60 @@ export function listUsers(): User[] {
})) }))
} }
export function getPermittedLibraryIds(userId: string): string[] { export interface LibraryPermission {
const db = getDb() libraryId: string
const rows = db accessLevel: 'read' | 'write'
.prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
.all(userId) as { library_id: string }[]
return rows.map((r) => r.library_id)
} }
export function setLibraryPermissions(userId: string, libraryIds: string[]): void { export function getLibraryPermissions(userId: string): LibraryPermission[] {
const db = getDb()
const rows = db
.prepare('SELECT library_id, access_level FROM library_permissions WHERE user_id = ?')
.all(userId) as { library_id: string; access_level: string }[]
return rows.map((r) => ({ libraryId: r.library_id, accessLevel: r.access_level as 'read' | 'write' }))
}
export function getLibraryAccessLevel(userId: string, libraryId: string): 'read' | 'write' | null {
const db = getDb()
const row = db
.prepare('SELECT access_level FROM library_permissions WHERE user_id = ? AND library_id = ?')
.get(userId, libraryId) as { access_level: string } | undefined
if (!row) return null
return row.access_level as 'read' | 'write'
}
export function setLibraryPermissions(userId: string, permissions: LibraryPermission[]): void {
const db = getDb() const db = getDb()
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId) db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)') const insert = db.prepare(
for (const libraryId of libraryIds) { 'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)'
insert.run(userId, libraryId) )
for (const { libraryId, accessLevel } of permissions) {
insert.run(userId, libraryId, accessLevel)
} }
}) })
tx() tx()
} }
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] { export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
if (role === 'admin') return getLibraries() if (role === 'admin') return getLibraries().map((l) => ({ ...l, accessLevel: 'admin' as const }))
const db = getDb() const db = getDb()
const rows = db const rows = db
.prepare( .prepare(
`SELECT l.id, l.name, l.path, l.type, l.cover_ext `SELECT l.id, l.name, l.path, l.type, l.cover_ext, lp.access_level
FROM libraries l FROM libraries l
INNER JOIN library_permissions lp ON lp.library_id = l.id INNER JOIN library_permissions lp ON lp.library_id = l.id
WHERE lp.user_id = ? WHERE lp.user_id = ?
ORDER BY l.name ASC` ORDER BY l.name ASC`
) )
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[] .all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null; access_level: string }[]
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
path: r.path, path: r.path,
type: r.type as Library['type'], type: r.type as Library['type'],
coverExt: r.cover_ext, coverExt: r.cover_ext,
accessLevel: r.access_level as 'read' | 'write',
})) }))
} }

View File

@@ -6,6 +6,7 @@ export interface Library {
path: string path: string
type: LibraryType type: LibraryType
coverExt: string | null coverExt: string | null
accessLevel?: 'admin' | 'read' | 'write'
} }
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android' export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'
@@ -44,6 +45,7 @@ export interface FileEntry {
mediaType: MediaType | null mediaType: MediaType | null
url: string | null url: string | null
thumbnailUrl: string | null thumbnailUrl: string | null
hasExtractedText?: boolean
} }
export interface Movie { export interface Movie {