Compare commits

...

77 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
236f168eeb Merge pull request 'text-extraction-improvements' (#24) from text-extraction-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/24
2026-04-13 16:29:25 +00:00
Garret Patti
fea55594d0 add ai job queue 2026-04-13 12:29:09 -04:00
Garret Patti
8557c80c52 reduce api calls for text extraction 2026-04-13 11:18:39 -04:00
Garret Patti
68b1ed94ea fix vertical image clipping in viewer 2026-04-13 10:53:05 -04:00
Garret Patti
e31a9667ef text extraction improvements: editable text and source language hint
- Extracted text in the tag panel is now an editable textarea; a Save
  button appears when the content is dirty and persists edits to the DB
- Source language input added next to Re-translate button; when filled,
  the translation prompt uses "translate from X to Y" for more accurate
  results
- New updateExtractedText() helper and PATCH /api/ai-tagging/fields
  endpoint to support saving edited text
- translateItemText/translateText accept optional sourceLanguage param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:29:47 -04:00
c454d020da Merge pull request 'doomscroll-improvements' (#23) from doomscroll-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/23
2026-04-13 13:23:33 +00:00
Garret Patti
b0fc275a52 add extract text button to doom scroll mode
Show an extract-text button (document icon) in the bottom bar when the
current image has no extracted text yet. Clicking it calls the extract-text
API, shows a spinner while in progress, and on success replaces itself with
the text-lines display button and auto-opens the overlay. Error state briefly
turns the button red. Resets on every item navigation alongside the other
text state. Hidden for videos and items without an itemKey.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:19:32 -04:00
Garret Patti
cd9a83ea90 send higher resolution images to AI vision endpoints
Add getAiImagePath() to thumbnails.ts (1920px wide, quality 90, no
upscaling) cached separately from display thumbnails via an _ai suffix.
Swap all four image-to-AI code paths in ai-tagger.ts (extract text,
describe, batch tagging x2) to use the new high-res image instead of
the 400px display thumbnail, improving OCR accuracy on dense text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:08:43 -04:00
Garret Patti
5ba73b2e56 doom scroll and viewer improvements
- move play/pause to clicking the video directly; remove dedicated button
- replace emoji mute icons with flat minimal SVGs
- add view-in-library button in doom scroll that navigates to the file's
  directory and opens it in the regular viewer
- add display text overlay button in doom scroll and image lightbox;
  shows extracted text (translated by default when available) in a
  semi-transparent box at the bottom; toggle between translated/original
- hide tag panel by default in image lightbox and video player modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 08:16:34 -04:00
2b51f72f96 Merge pull request 'ai-customization' (#22) from ai-customization into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/22
2026-04-13 01:13:41 +00:00
Garret Patti
efaff8ca1b add applied tags as context to description prompt
When generating an item description, any already-applied tags are
appended to the system prompt as a source of truth, so the model
can produce a more accurate description aligned with existing tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:12:58 -04:00
Garret Patti
89ac22e9d1 show applied tags first in tag selector picker
Applied tags are now pinned to the front of each category's tag list,
with unapplied tags continuing in usage order behind them. Both
partitions preserve the existing usage-sort from the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:58:12 -04:00
Garret Patti
b0d146679f scope doom scroll to current directory when no filters active
When no filters are selected, doom scroll now recursively fetches only
items under the current directory instead of the entire library root.
Navigating to a new directory invalidates the cached listing. Filter-
based doom scroll (search or tags) continues to search library-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:51:29 -04:00
Garret Patti
887cc05901 add per-library AI model and prompt customization
- Add library_ai_settings table with migration for per-library overrides
- Extend AiConfig with editable prompt parts for description, tagging,
  extraction, and translation steps; defaults match previous hardcoded values
- Add getEffectiveAiConfig(libraryId) that merges global settings with
  library-level overrides (empty override falls through to global)
- Update all ai-tagger functions to use getEffectiveAiConfig and build
  prompts from configurable parts
- Add GET/PUT /api/ai-settings/library/[id] for per-library overrides
- Update /api/ai-settings GET/PUT to include prompt fields
- Add Prompts section and Library Overrides section to admin UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:37:11 -04:00
afb9540df2 Merge pull request 'ai-descriptions' (#21) from ai-descriptions into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/21
2026-04-12 23:55:05 +00:00
Garret Patti
5ac4b3bd8a customize model based on step 2026-04-12 19:50:18 -04:00
Garret Patti
470f34c985 feed extracted text to image tagger prompt 2026-04-12 19:15:19 -04:00
Garret Patti
7e284383b4 add ai descriptions and extracted text 2026-04-12 18:18:59 -04:00
60790a3af1 Merge pull request 'ai-feature-setup' (#20) from ai-feature-setup into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/20
2026-04-12 21:24:57 +00:00
Garret Patti
6c769b457f handle video tagging 2026-04-12 17:24:39 -04:00
Garret Patti
ad9920a448 limit tags sent and send applied tags to ai 2026-04-12 16:45:26 -04:00
Garret Patti
732e9134c3 ai starter implementation 2026-04-12 15:39:48 -04:00
Garret Patti
0238dbda7a Add AI-powered image tagging via local LLM
Adds automatic image tagging that runs as a post-scan phase, sending
thumbnails to an OpenAI-compatible vision API and applying matching
tags from the user-defined tag vocabulary.

- New ai-tagger module with batch processing, failure tolerance, and
  tag validation against existing vocabulary
- Admin settings page (Manage > AI Tagging) for endpoint, model, and
  enable toggle with connection testing
- DB migration for ai_tagged_at tracking column and AI config seeds
- Re-tag All support to queue items for reprocessing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:18:03 -04:00
9bff0f848a Merge pull request 'add individual library scanning' (#19) from scanner-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 53s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/19
2026-04-12 18:10:13 +00:00
Garret Patti
aae41e9803 add individual library scanning 2026-04-12 13:51:51 -04:00
7e9ba6e014 Merge pull request 'add-android-platform' (#18) from add-android-platform into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/18
2026-04-12 17:09:29 +00:00
Garret Patti
0091606e4d handle other archive types for linux 2026-04-12 13:09:07 -04:00
Garret Patti
080cc011b9 icon color and size tweaks 2026-04-12 12:41:42 -04:00
Garret Patti
d3e1bf049b handle android and swap to os icons 2026-04-12 11:53:27 -04:00
625539f35e Merge pull request 'game-enhancements' (#17) from game-enhancements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m4s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/17
2026-04-12 14:19:51 +00:00
Garret Patti
84c65c7964 Add screenshots to game detail modal
- New /api/game-screenshots route handles GET (list), POST (upload), and DELETE (remove single file)
- Screenshots stored in a screenshots/ subfolder inside each game directory
- Images converted to JPEG via Sharp on upload, named shot-{timestamp}.jpg
- Modal shows a horizontal scrollable strip of 16:9 thumbnail tiles
- Hover a tile to reveal a delete button; uploading placeholders appear per in-flight upload
- Dashed + tile triggers multi-file picker for batch uploads
- Click any thumbnail to open a full-screen lightbox with prev/next arrows, counter, and keyboard nav (←/→/Esc)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:18:38 -04:00
Garret Patti
53205d4a19 Add multi-platform game support with per-OS download detection
- Detect Windows (.zip), Linux (.tar.gz), and macOS (.dmg / .app bundle) game archives during scan
- Store GameFile[] with platform metadata in DB instead of plain zipFiles[]
- Stream .app bundles as on-the-fly zip archives via archiver
- Show WIN/LIN/MAC platform badge pills on GameCard and SeriesCard
- Auto-select the download matching the user's OS in GameDetailModal
- Persist cover URL to DB immediately on upload (no re-scan needed)
- Backward-compatible: legacy zipFiles entries map to platform 'windows'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:47:09 -04:00
ebc35d7184 Merge pull request 'add more management capabilities' (#16) from management into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/16
2026-04-11 22:34:38 +00:00
Garret Patti
768c49ef00 add more management capabilities 2026-04-11 18:33:03 -04:00
1ca90184f5 Merge pull request 'clean-up' (#15) from clean-up into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m42s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/15
2026-04-11 01:33:10 +00:00
Garret Patti
6c2443fa2c Filter non-browser-playable formats from Doom Scroll
Formats like .mkv, .avi, .wmv, .ts, .m2ts and .tiff are not natively
supported by browsers and would stall silently in Doom Scroll mode.

Add src/lib/browser-media.ts with BROWSER_VIDEO_EXTENSIONS (.mp4,
.webm, .mov, .m4v), BROWSER_IMAGE_EXTENSIONS (.jpg, .jpeg, .png, .gif,
.webp, .bmp), and an isBrowserPlayable() helper that extracts the
extension without importing Node's path module.

Filter doomScrollItems in MixedView, MoviesView, and TvView using this
helper so only natively renderable files are passed to DoomScrollView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:29:17 -04:00
Garret Patti
5d4d11512d Fix DoomScrollView going blank after 100 items
When history hit the 100-entry cap, setHistory sliced the array back to
indices 0–99 but setHistoryIndex still returned idx + 1 = 100, making
current = history[100] = undefined. Nothing rendered and no API calls
were made until the user went back (decrementing to index 99, which
held the newly-picked item).

Fix: cap the returned historyIndex at HISTORY_CAP - 1 so it always
points to a valid entry in the sliced array. Extract HISTORY_CAP = 100
as a named constant so the slice and the index cap stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:06 -04:00
Garret Patti
6f86750a99 Unify media_key and item_key — use item_key everywhere
media_key was a lossy shortening of item_key (libraryId:lastSegment) that
introduced a real collision bug: two TV episodes from different series with
the same filename would share the same media_key and each other's tags.

- DB migration converts existing media_tags rows from short format to full
  item_key by joining against media_items; ambiguous/orphaned rows are dropped
- media_tags column renamed media_key → item_key
- Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes
  item_key directly to reKeyMediaItem
- DB reader functions (tv, movies, games) now expose item_key on returned
  entities; frontend components use entity.item_key instead of constructing
  the short libraryId:id form
- MixedView now constructs the full mixed_file: item_key format
- Tag API renamed mediaKey param → itemKey throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:04:29 -04:00
390ce8fcc6 Merge pull request 'performance-stability' (#14) from performance-stability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/14
2026-04-07 00:22:00 +00:00
Garret Patti
f08950f456 Fix Doom Scroll mode bugs in TV libraries and video autoplay
TV library fix: the unfiltered Doom Scroll path was calling
/api/browse which explicitly rejects non-mixed libraries with a 400
error, leaving the item list empty. Replace it with the same TV API
hierarchy fetch already used by the filtered path (series → seasons →
episodes), matching how the rest of the TV library is loaded.

Autoplay fix (all library types): two interacting bugs caused videos
to silently stall on navigation. First, the play/pause effect had
current?.url in its deps, so navigating while paused would call
pause() on the freshly-mounted video element before the isPaused reset
could take effect. Second, browser autoplay policy blocks unmuted
play() calls and the rejection was silently swallowed with no recovery.
Fix by merging the isPaused reset and the play() call into one
navigation effect, and adding a muted fallback on rejection so playback
always starts — the user can unmute manually afterward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:21:27 -04:00
Garret Patti
4d75e74cab Fix post-scan CPU spike and improve scan performance at scale
- Remove fire-and-forget thumbnail pre-warming from scanMixed(): firing
  48k+ simultaneous unresolved getThumbnailPath() promises was saturating
  sharp and ffmpeg after scan completion, keeping CPU pegged. Mixed-library
  thumbnails are now generated on-demand by /api/thumbnail as before.
- Add incremental fingerprinting: load existing (item_key → fingerprint)
  map from DB before each walk; reuse stored fingerprint for unchanged paths
  instead of re-reading 64 KB per file. Stable re-scans now do ~0 bytes of
  fingerprint I/O.
- Wrap all bulk DB upsert and delete loops in db.transaction() in
  scanMovies(), scanTv(), scanMixed(), and reconcileAndPrune(). Reduces
  N auto-committed WAL writes to a single batch commit per scan phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:58:05 -04:00
e5953100d6 Merge pull request 'file-fingerprinting' (#13) from file-fingerprinting into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/13
2026-04-06 23:06:22 +00:00
Garret Patti
58c5e424d9 Fix media_tags not updating when fingerprint move is detected
reKeyMediaItem was called with item_key values (e.g. "lib1:movie:Inception")
but media_tags stores keys in the shorter UI format (e.g. "lib1:Inception"),
so the UPDATE never matched any rows.

Add itemKeyToMediaKey() to extract the terminal segment of an item_key and
reconstruct the media_key format the UI uses:
  lib1:movie:Inception%20(2010)          → lib1:Inception%20(2010)
  lib1:tv_episode:Show:Season1:ep.mkv   → lib1:ep.mkv
  lib1:mixed_file:dir%2Ffile.mp4        → lib1:dir%2Ffile.mp4

Also skip the UPDATE when old and new media_keys are identical (e.g. a TV
episode moved between seasons keeps the same filename-based media_key).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:59:51 -04:00
Garret Patti
38a6886863 Add file fingerprinting for move-resilient media item identity
Computes a SHA-256 partial-content fingerprint (file size + first 64 KB) for
movies, TV episodes, and mixed files during scans. When a file is moved or
renamed within a library, the scan detects the fingerprint match, renames the
media_items row in-place, and updates media_tags.media_key to match — so tags
and NFO metadata survive the move transparently.

- src/lib/fingerprint.ts: new computeFingerprint() using sync FS reads
- src/lib/db.ts: fingerprint TEXT column + index migration
- src/lib/tags.ts: reKeyMediaItem() to update media_tags on rename
- src/lib/scanner.ts: replace clear+upsert with detectMoves/reconcileAndPrune
  for movies, TV episodes, and mixed files; games retain clear+upsert (v1)
- TV scan restructured to a single filesystem pass (no double-scanning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:35:02 -04:00
Garret Patti
819748d1ff DB-first library reads, mixed library indexing, and manual NFO refresh
- API reads now serve from media_items cache instead of scanning the filesystem
  on every request; scans (manual or scheduled) remain the write path
- NFO metadata is no longer parsed automatically during scans; title falls back
  to folder/filename — metadata can be refreshed per-item via the kabob menu
- Mixed libraries are now indexed in media_items (new mixed_file item type)
  with file_path stored; scanMixed walks recursively and upserts all files
- Added file_path column to media_items and migrated item_type CHECK constraint
  to include mixed_file via safe table-recreation migration
- New POST /api/nfo-refresh endpoint reads the .nfo for a single item and
  patches its DB row (supports movie, tv_series, tv_episode)
- Added "Refresh metadata" button to movie and TV series kabob menus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:20:21 -04:00
01a4a1c0b7 Merge pull request 'fix search/filter bugs in game and TV libraries' (#12) from search-tweaks into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/12
2026-04-06 18:24:30 +00:00
Garret Patti
5d27ba351b fix search/filter bugs in game and TV libraries
- Game series: filter now checks child games for both search and tag matches instead of always passing series through
- TV episodes: tag selector no longer closes after picking a tag
- TV episodes: filter panel now filters episodes within a season view
- TV series list: series now appear when any of their episodes match the active tag filter (via new /api/tv/series-episode-tags endpoint backed by media_items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:23:34 -04:00
957d884903 Merge pull request 'Reduce code duplication and update README' (#11) from cleanup-pass into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/11
2026-04-06 16:52:22 +00:00
Garret Patti
6b5ff81654 Reduce code duplication and update README
- Extract shared utilities (HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl,
  thumbnailApiUrl, findFile) into new src/lib/media-utils.ts, removing
  identical copies from games.ts, movies.ts, tv.ts, files.ts, and scanner.ts
- Add comment in files.ts clarifying why its VIDEO_EXTENSIONS set intentionally
  differs from the media library set (web-playable formats for the mixed browser)
- Rewrite README to reflect the current feature set: Movies/TV libraries, auth
  system, tag system, background scanner, updated project structure, folder
  conventions for all four library types, and a complete API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:49:24 -04:00
80d922263e Merge pull request 'handle shows without season folders' (#10) from navigation into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/10
2026-04-06 02:18:58 +00:00
76 changed files with 10799 additions and 1138 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ medialore.db-shm
medialore.db-wal medialore.db-wal
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.session_secret .session_secret
.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

223
README.md
View File

@@ -1,13 +1,18 @@
# MediaLore # MediaLore
A self-hosted web UI for browsing media libraries on a NAS or local filesystem. Configure folders as typed libraries — currently supporting **Games** and **Mixed Media** library types. A self-hosted web UI for browsing media libraries on a NAS or local filesystem. Configure folders as typed libraries — supporting **Games**, **Movies**, **TV**, and **Mixed Media** library types.
## Features ## Features
- **Games library** — displays a grid of game cover art scanned from folders. Each game folder is expected to contain a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link for the zip. - **Games library** — grid of game cover art scanned from folders. Each game folder contains a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link.
- **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Image and video files display auto-generated square thumbnails. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab. - **Movies library** — grid of movie posters scanned from per-movie folders. Reads `.nfo` sidecar files (Kodi-compatible) for metadata (title, year, plot, rating, genres). Clicking a movie opens a full-screen video player.
- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated automatically when the source file changes. - **TV library** — browse TV series → seasons → episodes. Reads `tvshow.nfo` and per-episode `.nfo` files for metadata. Supports standard season folder layouts and flat (seasonless) series.
- **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`. - **Mixed media library** — folder-navigable browser that mirrors the directory structure on disk. Images open in a lightbox; videos open in an inline player with full seek support.
- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated when the source file changes.
- **Tag system** — create tag categories and items, then assign tags to individual media items per-library. Filter any library view by one or more tags.
- **Library management UI** — add and remove libraries at `/manage` without touching config files. Configuration persists across restarts in `libraries.json`.
- **User authentication** — iron-session cookie auth with `admin` and `user` roles. Admins manage libraries, scan settings, tags, and users. Users have read-only access, optionally restricted to specific libraries.
- **Background scanner** — scans all libraries on demand (or on a schedule) to pre-generate thumbnails and populate metadata caches.
- **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served. - **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served.
## Project Structure ## Project Structure
@@ -16,35 +21,77 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem.
MediaLoreWeb/ MediaLoreWeb/
├── libraries.json # Runtime library config (managed via UI, do not edit by hand) ├── libraries.json # Runtime library config (managed via UI, do not edit by hand)
├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored) ├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored)
├── data/ # Example media (not committed to production)
├── src/ ├── src/
│ ├── app/ │ ├── app/
│ │ ├── layout.tsx │ │ ├── layout.tsx
│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty) │ │ ├── page.tsx # Home — library cards (redirects to /manage if empty)
│ │ ├── manage/page.tsx # Library management — add/remove libraries │ │ ├── manage/page.tsx # Library management
│ │ ├── library/[id]/page.tsx # Library view (games or mixed) │ │ ├── manage/tags/page.tsx # Tag management
│ │ ├── manage/users/page.tsx # User management (admin only)
│ │ ├── manage/scan/page.tsx # Scan settings and manual trigger
│ │ ├── library/[id]/page.tsx # Library view (games / movies / tv / mixed)
│ │ └── api/ │ │ └── api/
│ │ ├── libraries/route.ts # GET /api/libraries, POST /api/libraries │ │ ├── auth/login/route.ts # POST /api/auth/login
│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id │ │ ├── auth/logout/route.ts # POST /api/auth/logout
│ │ ├── games/route.ts # GET /api/games?libraryId= │ │ ├── auth/register/route.ts # POST /api/auth/register
│ │ ├── browse/route.ts # GET /api/browse?libraryId=&path= │ │ ├── libraries/route.ts # GET, POST /api/libraries
│ │ ├── file/route.ts # GET /api/file?libraryId=&path= │ │ ├── libraries/[id]/route.ts # PATCH, DELETE /api/libraries/:id
│ │ ── thumbnail/route.ts # GET /api/thumbnail?libraryId=&path= │ │ ── games/route.ts # GET /api/games
│ │ ├── movies/route.ts # GET /api/movies
│ │ ├── tv/route.ts # GET /api/tv (series, seasons, episodes)
│ │ ├── browse/route.ts # GET /api/browse (mixed media)
│ │ ├── file/route.ts # GET /api/file
│ │ ├── thumbnail/route.ts # GET /api/thumbnail
│ │ ├── game-cover/route.ts # POST /api/game-cover (upload cover art)
│ │ ├── library-cover/[id]/route.ts # GET /api/library-cover/:id
│ │ ├── scan/route.ts # POST /api/scan
│ │ ├── scan-settings/route.ts # GET, POST /api/scan-settings
│ │ ├── settings/route.ts # GET, POST /api/settings
│ │ ├── tags/categories/route.ts # GET, POST /api/tags/categories
│ │ ├── tags/categories/[id]/route.ts# PATCH, DELETE /api/tags/categories/:id
│ │ ├── tags/items/route.ts # GET, POST /api/tags/items
│ │ ├── tags/items/[id]/route.ts # PATCH, DELETE /api/tags/items/:id
│ │ ├── tags/assignments/route.ts # GET, POST, DELETE /api/tags/assignments
│ │ ├── tags/library-assignments/route.ts # GET /api/tags/library-assignments
│ │ ├── users/route.ts # GET, POST /api/users
│ │ ├── users/[id]/route.ts # PATCH, DELETE /api/users/:id
│ │ └── users/[id]/permissions/route.ts # GET, POST /api/users/:id/permissions
│ ├── components/ │ ├── components/
│ │ ├── FilterPanel.tsx
│ │ ├── LibraryCard.tsx │ │ ├── LibraryCard.tsx
│ │ ├── NavLink.tsx │ │ ├── NavLink.tsx
│ │ ├── DoomScrollView.tsx
│ │ ├── games/ │ │ ├── games/
│ │ │ ├── GamesView.tsx │ │ │ ├── GamesView.tsx
│ │ │ └── GameDetailModal.tsx │ │ │ └── GameDetailModal.tsx
│ │ ── mixed/ │ │ ── movies/
│ │ ├── MixedView.tsx │ │ ├── MoviesView.tsx
│ │ ── VideoPlayerModal.tsx │ │ ── MovieDetailModal.tsx
│ │ └── ImageLightbox.tsx │ │ ├── tv/
│ │ │ └── TvView.tsx
│ │ ├── mixed/
│ │ │ ├── MixedView.tsx
│ │ │ ├── VideoPlayerModal.tsx
│ │ │ └── ImageLightbox.tsx
│ │ └── tags/
│ │ └── TagSelector.tsx
│ ├── lib/ │ ├── lib/
│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers │ │ ├── auth.ts # iron-session auth, requireAdmin / requireLibraryAccess helpers
│ │ ├── db.ts # SQLite setup and migrations (better-sqlite3)
│ │ ├── libraries.ts # Config read/write, path resolution, path-jailing
│ │ ├── media-utils.ts # Shared constants (HIDDEN_FILES, VIDEO_EXTENSIONS) and helpers
│ │ ├── games.ts # Games library scanner │ │ ├── games.ts # Games library scanner
│ │ ├── files.ts # Mixed library directory scanner │ │ ├── movies.ts # Movies library scanner
│ │ ── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg) │ │ ── tv.ts # TV library scanner (series / seasons / episodes)
│ │ ├── files.ts # Mixed media directory scanner
│ │ ├── nfo.ts # Kodi-compatible .nfo XML parser
│ │ ├── scanner.ts # Full-library background scan orchestrator
│ │ ├── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg)
│ │ ├── tags.ts # Tag CRUD and assignment helpers
│ │ ├── users.ts # User CRUD and permission helpers
│ │ └── app-settings.ts # App-level settings (scan schedule, etc.)
│ ├── hooks/
│ │ └── useUserSettings.ts
│ └── types/ │ └── types/
│ └── index.ts │ └── index.ts
``` ```
@@ -63,11 +110,14 @@ MediaLoreWeb/
# 1. Install dependencies # 1. Install dependencies
npm install npm install
# 2. Start the development server # 2. Copy the example env file and fill in SESSION_SECRET
cp .env.example .env.local
# 3. Start the development server
npm run dev npm run dev
``` ```
Open [http://localhost:3000](http://localhost:3000). Open [http://localhost:3000](http://localhost:3000). On first run you will be prompted to create an admin account.
Other available commands: Other available commands:
@@ -77,23 +127,37 @@ npm run start # Start production server (run build first)
npm run lint # Run ESLint npm run lint # Run ESLint
``` ```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `SESSION_SECRET` | Yes | Secret used to sign session cookies. Must be at least 32 characters. |
| `COOKIE_SECURE` | No | Set to `true` in production (HTTPS). Defaults to `false`. |
## Authentication
MediaLore uses cookie-based sessions (iron-session). On first launch, navigate to `/register` to create the initial admin account. Subsequent registrations require an existing admin to create accounts via `/manage/users`.
**Roles:**
| Role | Capabilities |
|------|-------------|
| `admin` | Full access: manage libraries, users, tags, scan settings |
| `user` | Read-only access to assigned libraries |
Library-level permissions can be configured per user at `/manage/users`.
## Library Configuration ## Library Configuration
Libraries are managed through the **Manage Libraries** screen at `/manage` in the app. No manual file editing is required. Libraries are managed through the **Manage Libraries** screen at `/manage` in the app.
When you add a library via the UI, you provide:
| Field | Description | | Field | Description |
|--------|-------------| |-------|-------------|
| Name | Display name shown in the UI | | Name | Display name shown in the UI |
| Path | Absolute or project-relative path to the library root folder on disk | | Path | Absolute or project-relative path to the library root folder on disk |
| Type | `Games` or `Mixed Media` | | Type | `games`, `movies`, `tv`, or `mixed` |
The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root and persists across restarts. The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root.
If no libraries are configured, navigating to `/` automatically redirects to `/manage`.
Paths can point anywhere on the filesystem — they do not need to be inside the project directory.
## Library Folder Conventions ## Library Folder Conventions
@@ -109,8 +173,41 @@ Games/
└── widecover.jpg # Optional — landscape/hero cover art (case-insensitive) └── widecover.jpg # Optional — landscape/hero cover art (case-insensitive)
``` ```
- The `.zip` filename can be anything; the first `.zip` found in the folder is used. Subdirectories without a `.zip` are treated as series containers — their child directories are scanned as individual games.
- Cover art filenames are matched case-insensitively against `cover.*` and `widecover.*`. Any image extension is accepted.
### Movies (`"type": "movies"`)
Each movie is a subdirectory containing a single video file and optional sidecar files:
```
Movies/
└── The Matrix (1999)/
├── The Matrix (1999).mkv # Required — any supported video extension
├── movie.nfo # Optional — Kodi-compatible metadata
├── poster.jpg # Optional — portrait poster art
└── backdrop.jpg # Optional — backdrop/fanart image
```
Supported video extensions: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.wmv`, `.ts`, `.m2ts`
Poster filenames are matched case-insensitively against `poster`, `cover`, or `folder`. Backdrop filenames are matched against `backdrop`, `fanart`, or `background`.
### TV (`"type": "tv"`)
```
TV/
└── Breaking Bad/
├── tvshow.nfo # Optional — series metadata
├── poster.jpg # Optional — series poster
├── Season 01/
│ ├── s01e01.mkv
│ ├── s01e01.nfo # Optional — episode metadata
│ └── ...
└── Season 02/
└── ...
```
Season directory names are matched against patterns: `Season 01`, `S01`, `1`, `01`. If no season subdirectories contain video files, the series root itself is treated as a flat (seasonless) season.
### Mixed Media (`"type": "mixed"`) ### Mixed Media (`"type": "mixed"`)
@@ -123,15 +220,57 @@ No specific structure is required. The UI mirrors the directory tree exactly as
All API routes are server-side. File paths are never exposed in client-side state — only opaque `/api/file?...` URLs are sent to the browser. All API routes are server-side. File paths are never exposed in client-side state — only opaque `/api/file?...` URLs are sent to the browser.
### Auth
| Route | Method | Description |
|-------|--------|-------------|
| `/api/auth/login` | POST | Authenticate. Body: `{ username, password }` |
| `/api/auth/logout` | POST | Clear session cookie |
| `/api/auth/register` | POST | Create account. Body: `{ username, password }`. First user becomes admin. |
### Libraries
| Route | Method | Description | | Route | Method | Description |
|-------|--------|-------------| |-------|--------|-------------|
| `/api/libraries` | GET | Returns the full configured library list | | `/api/libraries` | GET | Returns the full configured library list |
| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }`. Validates the path exists. | | `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }` |
| `/api/libraries/:id` | PATCH | Updates a library |
| `/api/libraries/:id` | DELETE | Removes a library by id | | `/api/libraries/:id` | DELETE | Removes a library by id |
| `/api/games?libraryId=<id>` | GET | Scans the games library and returns structured game entries |
| `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library | ### Media
| `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback |
| `/api/thumbnail?libraryId=<id>&path=<relpath>` | GET | Returns a cached square thumbnail (JPEG) for an image or video file; `404` if generation fails or ffmpeg is unavailable | | Route | Method | Description |
|-------|--------|-------------|
| `/api/games?libraryId=` | GET | Scans the games library and returns structured game entries |
| `/api/movies?libraryId=` | GET | Scans the movies library and returns movie entries |
| `/api/tv?libraryId=` | GET | Returns TV series list |
| `/api/tv?libraryId=&seriesId=` | GET | Returns seasons for a series |
| `/api/tv?libraryId=&seriesId=&seasonId=` | GET | Returns episodes for a season |
| `/api/browse?libraryId=&path=` | GET | Lists the contents of a directory within a mixed library |
| `/api/file?libraryId=&path=` | GET | Streams a file; supports HTTP `Range` requests for seekable video |
| `/api/thumbnail?libraryId=&path=` | GET | Returns a cached square thumbnail (JPEG); `404` if generation fails |
### Tags
| Route | Method | Description |
|-------|--------|-------------|
| `/api/tags/categories` | GET, POST | List or create tag categories |
| `/api/tags/categories/:id` | PATCH, DELETE | Update or delete a category |
| `/api/tags/items` | GET, POST | List or create tag items |
| `/api/tags/items/:id` | PATCH, DELETE | Update or delete a tag item |
| `/api/tags/assignments` | GET, POST, DELETE | Get, add, or remove tag assignments on a media item |
| `/api/tags/library-assignments?libraryId=` | GET | All tag assignments for a library (used by filter panel) |
### Users & Settings
| Route | Method | Description |
|-------|--------|-------------|
| `/api/users` | GET, POST | List or create users (admin only) |
| `/api/users/:id` | PATCH, DELETE | Update or delete a user |
| `/api/users/:id/permissions` | GET, POST | Get or set library-level permissions for a user |
| `/api/settings` | GET, POST | App-level settings |
| `/api/scan` | POST | Trigger a full library scan |
| `/api/scan-settings` | GET, POST | Get or update scan schedule settings |
## Tech Stack ## Tech Stack
@@ -139,3 +278,7 @@ All API routes are server-side. File paths are never exposed in client-side stat
- [React 19](https://react.dev/) - [React 19](https://react.dev/)
- [TypeScript 5](https://www.typescriptlang.org/) - [TypeScript 5](https://www.typescriptlang.org/)
- [Tailwind CSS 4](https://tailwindcss.com/) - [Tailwind CSS 4](https://tailwindcss.com/)
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — SQLite database
- [iron-session](https://github.com/vvo/iron-session) — cookie-based sessions
- [sharp](https://sharp.pixelplumbing.com/) — image thumbnail generation
- [ffmpeg](https://ffmpeg.org/) — video thumbnail extraction

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

1137
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"archiver": "^7.0.1",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10", "fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
@@ -19,10 +20,12 @@
"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",
"@types/archiver": "^7.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getJobQueue, getJobHistory, retryJob, cancelJob, cancelAllQueued, clearJobHistory } from '@/lib/ai-jobs'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const queue = getJobQueue()
const history = getJobHistory(50)
return NextResponse.json({ queue, history })
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { action?: string; jobId?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { action, jobId } = body
switch (action) {
case 'retry': {
if (!jobId || typeof jobId !== 'string') {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
}
const ok = retryJob(jobId)
if (!ok) {
return NextResponse.json({ error: 'Job not found or not in failed state' }, { status: 404 })
}
return NextResponse.json({ ok: true })
}
case 'cancel': {
if (!jobId || typeof jobId !== 'string') {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
}
const ok = cancelJob(jobId)
if (!ok) {
return NextResponse.json({ error: 'Job not found or not in queued state' }, { status: 404 })
}
return NextResponse.json({ ok: true })
}
case 'cancel-all': {
const cancelled = cancelAllQueued()
return NextResponse.json({ cancelled })
}
case 'clear-history': {
const cleared = clearJobHistory()
return NextResponse.json({ cleared })
}
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
return NextResponse.json(getLibraryAiOverrides(id))
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
setLibraryAiOverrides(id, {
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : 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))
}

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

@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const db = getDb()
const result = db.prepare('UPDATE media_items SET ai_tagged_at = NULL').run()
return NextResponse.json({ cleared: result.changes })
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const config = getAiConfig()
const preferredLanguage = getPreferredLanguage()
const maxRetries = getAiMaxRetries()
return NextResponse.json({ ...config, preferredLanguage, maxRetries })
}
export async function PUT(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: {
endpoint?: string
model?: string
modelTagging?: string
modelDescribe?: string
modelExtract?: string
modelTranslate?: string
enabled?: boolean
preferredLanguage?: string
promptDescribe?: string
promptTagger?: string
promptExtract?: string
promptTranslate?: string
maxRetries?: number
maxTokensTag?: number
maxTokensDescribe?: number
maxTokensExtract?: number
maxTokensTranslate?: number
ocrMode?: string
ocrLanguages?: string
ocrConfidenceThreshold?: number
}
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const {
endpoint, model, enabled, preferredLanguage,
modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate,
maxRetries,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
} = body
if (typeof endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
}
if (typeof model !== 'string') {
return NextResponse.json({ error: 'model is required' }, { status: 400 })
}
if (typeof enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
}
updateAiConfig(
endpoint,
model,
enabled,
typeof modelTagging === 'string' ? modelTagging : undefined,
typeof modelDescribe === 'string' ? modelDescribe : undefined,
typeof modelExtract === 'string' ? modelExtract : undefined,
typeof modelTranslate === 'string' ? modelTranslate : undefined,
typeof promptDescribe === 'string' ? promptDescribe : undefined,
typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : 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()) {
setPreferredLanguage(preferredLanguage.trim())
}
if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) {
setAiMaxRetries(maxRetries)
}
const config = getAiConfig()
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage(), maxRetries: getAiMaxRetries() })
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getAiConfig } from '@/lib/app-settings'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { endpoint, model } = getAiConfig()
if (!endpoint) {
return NextResponse.json({ error: 'No endpoint configured' }, { status: 400 })
}
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model: model || 'test',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 1,
}),
})
clearTimeout(timeout)
if (!res.ok) {
const text = await res.text().catch(() => '')
return NextResponse.json(
{ error: `LLM returned ${res.status}: ${text.slice(0, 200)}` },
{ status: 502 }
)
}
return NextResponse.json({ ok: true })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return NextResponse.json({ error: `Connection failed: ${message}` }, { status: 502 })
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
const MEDIA_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS])
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 jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

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

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
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 jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

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

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const itemKey = searchParams.get('itemKey')
if (!itemKey) {
return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const fields = getAiFields(itemKey)
return NextResponse.json(fields)
}
export async function PATCH(request: NextRequest) {
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { itemKey, extractedText, aiDescription } = body
if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
if (extractedText === undefined && aiDescription === undefined) {
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
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 })
}

View File

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

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

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

View File

@@ -1,7 +1,11 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { getDb } from '@/lib/db'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
@@ -28,5 +32,94 @@ 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)
} }
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const itemPath = searchParams.get('path')
if (!libraryId || !itemPath) {
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'mixed') {
return NextResponse.json({ error: 'Library is not a mixed library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
let absPath: string
try {
absPath = resolveAndJail(root, itemPath)
} catch {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
}
try {
const stat = fs.statSync(absPath)
if (stat.isDirectory()) {
fs.rmSync(absPath, { recursive: true, force: true })
} else {
fs.unlinkSync(absPath)
}
} catch {
return NextResponse.json({ error: 'Failed to delete' }, { status: 500 })
}
const db = getDb()
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(itemPath)}`
removeAllAssignmentsForItem(itemKey)
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey)
// For directories, also clean up children
const prefix = `${libraryId}:mixed_file:${encodeURIComponent(itemPath + '/')}`
const children = db.prepare('SELECT item_key FROM media_items WHERE item_key LIKE ?').all(`${prefix}%`) as { item_key: string }[]
for (const child of children) {
removeAllAssignmentsForItem(child.item_key)
}
db.prepare('DELETE FROM media_items WHERE item_key LIKE ?').run(`${prefix}%`)
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import archiver from 'archiver'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
@@ -20,13 +21,38 @@ const MIME_TYPES: Record<string, string> = {
'.tiff': 'image/tiff', '.tiff': 'image/tiff',
'.tif': 'image/tiff', '.tif': 'image/tiff',
'.zip': 'application/zip', '.zip': 'application/zip',
'.dmg': 'application/x-apple-diskimage',
'.gz': 'application/gzip',
'.tgz': 'application/gzip',
'.bz2': 'application/x-bzip2',
'.xz': 'application/x-xz',
'.zst': 'application/zstd',
} }
function getMimeType(filePath: string): string { function getMimeType(filePath: string): string {
// Special-case multi-part extensions before checking the last extension
const lower = filePath.toLowerCase()
if (lower.endsWith('.tar.gz')) return 'application/gzip'
if (lower.endsWith('.tar.bz2')) return 'application/x-bzip2'
if (lower.endsWith('.tar.xz')) return 'application/x-xz'
if (lower.endsWith('.tar.zst')) return 'application/zstd'
const ext = path.extname(filePath).toLowerCase() const ext = path.extname(filePath).toLowerCase()
return MIME_TYPES[ext] ?? 'application/octet-stream' return MIME_TYPES[ext] ?? 'application/octet-stream'
} }
function isDownloadAttachment(filePath: string): boolean {
const lower = filePath.toLowerCase()
return (
lower.endsWith('.zip') ||
lower.endsWith('.tar.gz') ||
lower.endsWith('.tar.bz2') ||
lower.endsWith('.tar.xz') ||
lower.endsWith('.tar.zst') ||
lower.endsWith('.tgz') ||
lower.endsWith('.dmg')
)
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId') const libraryId = searchParams.get('libraryId')
@@ -60,6 +86,25 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'File not found' }, { status: 404 }) return NextResponse.json({ error: 'File not found' }, { status: 404 })
} }
// .app bundle: stream the directory as a zip archive on the fly
if (stat.isDirectory() && subpath.toLowerCase().endsWith('.app')) {
const bundleName = path.basename(filePath)
const zipName = `${bundleName}.zip`
const archive = archiver('zip', { zlib: { level: 6 } })
archive.directory(filePath, bundleName)
archive.finalize()
return new NextResponse(archive as unknown as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`,
'Cache-Control': 'no-store',
},
})
}
if (!stat.isFile()) { if (!stat.isFile()) {
return NextResponse.json({ error: 'Not a file' }, { status: 400 }) return NextResponse.json({ error: 'Not a file' }, { status: 400 })
} }
@@ -68,9 +113,7 @@ export async function GET(request: NextRequest) {
const fileSize = stat.size const fileSize = stat.size
const rangeHeader = request.headers.get('range') const rangeHeader = request.headers.get('range')
// Handle ZIP as a download const contentDisposition = isDownloadAttachment(filePath)
const isZip = path.extname(filePath).toLowerCase() === '.zip'
const contentDisposition = isZip
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"` ? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"` : `inline; filename="${encodeURIComponent(path.basename(filePath))}"`

View File

@@ -4,6 +4,7 @@ import sharp from 'sharp'
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 { requireAdmin } from '@/lib/auth' import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
@@ -99,5 +100,16 @@ export async function POST(request: NextRequest) {
? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}` ? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}` : `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
// Update DB metadata so the new cover is visible without a re-scan
const db = getDb()
const itemKey = `${libraryId}:game:${itemId}`
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
if (row) {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
if (coverType === 'cover') meta.coverUrl = url
else meta.wideCoverUrl = url
db.prepare('UPDATE media_items SET metadata = ? WHERE item_key = ?').run(JSON.stringify(meta), itemKey)
}
return NextResponse.json({ url }, { status: 200 }) return NextResponse.json({ url }, { status: 200 })
} }

View File

@@ -0,0 +1,177 @@
import path from 'path'
import fs from 'fs'
import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireAdmin, requireLibraryAccess } from '@/lib/auth'
import { fileApiUrl, thumbnailApiUrl } from '@/lib/media-utils'
const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024 // 20 MB
type GameDirResult =
| { gameDir: string; screenshotsDir: string; folderPath: string }
| { error: string; status: number }
function getGameDir(libraryId: string, gameId: string): GameDirResult {
const library = getLibrary(libraryId)
if (!library) return { error: 'Library not found', status: 404 }
if (library.type !== 'games') return { error: 'Library is not a games library', status: 400 }
const libraryRoot = resolveLibraryRoot(library)
const folderPath = decodeURIComponent(gameId)
let gameDir: string
try {
gameDir = resolveAndJail(libraryRoot, folderPath)
} catch {
return { error: 'Invalid game path', status: 400 }
}
if (!fs.existsSync(gameDir)) return { error: 'Game folder not found', status: 404 }
return { gameDir, screenshotsDir: path.join(gameDir, 'screenshots'), folderPath }
}
// ─── GET: list screenshots ────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
if (!libraryId || !gameId) {
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const resolved = getGameDir(libraryId, gameId)
if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status })
const { screenshotsDir, folderPath } = resolved
if (!fs.existsSync(screenshotsDir)) {
return NextResponse.json({ screenshots: [] })
}
let files: string[]
try {
files = fs.readdirSync(screenshotsDir)
} catch {
return NextResponse.json({ screenshots: [] })
}
const screenshots = files
.filter((f) => SCREENSHOT_EXTENSIONS.has(path.extname(f).toLowerCase()))
.sort()
.map((filename) => {
const relPath = path.join(folderPath, 'screenshots', filename)
return {
filename,
url: fileApiUrl(libraryId, relPath),
thumbnailUrl: thumbnailApiUrl(libraryId, relPath),
}
})
return NextResponse.json({ screenshots })
}
// ─── POST: upload screenshot ──────────────────────────────────────────────────
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
if (!libraryId || !gameId) {
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
}
const resolved = getGameDir(libraryId, gameId)
if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status })
const { screenshotsDir, folderPath } = resolved
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
}
const file = formData.get('screenshot')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'screenshot field is required' }, { status: 400 })
}
if (file.size > MAX_SCREENSHOT_BYTES) {
return NextResponse.json({ error: 'File too large. Maximum size is 20 MB.' }, { status: 400 })
}
const rawBuffer = Buffer.from(await file.arrayBuffer())
let processedBuffer: Buffer
try {
processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer()
} catch {
return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 })
}
fs.mkdirSync(screenshotsDir, { recursive: true })
const filename = `shot-${Date.now()}.jpg`
fs.writeFileSync(path.join(screenshotsDir, filename), processedBuffer)
const relPath = path.join(folderPath, 'screenshots', filename)
return NextResponse.json(
{
filename,
url: fileApiUrl(libraryId, relPath),
thumbnailUrl: thumbnailApiUrl(libraryId, relPath),
},
{ status: 201 }
)
}
// ─── DELETE: remove screenshot ────────────────────────────────────────────────
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
const filename = searchParams.get('filename')
if (!libraryId || !gameId || !filename) {
return NextResponse.json({ error: 'Missing libraryId, gameId, or filename' }, { status: 400 })
}
// Filename must be a plain basename — no path separators, no traversal
if (filename !== path.basename(filename) || filename.includes('..')) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
}
const resolved = getGameDir(libraryId, gameId)
if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status })
const { screenshotsDir } = resolved
let filePath: string
try {
filePath = resolveAndJail(screenshotsDir, filename)
} catch {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
}
try {
fs.unlinkSync(filePath)
} catch {
return NextResponse.json({ error: 'File not found or could not be deleted' }, { status: 404 })
}
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,7 +1,10 @@
import fs from 'fs'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanGamesLibrary } from '@/lib/games' import { gamesFromDb } from '@/lib/games'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { getDb } from '@/lib/db'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
@@ -22,7 +25,48 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 }) return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
} }
const root = resolveLibraryRoot(library) return NextResponse.json(gamesFromDb(libraryId))
const games = scanGamesLibrary(root, libraryId) }
return NextResponse.json(games)
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
if (!libraryId || !gameId) {
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'games') {
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
const dirName = decodeURIComponent(gameId)
let gameDir: string
try {
gameDir = resolveAndJail(root, dirName)
} catch {
return NextResponse.json({ error: 'Invalid game path' }, { status: 400 })
}
try {
fs.rmSync(gameDir, { recursive: true, force: true })
} catch {
return NextResponse.json({ error: 'Failed to delete game directory' }, { status: 500 })
}
const itemKey = `${libraryId}:game:${gameId}`
removeAllAssignmentsForItem(itemKey)
getDb().prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey)
return new NextResponse(null, { status: 204 })
} }

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

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function PATCH(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const body = await request.json()
const { itemKey, title, year, plot, genres } = body as {
itemKey: string
title?: string
year?: number | null
plot?: string | null
genres?: string[]
}
if (!itemKey) {
return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 })
}
const db = getDb()
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
if (!row) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
const sets: string[] = []
const params: Record<string, unknown> = { item_key: itemKey }
if (title !== undefined) {
sets.push('title = @title')
params.title = title
}
if (year !== undefined) {
sets.push('year = @year')
params.year = year
}
if (plot !== undefined) {
sets.push('plot = @plot')
params.plot = plot
}
if (genres !== undefined) {
sets.push('genres = @genres')
params.genres = JSON.stringify(genres)
}
// Always mark as manually edited in the metadata blob
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {}
existingMeta.manuallyEdited = true
sets.push('metadata = @metadata')
params.metadata = JSON.stringify(existingMeta)
if (sets.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 })
}
db.prepare(`UPDATE media_items SET ${sets.join(', ')} WHERE item_key = @item_key`).run(params)
return NextResponse.json({ success: true })
}

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import path from 'path' 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 { scanMoviesLibrary } from '@/lib/movies' import { moviesFromDb } from '@/lib/movies'
import { removeAllAssignmentsForItem } from '@/lib/tags' import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth' import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
@@ -25,9 +25,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 }) return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
} }
const root = resolveLibraryRoot(library) return NextResponse.json(moviesFromDb(libraryId))
const movies = scanMoviesLibrary(root, libraryId)
return NextResponse.json(movies)
} }
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
@@ -66,7 +64,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 }) return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 })
} }
removeAllAssignmentsForItem(`${libraryId}:${movieId}`) removeAllAssignmentsForItem(`${libraryId}:movie:${movieId}`)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

@@ -0,0 +1,198 @@
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
import { findNfoFile } from '@/lib/movies'
import { parseMovieNfo, parseTvShowNfo, parseEpisodeNfo } from '@/lib/nfo'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const itemType = searchParams.get('itemType') as 'movie' | 'tv_series' | 'tv_episode' | null
const itemKey = searchParams.get('itemKey')
if (!libraryId || !itemType || !itemKey) {
return NextResponse.json({ error: 'Missing libraryId, itemType, or itemKey' }, { status: 400 })
}
if (!['movie', 'tv_series', 'tv_episode'].includes(itemType)) {
return NextResponse.json({ error: 'itemType must be movie, tv_series, or tv_episode' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
const db = getDb()
const row = db
.prepare('SELECT * FROM media_items WHERE item_key = ?')
.get(itemKey) as {
item_key: string
item_type: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
} | undefined
if (!row) {
return NextResponse.json({ error: 'Item not found in database' }, { status: 404 })
}
const libraryRoot = resolveLibraryRoot(library)
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {}
if (itemType === 'movie') {
// item_key: {libraryId}:movie:{encodedDirName}
const encodedDirName = itemKey.split(':movie:')[1]
if (!encodedDirName) {
return NextResponse.json({ error: 'Invalid item key' }, { status: 400 })
}
const dirName = decodeURIComponent(encodedDirName)
const movieDir = path.join(libraryRoot, dirName)
const nfoFileName = findNfoFile(movieDir, dirName)
if (!nfoFileName) {
return NextResponse.json({ updated: false, reason: 'no nfo found' })
}
const nfo = parseMovieNfo(path.join(movieDir, nfoFileName))
if (!nfo) {
return NextResponse.json({ updated: false, reason: 'nfo parse failed' })
}
db.prepare(`
UPDATE media_items SET
title = @title,
year = @year,
plot = @plot,
genres = @genres,
metadata = @metadata
WHERE item_key = @item_key
`).run({
item_key: itemKey,
title: nfo.title ?? row.title,
year: nfo.year ?? null,
plot: nfo.plot ?? null,
genres: JSON.stringify(nfo.genres ?? []),
metadata: JSON.stringify({
...existingMeta,
rating: nfo.rating ?? null,
runtime: nfo.runtime ?? null,
}),
})
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
}
if (itemType === 'tv_series') {
// item_key: {libraryId}:tv_series:{encodedDirName}
const encodedDirName = itemKey.split(':tv_series:')[1]
if (!encodedDirName) {
return NextResponse.json({ error: 'Invalid item key' }, { status: 400 })
}
const dirName = decodeURIComponent(encodedDirName)
const seriesDir = path.join(libraryRoot, dirName)
const nfoPath = path.join(seriesDir, 'tvshow.nfo')
const nfo = parseTvShowNfo(nfoPath)
if (!nfo) {
return NextResponse.json({ updated: false, reason: 'no nfo found' })
}
db.prepare(`
UPDATE media_items SET
title = @title,
year = @year,
plot = @plot,
genres = @genres,
metadata = @metadata
WHERE item_key = @item_key
`).run({
item_key: itemKey,
title: nfo.title ?? row.title,
year: nfo.year ?? null,
plot: nfo.plot ?? null,
genres: JSON.stringify(nfo.genres ?? []),
metadata: JSON.stringify({
...existingMeta,
status: nfo.status ?? null,
}),
})
// 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 (!row.file_path) {
return NextResponse.json({ updated: false, reason: 'no file_path in database' })
}
const episodeDir = path.join(libraryRoot, path.dirname(row.file_path))
const baseName = path.basename(row.file_path, path.extname(row.file_path))
const nfoPath = path.join(episodeDir, `${baseName}.nfo`)
const nfo = parseEpisodeNfo(nfoPath)
if (!nfo) {
return NextResponse.json({ updated: false, reason: 'no nfo found' })
}
db.prepare(`
UPDATE media_items SET
title = @title,
plot = @plot,
metadata = @metadata
WHERE item_key = @item_key
`).run({
item_key: itemKey,
title: nfo.title ?? row.title,
plot: nfo.plot ?? null,
metadata: JSON.stringify({
...existingMeta,
episodeNumber: nfo.episode ?? existingMeta.episodeNumber ?? null,
seasonNumber: nfo.season ?? existingMeta.seasonNumber ?? null,
aired: nfo.aired ?? null,
rating: nfo.rating ?? null,
}),
})
return NextResponse.json({ updated: true, title: nfo.title })
}
return NextResponse.json({ error: 'Unhandled itemType' }, { status: 400 })
}

200
src/app/api/rename/route.ts Normal file
View File

@@ -0,0 +1,200 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
import { reKeyMediaItem } from '@/lib/tags'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const body = await request.json()
const { libraryId, oldPath, newName, itemType } = body as {
libraryId: string
oldPath: string
newName: string
itemType: string
}
if (!libraryId || !oldPath || !newName || !itemType) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Validate newName
if (/[/\\]/.test(newName) || newName === '.' || newName === '..' || newName.startsWith('.')) {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
const root = resolveLibraryRoot(library)
let oldAbsPath: string
try {
oldAbsPath = resolveAndJail(root, oldPath)
} catch {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
}
// Compute new path by replacing the last segment
const parentDir = path.dirname(oldPath)
const newPath = parentDir === '.' ? newName : `${parentDir}/${newName}`
let newAbsPath: string
try {
newAbsPath = resolveAndJail(root, newPath)
} catch {
return NextResponse.json({ error: 'Invalid new path' }, { status: 400 })
}
// Collision check
if (fs.existsSync(newAbsPath)) {
return NextResponse.json({ error: 'A file or folder with that name already exists' }, { status: 409 })
}
// Perform filesystem rename
try {
fs.renameSync(oldAbsPath, newAbsPath)
} catch {
return NextResponse.json({ error: 'Failed to rename' }, { status: 500 })
}
// Update database records
const db = getDb()
const enc = encodeURIComponent
const oldEnc = enc(oldPath)
const newEnc = enc(newPath)
try {
db.transaction(() => {
switch (itemType) {
case 'movie': {
const oldKey = `${libraryId}:movie:${oldEnc}`
const newKey = `${libraryId}:movie:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ?, file_path = replace(file_path, ?, ?) WHERE item_key = ?')
.run(newKey, oldPath, newPath, oldKey)
reKeyMediaItem(oldKey, newKey)
break
}
case 'tv_series': {
const oldKey = `${libraryId}:tv_series:${oldEnc}`
const newKey = `${libraryId}:tv_series:${newEnc}`
// Update series
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
reKeyMediaItem(oldKey, newKey)
// Update seasons: item_key contains series id
const seasonRows = db.prepare(
"SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_season'"
).all(`${libraryId}:tv_season:${oldEnc}:%`) as { item_key: string }[]
for (const row of seasonRows) {
const newSeasonKey = row.item_key.replace(`:tv_season:${oldEnc}:`, `:tv_season:${newEnc}:`)
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?')
.run(newSeasonKey, newKey, row.item_key)
reKeyMediaItem(row.item_key, newSeasonKey)
}
// Update episodes: item_key and file_path contain series path
const epRows = db.prepare(
"SELECT item_key, file_path FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_episode'"
).all(`${libraryId}:tv_episode:${oldEnc}:%`) as { item_key: string; file_path: string | null }[]
for (const row of epRows) {
const newEpKey = row.item_key.replace(`:tv_episode:${oldEnc}:`, `:tv_episode:${newEnc}:`)
// Find new parent_key from the episode's season portion
const newParentKey = row.item_key
.replace(`:tv_episode:${oldEnc}:`, `:tv_season:${newEnc}:`)
.split(':')
.slice(0, -1) // Remove episode id portion — parent is season
// Actually, parent_key is the season key. We need to reconstruct it.
// Episode key format: libraryId:tv_episode:seriesId:seasonId:episodeId
// Season key format: libraryId:tv_season:seriesId:seasonId
const parts = newEpKey.split(':')
// parts: [libraryId, 'tv_episode', seriesEnc, seasonEnc, episodeEnc]
const seasonKey = `${parts[0]}:tv_season:${parts[2]}:${parts[3]}`
const newFilePath = row.file_path ? row.file_path.replace(oldPath, newPath) : null
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ?, file_path = ? WHERE item_key = ?')
.run(newEpKey, seasonKey, newFilePath, row.item_key)
reKeyMediaItem(row.item_key, newEpKey)
}
break
}
case 'tv_episode': {
const oldKey = `${libraryId}:tv_episode:${oldEnc}`
const newKey = `${libraryId}:tv_episode:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
.run(newKey, newPath, oldKey)
reKeyMediaItem(oldKey, newKey)
break
}
case 'game': {
const oldKey = `${libraryId}:game:${oldEnc}`
const newKey = `${libraryId}:game:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
reKeyMediaItem(oldKey, newKey)
break
}
case 'game_series': {
const oldKey = `${libraryId}:game_series:${oldEnc}`
const newKey = `${libraryId}:game_series:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
reKeyMediaItem(oldKey, newKey)
// Update child games
const gameRows = db.prepare(
"SELECT item_key FROM media_items WHERE parent_key = ? AND item_type = 'game'"
).all(oldKey) as { item_key: string }[]
for (const row of gameRows) {
const newGameKey = row.item_key.replace(`:game:${oldEnc}`, `:game:${newEnc}`)
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?')
.run(newGameKey, newKey, row.item_key)
reKeyMediaItem(row.item_key, newGameKey)
}
break
}
case 'mixed_file': {
const oldKey = `${libraryId}:mixed_file:${oldEnc}`
const newKey = `${libraryId}:mixed_file:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
.run(newKey, newPath, oldKey)
reKeyMediaItem(oldKey, newKey)
// If directory, update all children
const childRows = db.prepare(
"SELECT item_key, file_path FROM media_items WHERE item_key LIKE ?"
).all(`${libraryId}:mixed_file:${enc(oldPath + '/')}%`) as { item_key: string; file_path: string | null }[]
for (const row of childRows) {
const newChildKey = row.item_key.replace(
`mixed_file:${enc(oldPath + '/')}`,
`mixed_file:${enc(newPath + '/')}`
)
const newChildPath = row.file_path ? row.file_path.replace(oldPath + '/', newPath + '/') : null
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
.run(newChildKey, newChildPath, row.item_key)
reKeyMediaItem(row.item_key, newChildKey)
}
break
}
}
})()
} catch (err) {
// Attempt to rollback filesystem rename on DB failure
try { fs.renameSync(newAbsPath, oldAbsPath) } catch { /* best effort */ }
return NextResponse.json({ error: 'Database update failed' }, { status: 500 })
}
return NextResponse.json({ newName, newPath })
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary } from '@/lib/libraries'
import { isScanRunning, runSingleLibraryScan } from '@/lib/scanner'
import { requireAdmin } from '@/lib/auth'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (isScanRunning()) {
return NextResponse.json({ error: 'Scan already in progress' }, { status: 409 })
}
// Fire-and-forget
void runSingleLibraryScan(library)
return new NextResponse(null, { status: 202 })
}

View File

@@ -1,28 +1,28 @@
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(mediaKey: string): string | null { function extractLibraryId(itemKey: string): string | null {
const colonIdx = mediaKey.indexOf(':') const colonIdx = itemKey.indexOf(':')
if (colonIdx === -1) return null if (colonIdx === -1) return null
return mediaKey.slice(0, colonIdx) return itemKey.slice(0, colonIdx)
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey') const itemKey = searchParams.get('itemKey')
if (!mediaKey) { if (!itemKey) {
return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
} }
const libraryId = extractLibraryId(mediaKey) const libraryId = extractLibraryId(itemKey)
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
return NextResponse.json(getResolvedTagsForItem(mediaKey)) return NextResponse.json(getResolvedTagsForItem(itemKey))
} catch (err) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 }) return NextResponse.json({ error: (err as Error).message }, { status: 500 })
} }
@@ -30,18 +30,18 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { mediaKey, tagId } = await request.json() const { itemKey, tagId } = await request.json()
if (!mediaKey || !tagId) { if (!itemKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
} }
const libraryId = extractLibraryId(mediaKey) const libraryId = extractLibraryId(itemKey)
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { 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(mediaKey, tagId) addTagToItem(itemKey, tagId)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} catch (err) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 }) return NextResponse.json({ error: (err as Error).message }, { status: 400 })
@@ -51,19 +51,19 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey') const itemKey = searchParams.get('itemKey')
const tagId = searchParams.get('tagId') const tagId = searchParams.get('tagId')
if (!mediaKey || !tagId) { if (!itemKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
} }
const libraryId = extractLibraryId(mediaKey) const libraryId = extractLibraryId(itemKey)
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { 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(mediaKey, tagId) removeTagFromItem(itemKey, tagId)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} catch (err) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 }) return NextResponse.json({ error: (err as Error).message }, { status: 400 })

View File

@@ -2,9 +2,10 @@ import fs from 'fs'
import path from 'path' 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 { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv' import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv'
import { removeAllAssignmentsForItem } from '@/lib/tags' import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth' import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
@@ -27,20 +28,15 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 }) return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
} }
const root = resolveLibraryRoot(library)
if (seriesId && seasonId) { if (seriesId && seasonId) {
const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId) return NextResponse.json(tvEpisodesFromDb(libraryId, seriesId, seasonId))
return NextResponse.json(episodes)
} }
if (seriesId) { if (seriesId) {
const seasons = scanTvSeasons(root, libraryId, seriesId) return NextResponse.json(tvSeasonsFromDb(libraryId, seriesId))
return NextResponse.json(seasons)
} }
const series = scanTvLibrary(root, libraryId) return NextResponse.json(tvSeriesFromDb(libraryId))
return NextResponse.json(series)
} }
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
@@ -50,6 +46,7 @@ export async function DELETE(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId') const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId') const seriesId = searchParams.get('seriesId')
const episodeKey = searchParams.get('episodeKey')
if (!libraryId || !seriesId) { if (!libraryId || !seriesId) {
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 }) return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
@@ -64,6 +61,38 @@ export async function DELETE(request: NextRequest) {
} }
const root = resolveLibraryRoot(library) const root = resolveLibraryRoot(library)
// Episode-level delete
if (episodeKey) {
const db = getDb()
const row = db.prepare('SELECT file_path FROM media_items WHERE item_key = ?').get(episodeKey) as { file_path: string | null } | undefined
if (!row?.file_path) {
return NextResponse.json({ error: 'Episode not found' }, { status: 404 })
}
let episodePath: string
try {
episodePath = resolveAndJail(root, row.file_path)
} catch {
return NextResponse.json({ error: 'Invalid episode path' }, { status: 400 })
}
try {
fs.unlinkSync(episodePath)
// Also remove sidecar NFO if it exists
const nfoPath = episodePath.replace(path.extname(episodePath), '.nfo')
if (fs.existsSync(nfoPath)) fs.unlinkSync(nfoPath)
} catch {
return NextResponse.json({ error: 'Failed to delete episode file' }, { status: 500 })
}
removeAllAssignmentsForItem(episodeKey)
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(episodeKey)
return new NextResponse(null, { status: 204 })
}
// Series-level delete
const dirName = decodeURIComponent(seriesId) const dirName = decodeURIComponent(seriesId)
let seriesDir: string let seriesDir: string
@@ -79,7 +108,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 }) return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
} }
removeAllAssignmentsForItem(`${libraryId}:${seriesId}`) removeAllAssignmentsForItem(`${libraryId}:tv_series:${seriesId}`)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSeriesEpisodeTagMap } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 })
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
return NextResponse.json(getSeriesEpisodeTagMap(libraryId))
}

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="-5.5 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>android</title>
<path d="M14.563 4.344l-1.219 1.719c1.906 0.906 3.281 2.594 3.438 4.563h-13c0.156-1.969 1.5-3.656 3.406-4.563l-1.219-1.719c-0.063-0.125-0.031-0.25 0.063-0.313s0.219-0.031 0.313 0.063l1.25 1.813c0.813-0.313 1.719-0.5 2.688-0.5s1.844 0.188 2.688 0.5l1.25-1.813c0.063-0.094 0.188-0.125 0.281-0.063s0.125 0.188 0.063 0.313zM7.531 8.813c0.406 0 0.719-0.313 0.719-0.719 0-0.375-0.313-0.719-0.719-0.719-0.375 0-0.719 0.344-0.719 0.719 0 0.406 0.344 0.719 0.719 0.719zM13.094 8.813c0.406 0 0.719-0.313 0.719-0.719 0-0.375-0.313-0.719-0.719-0.719-0.375 0-0.719 0.344-0.719 0.719 0 0.406 0.344 0.719 0.719 0.719zM0 18.781v-5.781c0-0.813 0.625-1.5 1.469-1.5 0.813 0 1.438 0.688 1.438 1.5v5.781c0 0.844-0.625 1.5-1.438 1.5-0.844 0-1.469-0.656-1.469-1.5zM17.594 18.781v-5.781c0-0.813 0.656-1.5 1.469-1.5s1.469 0.688 1.469 1.5v5.781c0 0.844-0.656 1.5-1.469 1.5s-1.469-0.656-1.469-1.5zM3.813 22.125v-10.594h13v10.594c0 0.625-0.531 1.156-1.156 1.156h-1.281v3.281c0 0.813-0.656 1.469-1.469 1.469s-1.469-0.656-1.469-1.469v-3.281h-2.281v3.281c0 0.813-0.625 1.469-1.438 1.469-0.844 0-1.469-0.656-1.469-1.469v-3.281h-1.313c-0.594 0-1.125-0.531-1.125-1.156z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

6
src/app/icons/linux.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 76 76" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" enable-background="new 0 0 76.00 76.00" xml:space="preserve">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 35.625,29.6875C 36.4995,29.6875 37.2083,30.3964 37.2083,31.2708C 37.2083,32.1453 36.4994,32.8542 35.625,32.8542C 34.7505,32.8542 34.0417,32.1453 34.0417,31.2708C 34.0417,30.3964 34.7505,29.6875 35.625,29.6875 Z M 40.7708,29.6875C 41.6453,29.6875 42.3542,30.3964 42.3542,31.2708C 42.3542,32.1453 41.6453,32.8542 40.7708,32.8542C 39.8964,32.8542 39.1875,32.1453 39.1875,31.2708C 39.1875,30.3964 39.8964,29.6875 40.7708,29.6875 Z M 25.6695,50.3757C 24.9442,48.0415 24.5417,45.4621 24.5417,42.75L 24.5568,41.8418C 22.3238,43.0668 19.8176,44.3333 19,44.3333C 16.8873,44.3333 20.8257,39.4499 25.9794,34.1962C 26.4655,32.8374 27.0638,31.5722 27.7572,30.4249C 28.2641,24.0121 32.6565,19 38,19C 43.3435,19 47.7358,24.0121 48.2428,30.4249C 48.9362,31.5722 49.5345,32.8374 50.0206,34.1962C 55.1743,39.4499 59.1127,44.3333 57,44.3333C 56.1824,44.3333 53.6762,43.0669 51.4432,41.8418L 51.4583,42.75C 51.4583,45.4621 51.0558,48.0415 50.3305,50.3757L 48.2917,49.875C 48.2917,43.7841 45.5317,38.5857 41.649,36.5467L 38,42.75L 34.3514,36.5475C 30.4685,38.59 27.7084,43.8045 27.7085,49.9664L 25.6695,50.3757 Z M 34.0416,26.125C 31.8555,26.125 30.0833,28.2517 30.0833,30.875C 30.0833,33.4984 31.8555,35.625 34.0416,35.625C 36.2278,35.625 38,33.4984 38,30.875C 38,28.2517 36.2278,26.125 34.0416,26.125 Z M 38,30.875C 38,32.6239 39.7722,34.4375 41.9583,34.4375C 44.1444,34.4375 45.9166,32.6239 45.9166,30.875C 45.9166,29.1261 44.1444,27.3125 41.9583,27.3125C 39.7722,27.3125 38,29.1261 38,30.875 Z M 30.0833,50.6667C 33.1473,50.6667 35.7032,52.0266 36.29,53.8333L 36.8125,53.8333L 36.8125,55.4167L 36.29,55.4167C 35.7032,57.2234 33.1473,58.5833 30.0833,58.5833C 26.5855,58.5833 23.75,56.8111 23.75,54.625C 23.75,52.4389 26.5855,50.6667 30.0833,50.6667 Z M 45.9166,50.6667C 49.4144,50.6667 52.25,52.4389 52.25,54.625C 52.25,56.8111 49.4144,58.5833 45.9166,58.5833C 42.8526,58.5833 40.2968,57.2234 39.71,55.4167L 39.1875,55.4167L 39.1875,53.8333L 39.71,53.8333C 40.2968,52.0266 42.8526,50.6667 45.9166,50.6667 Z "/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

19
src/app/icons/mac.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>apple [#173]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

19
src/app/icons/windows.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>windows [#174]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-60.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M13.1458647,7289.43426 C13.1508772,7291.43316 13.1568922,7294.82929 13.1619048,7297.46884 C16.7759398,7297.95757 20.3899749,7298.4613 23.997995,7299 C23.997995,7295.84873 24.002005,7292.71146 23.997995,7289.71311 C20.3809524,7289.71311 16.7649123,7289.43426 13.1458647,7289.43426 M4,7289.43526 L4,7296.22153 C6.72581454,7296.58933 9.45162907,7296.94113 12.1724311,7297.34291 C12.1774436,7294.71736 12.1704261,7292.0908 12.1704261,7289.46524 C9.44661654,7289.47024 6.72380952,7289.42627 4,7289.43526 M4,7281.84344 L4,7288.61071 C6.72581454,7288.61771 9.45162907,7288.57673 12.1774436,7288.57973 C12.1754386,7285.96017 12.1754386,7283.34361 12.1724311,7280.72405 C9.44461153,7281.06486 6.71679198,7281.42567 4,7281.84344 M24,7288.47179 C20.3879699,7288.48578 16.7759398,7288.54075 13.1619048,7288.55175 C13.1598997,7285.88921 13.1598997,7283.22967 13.1619048,7280.56914 C16.7689223,7280.01844 20.3839599,7279.50072 23.997995,7279 C24,7282.15826 23.997995,7285.31353 24,7288.47179" id="windows-[#174]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,11 +1,12 @@
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'
import TvView from '@/components/tv/TvView' import TvView from '@/components/tv/TvView'
import ScanLibraryButton from '@/components/ScanLibraryButton'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -22,13 +23,16 @@ 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>
{library.type !== 'mixed' && (
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}> <a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
Libraries Libraries
@@ -37,12 +41,23 @@ export default async function LibraryPage({ params, searchParams }: Props) {
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{library.name} {library.name}
</span> </span>
{session.role === 'admin' && (
<div className="ml-auto">
<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>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -286,7 +286,10 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
return return
} }
// Success — reset form // Success — fire scan for the new library (fire-and-forget)
void fetch(`/api/scan/${encodeURIComponent((data as { id: string }).id)}`, { method: 'POST' })
// Reset form
setName('') setName('')
setLibPath('') setLibPath('')
setType('games') setType('games')

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,24 +272,41 @@ 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"
/>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{lib.name} {lib.name}
</span> </span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}> <span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
({lib.type}) ({lib.type})
</span> </span>
</label> </div>
<div
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
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
onClick={save} onClick={save}

View File

@@ -7,15 +7,18 @@ export interface DoomScrollItem {
url: string url: string
name: string name: string
mediaType: 'video' | 'image' mediaType: 'video' | 'image'
mediaKey?: string itemKey?: string
} }
interface Props { interface Props {
items: DoomScrollItem[] items: DoomScrollItem[]
videoContext?: 'mixed' | 'movies' | 'tv' videoContext?: 'mixed' | 'movies' | 'tv'
onClose: () => void onClose: () => void
onViewInLibrary?: (item: DoomScrollItem) => void
} }
const HISTORY_CAP = 100
function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem { function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem {
const excludeCount = Math.min(excludeRecent.length, items.length - 1) const excludeCount = Math.min(excludeRecent.length, items.length - 1)
const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url)) const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url))
@@ -24,7 +27,7 @@ function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): D
return pool[Math.floor(Math.random() * pool.length)] return pool[Math.floor(Math.random() * pool.length)]
} }
export default function DoomScrollView({ items, videoContext = 'mixed', onClose }: Props) { export default function DoomScrollView({ items, videoContext = 'mixed', onClose, onViewInLibrary }: Props) {
const settings = useUserSettings() const settings = useUserSettings()
const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted
@@ -38,7 +41,17 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false) const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5) const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
// Text overlay state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null)
const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false)
const [extracting, setExtracting] = useState(false)
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)
@@ -46,6 +59,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
const isVideo = current?.mediaType === 'video' const isVideo = current?.mediaType === 'video'
const backCount = history.length - 1 - historyIndex const backCount = history.length - 1 - historyIndex
// Derived: what text to display in the overlay
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
const goNext = useCallback(() => { const goNext = useCallback(() => {
if (items.length === 0) return if (items.length === 0) return
setHistoryIndex((idx) => { setHistoryIndex((idx) => {
@@ -55,9 +71,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
const next = pickRandom(items, history) const next = pickRandom(items, history)
setHistory((h) => { setHistory((h) => {
const updated = [...h, next] const updated = [...h, next]
return updated.length > 100 ? updated.slice(-100) : updated return updated.length > HISTORY_CAP ? updated.slice(-HISTORY_CAP) : updated
}) })
return idx + 1 return Math.min(idx + 1, HISTORY_CAP - 1)
}) })
}, [items, history]) }, [items, history])
@@ -73,9 +89,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
setTimeout(() => { cooldownRef.current = false }, 300) setTimeout(() => { cooldownRef.current = false }, 300)
}, [goNext, goPrev]) }, [goNext, goPrev])
// Reset pause when switching items // On navigation to a new item: reset pause state and start playing.
// Merging the reset + play() into one effect prevents the old isPaused=true
// value from calling pause() on the freshly-mounted video element before the
// reset fires. If autoplay is blocked by browser policy (common when unmuted),
// fall back to muted and retry — the user can unmute manually afterward.
useEffect(() => { useEffect(() => {
setIsPaused(false) setIsPaused(false)
if (!videoRef.current) return
videoRef.current.play().catch(() => {
if (!videoRef.current) return
videoRef.current.muted = true
setLocalMuted(true)
videoRef.current.play().catch(() => {})
})
}, [current?.url]) }, [current?.url])
// Sync muted imperatively — React's muted prop is not reliable // Sync muted imperatively — React's muted prop is not reliable
@@ -83,7 +110,8 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
if (videoRef.current) videoRef.current.muted = localMuted if (videoRef.current) videoRef.current.muted = localMuted
}, [localMuted, current?.url]) }, [localMuted, current?.url])
// Sync play/pause imperatively // Sync play/pause imperatively for user-initiated pause/unpause only.
// current?.url is intentionally excluded: navigation is handled above.
useEffect(() => { useEffect(() => {
if (!videoRef.current) return if (!videoRef.current) return
if (isPaused) { if (isPaused) {
@@ -91,7 +119,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
} else { } else {
videoRef.current.play().catch(() => {}) videoRef.current.play().catch(() => {})
} }
}, [isPaused, current?.url]) }, [isPaused])
// Auto-play timer — resets on each new item, pause, enable/disable, or interval change // Auto-play timer — resets on each new item, pause, enable/disable, or interval change
useEffect(() => { useEffect(() => {
@@ -100,11 +128,44 @@ 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; clear any in-flight poll on item change
useEffect(() => {
if (extractPollRef.current) {
clearInterval(extractPollRef.current)
extractPollRef.current = null
}
setExtractedText(null)
setTranslatedText(null)
setShowTextOverlay(false)
setShowOriginal(false)
setExtracting(false)
setExtractError(null)
setExtractPending(false)
if (!current?.itemKey) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
.then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
setExtractedText(data.extractedText)
setTranslatedText(data.extractedTextTranslated)
})
.catch(() => {})
}, [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 }
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') } if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') }
if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') } if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') }
if (e.key === 't' || e.key === 'T') {
if (extractedText) setShowTextOverlay((v) => !v)
}
} }
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
e.preventDefault() e.preventDefault()
@@ -133,7 +194,59 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
document.removeEventListener('touchend', handleTouchEnd) document.removeEventListener('touchend', handleTouchEnd)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [navigate, onClose]) }, [navigate, onClose, extractedText])
const handleExtractText = async () => {
if (!current?.itemKey) return
const itemKey = current.itemKey
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 }),
})
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) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
}
const result = await res.json()
setExtractedText(result.extractedText || null)
setTranslatedText(result.translatedText || null)
if (result.extractedText) setShowTextOverlay(true)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}
return ( return (
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}> <div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
@@ -205,8 +318,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
loop={!autoPlayEnabled} loop={!autoPlayEnabled}
muted={localMuted} muted={localMuted}
playsInline playsInline
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain cursor-pointer"
style={{ backgroundColor: '#000' }} style={{ backgroundColor: '#000' }}
onClick={() => setIsPaused((v) => !v)}
/> />
) : current?.mediaType === 'image' ? ( ) : current?.mediaType === 'image' ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -219,32 +333,116 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
) : null} ) : null}
</div> </div>
{/* Bottom bar: mute | filename | play-pause */} {/* Text overlay */}
{showTextOverlay && displayText && (
<div
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)' }}
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>
)}
{/* Bottom bar: mute | filename | action buttons */}
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10"> <div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
<div className="w-9 flex-shrink-0"> <div className="w-9 flex-shrink-0">
{isVideo && ( {isVideo && (
<button <button
onClick={() => setLocalMuted((v) => !v)} onClick={() => setLocalMuted((v) => !v)}
className="w-9 h-9 rounded-full flex items-center justify-center text-base transition-opacity hover:opacity-100 opacity-70" className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label={localMuted ? 'Unmute' : 'Mute'} aria-label={localMuted ? 'Unmute' : 'Mute'}
> >
{localMuted ? '🔇' : '🔊'} {localMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/>
<line x1="17" y1="9" x2="23" y2="15"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
)}
</button> </button>
)} )}
</div> </div>
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}> <span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
{current?.name} {current?.name}
</span> </span>
<div className="w-9 flex-shrink-0 flex justify-end"> <div className="flex-shrink-0 flex items-center gap-1">
{isVideo && ( {extractedText ? (
<button <button
onClick={() => setIsPaused((v) => !v)} onClick={() => setShowTextOverlay((v) => !v)}
className="w-9 h-9 rounded-full flex items-center justify-center text-sm transition-opacity hover:opacity-100 opacity-70" className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }} style={{
aria-label={isPaused ? 'Play' : 'Pause'} backgroundColor: showTextOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
color: '#fff',
}}
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
> >
{isPaused ? '▶' : '⏸'} <svg width="16" height="16" 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="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/>
</svg>
</button>
) : current?.itemKey && current?.mediaType === 'image' ? (
<button
onClick={handleExtractText}
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"
style={{
backgroundColor: extractPending
? 'var(--accent)'
: extractError
? 'rgba(127,29,29,0.8)'
: 'rgba(0,0,0,0.5)',
color: extractError ? '#fca5a5' : '#fff',
}}
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
>
{extracting || extractPending ? (
<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">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
)}
</button>
) : null}
{onViewInLibrary && current?.itemKey && (
<button
onClick={(e) => { e.stopPropagation(); onViewInLibrary(current) }}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label="View in library"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button> </button>
)} )}
</div> </div>

View File

@@ -8,6 +8,7 @@ const TABS = [
{ href: '/manage/tags', label: 'Tags' }, { href: '/manage/tags', label: 'Tags' },
{ href: '/manage/users', label: 'Users' }, { href: '/manage/users', label: 'Users' },
{ href: '/manage/scanning', label: 'Scanning' }, { href: '/manage/scanning', label: 'Scanning' },
{ href: '/manage/ai-tagging', label: 'AI Integrations' },
] ]
export default function ManageSubNav() { export default function ManageSubNav() {

View File

@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
interface Props {
libraryId: string
}
export default function ScanLibraryButton({ libraryId }: Props) {
const [scanning, setScanning] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const handleScan = async () => {
setScanning(true)
setMessage(null)
try {
const res = await fetch(`/api/scan/${encodeURIComponent(libraryId)}`, { method: 'POST' })
if (res.status === 409) {
setMessage('A scan is already in progress.')
}
} catch {
setMessage('Failed to start scan.')
} finally {
setScanning(false)
}
}
return (
<div className="flex items-center gap-3">
<button
onClick={handleScan}
disabled={scanning}
className="text-sm px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!scanning) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{scanning ? 'Scanning…' : 'Scan'}
</button>
{message && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{message}
</span>
)}
</div>
)
}

View File

@@ -1,28 +1,136 @@
'use client' 'use client'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game } 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 WindowsIcon from '@/app/icons/windows.svg'
import LinuxIcon from '@/app/icons/linux.svg'
import MacosIcon from '@/app/icons/mac.svg'
import AndroidIcon from '@/app/icons/android.svg'
// Update the PLATFORM_LABELS to include android
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
android: 'AND',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#85c0ec',
linux: '#efd27b',
macos: '#b0b0b7',
android: '#9ee0ca',
}
interface Props { 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
readOnly?: boolean
} }
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: 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 [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [editingImages, setEditingImages] = useState(false) const [editingImages, setEditingImages] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [renaming, setRenaming] = useState(false)
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [aiDescription, setAiDescription] = useState<string | null>(null)
// Screenshots state
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
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(() => {
setScreenshotsLoading(true)
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
.then((r) => r.json())
.then((data) => setScreenshots(data.screenshots ?? []))
.catch(() => {})
.finally(() => setScreenshotsLoading(false))
}, [libraryId, game.id])
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 files = Array.from(e.target.files ?? [])
if (files.length === 0) return
e.target.value = ''
for (const file of files) {
setUploadingCount((n) => n + 1)
const form = new FormData()
form.append('screenshot', file)
try {
await fetch(
`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`,
{ method: 'POST', body: form }
)
} catch { /* ignore */ }
finally { setUploadingCount((n) => n - 1) }
}
fetchScreenshots()
}
const handleDeleteScreenshot = async (filename: string) => {
setDeletingScreenshot(filename)
try {
await fetch(
`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}&filename=${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
)
} catch { /* ignore */ }
finally {
setDeletingScreenshot(null)
fetchScreenshots()
}
}
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (lightboxIndex !== null) {
if (e.key === 'Escape') { setLightboxIndex(null); return }
if (e.key === 'ArrowLeft') { setLightboxIndex((i) => (i! > 0 ? i! - 1 : i)); return }
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); 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 (renaming) { setRenaming(false); return }
if (editingImages) { setEditingImages(false); return } if (editingImages) { setEditingImages(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose() onClose()
} }
} }
@@ -32,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]) }, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -50,20 +158,36 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const zipDownloadUrl = (zipPath: string) => const [clientPlatform, setClientPlatform] = useState<GamePlatform | null>(null)
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}` useEffect(() => {
const p = navigator.platform.toLowerCase()
if (p.startsWith('win')) setClientPlatform('windows')
else if (p.startsWith('mac') || p.includes('iphone') || p.includes('ipad')) setClientPlatform('macos')
else setClientPlatform('linux')
}, [])
const fileDownloadUrl = (filePath: string) =>
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(filePath)}`
const heroImage = game.wideCoverUrl ?? game.coverUrl const heroImage = game.wideCoverUrl ?? game.coverUrl
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}
> >
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
{/* ── Left pane — relative container for floating controls ── */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
{/* Scrollable card area */}
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div <div
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl" className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
> >
{editingImages ? ( {editingImages ? (
<ImageEditor <ImageEditor
@@ -74,17 +198,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
/> />
) : ( ) : (
<> <>
{/* 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>
{/* Hero image */} {/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}> <div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
@@ -99,13 +212,13 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
{/* Info */} {/* Info */}
<div className="p-5"> <div className="p-5">
{/* Title row with kebab menu */} {/* Title row with kebab menu */}
<div className="flex items-center gap-2 mb-4"> <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)' }}> <h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{game.title} {game.title}
</h2> </h2>
{/* 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)} onClick={() => setMenuOpen((o) => !o)}
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"
@@ -130,36 +243,405 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
> >
Edit images Edit images
</button> </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>
)} )}
</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> </div>
))}
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} /> <button
onClick={() => screenshotInputRef.current?.click()}
{/* Tags */} className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}> style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}> onMouseEnter={(e) => {
Tags ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
</p> ;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} /> }}
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>
</> </>
)} )}
</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
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>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && (
<MediaTagPanel
itemKey={game.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>
{/* Screenshot lightbox (z-60, sits above the modal) */}
{lightboxIndex !== null && (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.92)', zIndex: 60 }}
onClick={() => setLightboxIndex(null)}
>
<div
className="relative flex items-center justify-center w-full h-full p-8"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={screenshots[lightboxIndex].url}
alt={`Screenshot ${lightboxIndex + 1}`}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
/>
{/* Close */}
<button
onClick={() => setLightboxIndex(null)}
className="absolute top-4 right-4 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
aria-label="Close"
>
</button>
{/* Prev */}
{lightboxIndex > 0 && (
<button
onClick={() => setLightboxIndex((i) => i! - 1)}
className="absolute left-4 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
aria-label="Previous screenshot"
>
</button>
)}
{/* Next */}
{lightboxIndex < screenshots.length - 1 && (
<button
onClick={() => setLightboxIndex((i) => i! + 1)}
className="absolute right-4 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
aria-label="Next screenshot"
>
</button>
)}
{/* Counter */}
<div
className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs px-3 py-1 rounded-full"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'rgba(255,255,255,0.7)' }}
>
{lightboxIndex + 1} / {screenshots.length}
</div>
</div>
</div>
)}
</div>
) )
} }
// ─── Download Button ────────────────────────────────────────────────────────── // ─── Download Button ──────────────────────────────────────────────────────────
const PLATFORM_ICONS: Record<GamePlatform, string> = {
windows: (typeof WindowsIcon === 'string' ? WindowsIcon : (WindowsIcon as { src: string }).src),
linux: (typeof LinuxIcon === 'string' ? LinuxIcon : (LinuxIcon as { src: string }).src),
macos: (typeof MacosIcon === 'string' ? MacosIcon : (MacosIcon as { src: string }).src),
android: (typeof AndroidIcon === 'string' ? AndroidIcon : (AndroidIcon as { src: string }).src),
}
function PlatformPill({ platform }: { platform: GamePlatform }) {
const src = PLATFORM_ICONS[platform]
return (
<span
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex-shrink-0 flex items-center gap-1"
style={{ backgroundColor: PLATFORM_COLORS[platform], color: '#fff' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
{src && <img src={src} alt="" width={14} height={14} aria-hidden="true" />}
<span className="sr-only">{PLATFORM_LABELS[platform]}</span>
</span>
)
}
function DownloadButton({ function DownloadButton({
zipFiles, gameFiles,
clientPlatform,
downloadUrl, downloadUrl,
}: { }: {
zipFiles: string[] gameFiles: GameFile[]
downloadUrl: (zipPath: string) => string clientPlatform: GamePlatform | null
downloadUrl: (filePath: string) => string
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -175,13 +657,17 @@ function DownloadButton({
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler)
}, [open, close]) }, [open, close])
const primary = zipFiles[0] if (gameFiles.length === 0) return null
const primaryName = primary.split('/').pop() ?? primary
if (zipFiles.length === 1) { // Pick primary: first file matching clientPlatform, or first overall
const primary =
(clientPlatform ? gameFiles.find((f) => f.platform === clientPlatform) : null) ??
gameFiles[0]
if (gameFiles.length === 1) {
return ( return (
<a <a
href={downloadUrl(primary)} href={downloadUrl(primary.path)}
download download
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors" 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' }} style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
@@ -189,7 +675,9 @@ function DownloadButton({
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
> >
<span></span> <span></span>
Download .zip <span className="truncate">{primary.filename}</span>
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
</a> </a>
) )
} }
@@ -199,15 +687,16 @@ function DownloadButton({
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}> <div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
{/* Primary download */} {/* Primary download */}
<a <a
href={downloadUrl(primary)} href={downloadUrl(primary.path)}
download download
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors" className="flex items-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors min-w-0"
style={{ color: '#fff' }} style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
> >
<span></span> <span className="flex-shrink-0"></span>
{primaryName} <span className="truncate">{primary.filename}</span>
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
</a> </a>
{/* Divider */} {/* Divider */}
@@ -216,7 +705,7 @@ function DownloadButton({
{/* Dropdown toggle */} {/* Dropdown toggle */}
<button <button
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="px-3 flex items-center justify-center text-sm transition-colors" className="px-3 flex items-center justify-center text-sm transition-colors flex-shrink-0"
style={{ color: '#fff' }} style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
@@ -231,12 +720,10 @@ function DownloadButton({
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20" className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> >
{zipFiles.map((zipPath) => { {gameFiles.map((file) => (
const name = zipPath.split('/').pop() ?? zipPath
return (
<a <a
key={zipPath} key={file.path}
href={downloadUrl(zipPath)} href={downloadUrl(file.path)}
download download
onClick={close} onClick={close}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
@@ -244,11 +731,11 @@ function DownloadButton({
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')}
> >
<span style={{ color: 'var(--text-secondary)' }}></span> <span style={{ color: 'var(--text-secondary)' }} className="flex-shrink-0"></span>
{name} <span className="truncate">{file.filename}</span>
<PlatformPill platform={file.platform} />
</a> </a>
) ))}
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,15 +1,67 @@
'use client' 'use client'
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import type { Game, GameSeries } from '@/types' import type { Game, GamePlatform, GameSeries } from '@/types'
import GameDetailModal from './GameDetailModal' import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
interface Props { // Import SVG icons
libraryId: string import WindowsIcon from '@/app/icons/windows.svg'
import LinuxIcon from '@/app/icons/linux.svg'
import MacosIcon from '@/app/icons/mac.svg'
import AndroidIcon from '@/app/icons/android.svg'
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
android: 'AND',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#85c0ec',
linux: '#efd27b',
macos: '#b0b0b7',
android: '#9ee0ca',
} }
export default function GamesView({ libraryId }: Props) { const PLATFORM_ICONS: Record<GamePlatform, string> = {
windows: (typeof WindowsIcon === 'string' ? WindowsIcon : (WindowsIcon as { src: string }).src),
linux: (typeof LinuxIcon === 'string' ? LinuxIcon : (LinuxIcon as { src: string }).src),
macos: (typeof MacosIcon === 'string' ? MacosIcon : (MacosIcon as { src: string }).src),
android: (typeof AndroidIcon === 'string' ? AndroidIcon : (AndroidIcon as { src: string }).src),
}
function getPlatformIcon(platform: GamePlatform) {
const src = PLATFORM_ICONS[platform]
if (!src) return null
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="" width={14} height={14} aria-hidden="true" />
}
function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
if (platforms.length === 0) return null
return (
<div className="flex gap-1 flex-wrap">
{platforms.map((p) => (
<span
key={p}
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex items-center gap-1"
style={{ backgroundColor: PLATFORM_COLORS[p], color: '#fff' }}
>
{getPlatformIcon(p)}
<span className="sr-only">{PLATFORM_LABELS[p]}</span>
</span>
))}
</div>
)
}
interface Props {
libraryId: string
readOnly?: boolean
}
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)
@@ -21,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) => {
@@ -74,17 +129,31 @@ export default function GamesView({ libraryId }: Props) {
: items : items
const filtered = visibleItems.filter((item) => { const filtered = visibleItems.filter((item) => {
if ('games' in item) {
const searchMatch = !search ||
item.title.toLowerCase().includes(search.toLowerCase()) ||
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
if (!searchMatch) return false
if (selectedTagIds.size > 0) {
return item.games.some((g) => {
const gameTags = assignments[g.item_key!] ?? []
return [...selectedTagIds].every((id) => gameTags.includes(id))
})
}
return true
}
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
// Tag filtering only applies to games (series don't have tags directly) const gameTags = assignments[item.item_key!] ?? []
if ('games' in item) return true
const gameTags = assignments[`${libraryId}:${item.id}`] ?? []
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
} }
return true return true
}) })
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 (
<> <>
@@ -158,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)) }}
/> />
) )
)} )}
@@ -169,9 +238,22 @@ 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={() => {
setSelected(null)
setSelectedGameIndex(null)
fetchGames()
fetchAssignments()
}}
/> />
)} )}
</div> </div>
@@ -202,6 +284,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
) : ( ) : (
<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>
)} )}
{game.platforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={game.platforms} />
</div>
)}
</div> </div>
<div className="p-2"> <div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}> <p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
@@ -213,6 +300,12 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
} }
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) { function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
// Compute union of platforms across all games in the series
const seriesPlatforms: GamePlatform[] = [
...new Set(series.games.flatMap((g) => g.platforms)),
]
const resolvedCover = series.coverUrl ?? series.games[0]?.coverUrl ?? null
return ( return (
<button <button
onClick={onClick} onClick={onClick}
@@ -228,13 +321,19 @@ 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>
)} )}
{/* Game count badge */} {/* Platform badges (bottom-left) */}
{seriesPlatforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={seriesPlatforms} />
</div>
)}
{/* Game count badge (bottom-right) */}
<div <div
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold" className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}

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
@@ -9,15 +9,121 @@ interface Props {
onClose: () => void onClose: () => void
onPrev?: () => void onPrev?: () => void
onNext?: () => void onNext?: () => void
mediaKey?: string itemKey?: string
onTagsChanged?: () => void onTagsChanged?: () => void
onAiTag?: () => Promise<void>
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, mediaKey, onTagsChanged }: 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( const [showTagsLocal, setShowTagsLocal] = useState(false)
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280 const showTags = showTagsProp ?? showTagsLocal
) const setShowTags = onShowTagsChange ?? setShowTagsLocal
// Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null)
const [extracting, setExtracting] = useState(false)
const [extractPending, setExtractPending] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null)
const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false)
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
const [showTextOverlay, setShowTextOverlay] = 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)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
// Derived: what text to display in the overlay
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
// Fetch existing AI fields on mount / item change
const fetchAiFields = useCallback(() => {
if (!itemKey) return Promise.resolve()
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
})
.catch(() => {})
}, [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) => {
@@ -37,128 +143,480 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, medi
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)' }}>
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
<div className="relative flex-1 min-h-0 min-w-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
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} {name}
</span> </span>
<div className="flex items-center gap-2 flex-shrink-0"> </div>
{mediaKey && (
{/* Tags + Close — top-right */}
<div
className="absolute top-4 right-4 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{itemKey && !showTags && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors" className={smallBtn}
style={{ style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)', onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
color: showTags ? '#fff' : 'var(--text-primary)', onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
fontSize: '1.5rem', aria-label="Show tags"
}}
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" title="Tags"
> >
🏷 🏷
</button> </button>
)} )}
{!showTags && (
<button <button
onClick={onClose} onClick={onClose}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors" className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close" aria-label="Close"
title="Close"
> >
</button> </button>
</div> )}
</div> </div>
{showTags ? ( {/* Text display button — bottom-right, hidden when panel open */}
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full"> {!showTags && extractedText && (
{/* Image */}
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
{onPrev && (
<button <button
onClick={(e) => { e.stopPropagation(); onPrev() }} onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
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 bottom-4 right-4 ${smallBtn}`}
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} style={{
aria-label="Previous" backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</button> <line x1="3" y1="6" x2="21" y2="6"/>
)} <line x1="3" y1="12" x2="15" y2="12"/>
{onNext && ( <line x1="3" y1="18" x2="18" y2="18"/>
<button </svg>
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> </button>
)} )}
</div> </div>
{/* Tag panel */}
<div {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4" {showTags && (
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} <MediaTagPanel
onClick={(e) => e.stopPropagation()} itemKey={itemKey!}
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
> >
<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)' }}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Description
</p> </p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} /> <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> </div>
</div> <textarea
) : ( value={editedDescription}
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative"> onChange={(e) => setEditedDescription(e.target.value)}
{/* eslint-disable-next-line @next/next/no-img-element */} placeholder="No description yet…"
<img className="text-xs rounded-lg p-2 w-full resize-y outline-none"
src={url} style={{
alt={name} backgroundColor: 'var(--background)',
className="max-w-full max-h-full object-contain rounded-lg" border: '1px solid var(--border)',
onClick={(e) => e.stopPropagation()} color: 'var(--text-primary)',
minHeight: '3.5rem',
maxHeight: '8rem',
fontFamily: 'inherit',
}}
/> />
{onPrev && ( {editedDescription !== (aiDescription ?? '') && (
<button <button
onClick={(e) => { e.stopPropagation(); onPrev() }} onClick={async () => {
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" setSavingDesc(true)
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} try {
aria-label="Previous" 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> </button>
)} )}
{onNext && ( {descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
</div>
{/* Text extraction section — only for images */}
{isImage && (
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction
</p>
<button <button
onClick={(e) => { e.stopPropagation(); onNext() }} onClick={() => callExtract('llm')}
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" disabled={extracting || extractPending}
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} className={`${smallBtn} disabled:opacity-50`}
aria-label="Next" 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>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!extracting && !extractPending) {
;(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)'
}}
>
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
</button>
<input
type="text"
value={ocrLanguageInput}
onChange={(e) => setOcrLanguageInput(e.target.value)}
placeholder={defaultOcrLanguages}
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: 120,
}}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
</div>
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
{extractedText && (
<div className="flex flex-col gap-2">
<div>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Extracted Text
</p>
<textarea
value={editedExtractedText}
onChange={(e) => setEditedExtractedText(e.target.value)}
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'var(--background)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minHeight: '4rem',
maxHeight: '10rem',
fontFamily: 'inherit',
}}
/>
{editedExtractedText !== extractedText && (
<button
onClick={async () => {
setSavingText(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
})
setExtractedText(editedExtractedText)
} finally {
setSavingText(false)
}
}}
disabled={savingText}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingText ? '⟳ Saving…' : 'Save'}
</button> </button>
)} )}
</div> </div>
{translatedText && (
<div>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Translation
</p>
<pre
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
{translatedText}
</pre>
</div>
)} )}
<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)
setTranslatePending(false)
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.status === 202) {
setTranslatePending(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 translate')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}}
disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!retranslating && !translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div>
)}
</div>
)}
</MediaTagPanel>
)}
</div>
</div> </div>
) )
} }

View File

@@ -7,36 +7,46 @@ import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
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 =
| { type: 'video'; url: string; name: string; mediaKey: string; mediaIndex: number } | { type: 'video'; url: string; name: string; itemKey: string; mediaIndex: number }
| { type: 'image'; url: string; name: string; mediaKey: string; mediaIndex: number } | { type: 'image'; url: string; name: string; itemKey: string; mediaIndex: number }
| null | null
type TagPanelState = { entry: FileEntry; mediaKey: 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)
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false)
const [doomScrollEntries, setDoomScrollEntries] = useState<FileEntry[]>([])
const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false)
const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = useState(false)
const [pendingOpen, setPendingOpen] = useState<string | null>(null)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -70,6 +80,17 @@ export default function MixedView({ libraryId, initialPath }: Props) {
loadPath(initialPath) loadPath(initialPath)
}, [loadPath, initialPath]) }, [loadPath, initialPath])
// Invalidate doom scroll entry cache when the user navigates to a different directory
useEffect(() => {
setDoomScrollEntries([])
setDoomScrollEntriesLoaded(false)
setDoomScrollEntriesLoading(false)
setDoomScrollLoading(false)
}, [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())
@@ -79,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(() => {
@@ -94,17 +125,32 @@ export default function MixedView({ libraryId, initialPath }: Props) {
.finally(() => setRecursiveLoading(false)) .finally(() => setRecursiveLoading(false))
}, [libraryId, recursiveLoaded, recursiveLoading]) }, [libraryId, recursiveLoaded, recursiveLoading])
const fetchDoomScrollEntries = useCallback(() => {
if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return
setDoomScrollEntriesLoading(true)
fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true`
)
.then((r) => r.json())
.then((data: DirectoryListing) => {
setDoomScrollEntries(data.entries)
setDoomScrollEntriesLoaded(true)
})
.catch(() => {})
.finally(() => setDoomScrollEntriesLoading(false))
}, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading])
// Fetch the full recursive listing the first time any filter becomes active // Fetch the full recursive listing the first time any filter becomes active
useEffect(() => { useEffect(() => {
if (!filtersActive) return if (!filtersActive) return
fetchRecursive() fetchRecursive()
}, [filtersActive, fetchRecursive]) }, [filtersActive, fetchRecursive])
const mediaKeyFor = (entry: FileEntry) => { const itemKeyFor = (entry: FileEntry) => {
// In recursive mode entry.name is already the full relative path from the library root // In recursive mode entry.name is already the full relative path from the library root
if (filtersActive) return `${libraryId}:${encodeURIComponent(entry.name)}` if (filtersActive) return `${libraryId}:mixed_file:${encodeURIComponent(entry.name)}`
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
return `${libraryId}:${encodeURIComponent(rel)}` return `${libraryId}:mixed_file:${encodeURIComponent(rel)}`
} }
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? []) const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
@@ -112,7 +158,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const filteredEntries = sourceEntries.filter((entry) => { const filteredEntries = sourceEntries.filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0 && entry.type !== 'directory') { if (selectedTagIds.size > 0 && entry.type !== 'directory') {
const entryTags = assignments[mediaKeyFor(entry)] ?? [] const entryTags = assignments[itemKeyFor(entry)] ?? []
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
} }
return true return true
@@ -124,11 +170,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const openMediaEntry = (entry: FileEntry, idx: number) => { const openMediaEntry = (entry: FileEntry, idx: number) => {
if (!entry.url) return if (!entry.url) return
const mediaKey = mediaKeyFor(entry) const itemKey = itemKeyFor(entry)
if (entry.mediaType === 'video') { if (entry.mediaType === 'video') {
setModal({ type: 'video', url: entry.url, name: entry.name, mediaKey, mediaIndex: idx }) setModal({ type: 'video', url: entry.url, name: entry.name, itemKey, mediaIndex: idx })
} else if (entry.mediaType === 'image') { } else if (entry.mediaType === 'image') {
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey, mediaIndex: idx }) setModal({ type: 'image', url: entry.url, name: entry.name, itemKey, mediaIndex: idx })
} }
} }
@@ -155,7 +201,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} }
const handleTagEntry = (entry: FileEntry) => { const handleTagEntry = (entry: FileEntry) => {
setTagPanel({ entry, mediaKey: mediaKeyFor(entry) }) setTagPanel({ entry, itemKey: itemKeyFor(entry) })
} }
const navigateUp = () => { const navigateUp = () => {
@@ -181,27 +227,65 @@ export default function MixedView({ libraryId, initialPath }: Props) {
fetchRecursive() fetchRecursive()
return return
} }
if (recursiveLoaded) { // No filters: scope to current directory
if (doomScrollEntriesLoaded) {
setDoomScrollActive(true) setDoomScrollActive(true)
return return
} }
setDoomScrollLoading(true) setDoomScrollLoading(true)
fetchRecursive() fetchDoomScrollEntries()
} }
// Activate doom scroll once the recursive listing finishes loading (when triggered by button) // Activate doom scroll once the appropriate listing finishes loading (when triggered by button)
useEffect(() => { useEffect(() => {
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) { if (!doomScrollLoading) return
const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded
const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded
if (filtersDone || noFiltersDone) {
setDoomScrollLoading(false) setDoomScrollLoading(false)
setDoomScrollActive(true) setDoomScrollActive(true)
} }
}, [doomScrollLoading, recursiveLoading, recursiveLoaded]) }, [
doomScrollLoading, filtersActive,
recursiveLoading, recursiveLoaded,
doomScrollEntriesLoading, doomScrollEntriesLoaded,
])
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags). // When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
// When no filters, doom scroll uses the full recursiveEntries. // When no filters, doom scroll uses files recursively under the current directory.
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries) // In both cases entries come from recursive API calls so entry.name is the full relative path.
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url) const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries)
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' })) .filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
.map((e) => ({
url: e.url!,
name: e.name,
mediaType: e.mediaType as 'video' | 'image',
itemKey: `${libraryId}:mixed_file:${encodeURIComponent(e.name)}`,
}))
const handleViewInLibrary = useCallback((item: DoomScrollItem) => {
if (!item.itemKey) return
const rel = decodeURIComponent(item.itemKey.split(':mixed_file:')[1])
const parts = rel.split('/')
parts.pop()
const dir = parts.join('/')
setDoomScrollActive(false)
setPendingOpen(rel)
loadPath(dir)
}, [loadPath])
// Auto-open a file after navigating to its directory (from "view in library")
useEffect(() => {
if (!pendingOpen || !listing) return
const filename = pendingOpen.split('/').pop()!
const entry = listing.entries.find((e) => e.name === filename && e.type === 'file')
if (!entry) return
setPendingOpen(null)
const idx = mediaEntries.indexOf(entry)
openMediaEntry(entry, idx >= 0 ? idx : 0)
// openMediaEntry is defined inline and depends on stable state; listing/pendingOpen are the real triggers
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listing, pendingOpen])
return ( return (
<> <>
@@ -210,6 +294,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
items={doomScrollItems} items={doomScrollItems}
videoContext="mixed" videoContext="mixed"
onClose={() => setDoomScrollActive(false)} onClose={() => setDoomScrollActive(false)}
onViewInLibrary={handleViewInLibrary}
/> />
)} )}
@@ -258,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
@@ -315,7 +408,132 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</button> </button>
)} )}
{filteredEntries.map((entry) => ( {filteredEntries.map((entry) => (
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} /> <EntryTile
key={entry.name}
entry={entry}
onOpen={handleEntry}
onTag={handleTagEntry}
ocrMode={ocrMode}
defaultOcrLanguages={defaultOcrLanguages}
onAiTag={async (e) => {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging', {
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 ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
onExtractText={async (e, ocrLanguages) => {
if (e.type === 'directory') {
// Bulk extract for directory
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/extract-text-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 ?? 'Text extraction failed')
}
} else {
// Single image extract
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
}
}
}}
onDescribe={async (e) => {
if (e.type === 'directory') {
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/describe-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 ?? 'Description generation failed')
}
} else {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/describe', {
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 ?? 'Description generation failed')
}
}
}}
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) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
.then(() => {
if (filtersActive) {
setRecursiveEntries((prev) => prev.filter((r) => r.name !== e.name))
} else {
setListing((prev) => prev ? { ...prev, entries: prev.entries.filter((r) => r.name !== e.name) } : prev)
}
})
}}
onRename={async (e, newName) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: rel, newName, itemType: 'mixed_file' }),
})
if (!res.ok) return false
// Refresh the listing
if (filtersActive) {
fetchRecursive()
} else {
loadPath(currentPath)
}
return true
}}
/>
))} ))}
</div> </div>
)} )}
@@ -326,22 +544,54 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<VideoPlayerModal <VideoPlayerModal
url={modal.url} url={modal.url}
name={modal.name} name={modal.name}
mediaKey={modal.mediaKey} 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}
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: modal.itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
} : undefined}
/> />
)} )}
{modal?.type === 'image' && ( {modal?.type === 'image' && (
<ImageLightbox <ImageLightbox
url={modal.url} url={modal.url}
name={modal.name} name={modal.name}
mediaKey={modal.mediaKey} 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}
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: modal.itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
/> />
)} )}
@@ -378,7 +628,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
<TagSelector <TagSelector
mediaKey={tagPanel.mediaKey} itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
/> />
</div> </div>
@@ -391,11 +641,38 @@ export default function MixedView({ libraryId, initialPath }: Props) {
) )
} }
function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => 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'
) )
const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [entryRenaming, setEntryRenaming] = useState(false)
const [entryRenameName, setEntryRenameName] = useState('')
const [entryRenameError, setEntryRenameError] = useState<string | null>(null)
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [textExtracting, setTextExtracting] = useState(false)
const [textExtractError, setTextExtractError] = useState<string | null>(null)
const [describing, setDescribing] = useState(false)
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(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
// Reset image state when the entry changes (e.g. navigating to a new folder) // Reset image state when the entry changes (e.g. navigating to a new folder)
const prevUrl = useRef(entry.thumbnailUrl) const prevUrl = useRef(entry.thumbnailUrl)
if (prevUrl.current !== entry.thumbnailUrl) { if (prevUrl.current !== entry.thumbnailUrl) {
@@ -417,7 +694,7 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
tabIndex={0} tabIndex={0}
onClick={() => onOpen(entry)} onClick={() => onOpen(entry)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all cursor-pointer" className="group relative flex flex-col rounded-xl border text-xs transition-all cursor-pointer"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
@@ -428,6 +705,8 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)' ;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}} }}
> >
{/* Inner wrapper — clips visual content to rounded corners */}
<div className="absolute inset-0 overflow-hidden rounded-xl pointer-events-none">
{/* Thumbnail image — hidden until loaded */} {/* Thumbnail image — hidden until loaded */}
{entry.thumbnailUrl && ( {entry.thumbnailUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -485,6 +764,7 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
</div> </div>
)} )}
</div>
{/* Tag button — top-left, shown on hover */} {/* Tag button — top-left, shown on hover */}
<button <button
@@ -496,6 +776,398 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
> >
🏷 🏷
</button> </button>
{/* 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')) || (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}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 bottom-full mb-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{onAiTag && entry.mediaType === 'image' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setAiTagging(true)
setAiTagError(null)
onAiTag(entry)
.catch((err) => setAiTagError(err instanceof Error ? err.message : 'AI tagging failed'))
.finally(() => setAiTagging(false))
}}
disabled={aiTagging}
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')}
>
AI Tag
</button>
)}
{onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video') && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setDescribing(true)
setDescribeError(null)
onDescribe(entry)
.catch((err) => setDescribeError(err instanceof Error ? err.message : 'Description generation failed'))
.finally(() => setDescribing(false))
}}
disabled={describing}
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')}
>
📝 Describe
</button>
)}
{onDescribe && entry.type === 'directory' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setDescribing(true)
setDescribeError(null)
onDescribe(entry)
.catch((err) => setDescribeError(err instanceof Error ? err.message : 'Description generation failed'))
.finally(() => setDescribing(false))
}}
disabled={describing}
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')}
>
📝 Describe Folder
</button>
)}
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
<button
onClick={(e) => {
e.stopPropagation()
if (ocrMode && ocrMode !== 'llm') {
setOcrLanguageInput('')
setShowOcrPrompt(true)
} else {
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}
}}
disabled={textExtracting}
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')}
>
🔍 Extract Text
</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' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
disabled={textExtracting}
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')}
>
🔍 Extract Text for Folder
</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 && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setEntryRenameName(entry.name)
setEntryRenameError(null)
setEntryRenaming(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
</button>
)}
{onDelete && (
<button
onClick={(e) => { e.stopPropagation(); 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
</button>
)}
</div>
)}
</div>
)}
{/* AI tagging status overlay */}
{(aiTagging || aiTagError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: aiTagError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: aiTagError ? '#fca5a5' : 'var(--text-secondary)' }}>
{aiTagError ?? 'AI Tagging…'}
</span>
{aiTagError && (
<button
onClick={() => setAiTagError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Text extraction status overlay */}
{(textExtracting || textExtractError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: textExtractError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: textExtractError ? '#fca5a5' : 'var(--text-secondary)' }}>
{textExtractError ?? 'Extracting text…'}
</span>
{textExtractError && (
<button
onClick={() => setTextExtractError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Description generation status overlay */}
{(describing || describeError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: describeError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: describeError ? '#fca5a5' : 'var(--text-secondary)' }}>
{describeError ?? 'Generating description…'}
</span>
{describeError && (
<button
onClick={() => setDescribeError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</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 */}
{confirming && (
<div
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
<button
onClick={() => setConfirming(false)}
className="px-2 py-0.5 rounded transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => { setDeleting(true); onDelete!(entry) }}
disabled={deleting}
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{deleting ? '…' : 'Yes'}
</button>
</div>
)}
{/* Rename overlay */}
{entryRenaming && (
<div
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={entryRenameName}
onChange={(e) => setEntryRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onRename) {
const trimmed = entryRenameName.trim()
if (!trimmed) return
setEntryRenameSaving(true)
setEntryRenameError(null)
onRename(entry, trimmed).then((ok) => {
if (ok) setEntryRenaming(false)
else setEntryRenameError('Name already exists')
}).finally(() => setEntryRenameSaving(false))
}
if (e.key === 'Escape') setEntryRenaming(false)
}}
className="w-full px-2 py-1 rounded text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<div className="flex gap-1 justify-end">
<button onClick={() => setEntryRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
<button
onClick={() => {
if (!onRename) return
const trimmed = entryRenameName.trim()
if (!trimmed) return
setEntryRenameSaving(true)
setEntryRenameError(null)
onRename(entry, trimmed).then((ok) => {
if (ok) setEntryRenaming(false)
else setEntryRenameError('Name already exists')
}).finally(() => setEntryRenameSaving(false))
}}
disabled={entryRenameSaving}
className="px-2 py-0.5 rounded disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{entryRenameSaving ? '…' : 'Rename'}
</button>
</div>
{entryRenameError && <p style={{ color: '#fca5a5' }}>{entryRenameError}</p>}
</div>
)}
</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 {
@@ -10,20 +10,25 @@ interface Props {
onClose: () => void onClose: () => void
onPrev?: () => void onPrev?: () => void
onNext?: () => void onNext?: () => void
mediaKey?: string itemKey?: string
onTagsChanged?: () => void onTagsChanged?: () => 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, mediaKey, onTagsChanged, 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( const [showTagsLocal, setShowTagsLocal] = useState(false)
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280 const showTags = showTagsProp ?? showTagsLocal
) const setShowTags = onShowTagsChange ?? setShowTagsLocal
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -43,56 +48,58 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
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)' }}>
{/* ── Video column ── */}
<div className="flex flex-col flex-1 min-h-0 min-w-0 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} {name}
</span> </span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{mediaKey && ( {itemKey && !showTags && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={() => setShowTags(true)}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" className={smallBtn}
style={{ style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)', onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
color: showTags ? '#fff' : 'var(--text-primary)', onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
}} aria-label="Show tags"
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" title="Tags"
> >
🏷 🏷
</button> </button>
)} )}
{!showTags && (
<button <button
onClick={onClose} onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors" className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close" aria-label="Close"
title="Close"
> >
</button> </button>
)}
</div> </div>
</div> </div>
{showTags ? ( {/* Video area — single element, never remounts on panel toggle */}
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden"> <div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
{/* Video */}
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
<video <video
key={url} key={url}
src={url} src={url}
@@ -100,14 +107,18 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
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()}
/> />
</div>
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
so they align with ImageLightbox's buttons which span the full viewport */}
{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"
> >
@@ -117,7 +128,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
{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"
> >
@@ -125,53 +136,19 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
</button> </button>
)} )}
</div> </div>
{/* Tag panel */}
<div {/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4" {showTags && (
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} <MediaTagPanel
onClick={(e) => e.stopPropagation()} itemKey={itemKey!}
> onHide={() => setShowTags(false)}
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> onClose={onClose}
Tags onTagsChanged={onTagsChanged}
</p> onAiTag={readOnly ? undefined : onAiTag}
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} /> readOnly={readOnly}
</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 && (
<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>
)}
</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 {
@@ -13,21 +14,42 @@ interface Props {
onNext?: () => void onNext?: () => void
onTagsChanged?: () => void onTagsChanged?: () => void
onDeleted: (movieId: string) => void onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void
readOnly?: boolean
} }
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted }: 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)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' })
const [warnRefresh, setWarnRefresh] = useState(false)
const [renaming, setRenaming] = useState(false)
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
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 (editing) { setEditing(false); return }
if (renaming) { setRenaming(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose() onClose()
} }
} }
@@ -37,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]) }, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -66,12 +88,99 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
.catch(() => setDeleting(false)) .catch(() => setDeleting(false))
} }
const doRefreshMetadata = () => {
setRefreshing(true)
setWarnRefresh(false)
const itemKey = `${libraryId}:movie:${movie.id}`
fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`,
{ method: 'POST' }
)
.then(() => onMetadataRefreshed?.())
.finally(() => setRefreshing(false))
}
const handleRefreshMetadata = () => {
setMenuOpen(false)
if (movie.manuallyEdited) {
setWarnRefresh(true)
} else {
doRefreshMetadata()
}
}
const handleStartEditing = () => {
setMenuOpen(false)
setEditForm({
title: movie.title,
year: movie.year?.toString() ?? '',
plot: movie.plot ?? '',
genres: movie.genres.join(', '),
})
setEditing(true)
}
const handleSaveMetadata = () => {
setSaving(true)
const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean)
const yearNum = editForm.year ? parseInt(editForm.year, 10) : null
fetch('/api/metadata', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey: movie.item_key,
title: editForm.title,
year: isNaN(yearNum as number) ? null : yearNum,
plot: editForm.plot || null,
genres,
}),
})
.then(() => { setEditing(false); onMetadataRefreshed?.() })
.finally(() => setSaving(false))
}
const handleStartRename = () => {
setMenuOpen(false)
setRenameName(decodeURIComponent(movie.id))
setRenameError(null)
setRenaming(true)
}
const handleRename = () => {
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(movie.id),
newName: trimmed,
itemType: 'movie',
}),
})
.then(async (res) => {
if (res.status === 409) {
const data = await res.json()
setRenameError(data.error)
return
}
if (!res.ok) throw new Error()
setRenaming(false)
onMetadataRefreshed?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
if (playing) { if (playing) {
return ( return (
<VideoPlayerModal <VideoPlayerModal
url={videoUrl} url={videoUrl}
name={movie.title} name={movie.title}
mediaKey={`${libraryId}:${movie.id}`} itemKey={movie.item_key!}
onTagsChanged={onTagsChanged} onTagsChanged={onTagsChanged}
onClose={() => setPlaying(false)} onClose={() => setPlaying(false)}
onPrev={onPrev} onPrev={onPrev}
@@ -86,51 +195,22 @@ 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)' }}>
@@ -159,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</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"
@@ -175,6 +255,34 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
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)' }}
> >
<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 <button
onClick={() => { setMenuOpen(false); setConfirming(true) }} onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors" className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
@@ -186,9 +294,105 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</button> </button>
</div> </div>
)} )}
</div> </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 */} {/* Meta row */}
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && ( {(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mb-3"> <div className="flex flex-wrap items-center gap-2 mb-3">
@@ -215,6 +419,34 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
{movie.plot} {movie.plot}
</p> </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 */} {/* Confirmation banner */}
{confirming && ( {confirming && (
@@ -247,10 +479,18 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</div> </div>
)} )}
{/* Play button */} {/* 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">
<button <button
onClick={() => setPlaying(true)} 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" className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }} style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
@@ -258,15 +498,84 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
<span></span> <span></span>
Play Play
</button> </button>
<a
href={videoUrl}
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)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onClick={(e) => e.stopPropagation()}
title="Download"
aria-label="Download"
>
</a>
</div>
</div>
</div>
</div>
{/* Tags */} {/* Floating controls — tag + close */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}> {movie.item_key && !showTagPanel && (
Tags <button
</p> onClick={() => setShowTagPanel(true)}
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} /> 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> </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

@@ -5,12 +5,14 @@ import type { Movie } from '@/types'
import MovieDetailModal from './MovieDetailModal' import MovieDetailModal from './MovieDetailModal'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
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)
@@ -19,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[]>([])
@@ -57,7 +61,7 @@ export default function MoviesView({ libraryId }: Props) {
const filtered = movies.filter((movie) => { const filtered = movies.filter((movie) => {
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
const movieTags = assignments[`${libraryId}:${movie.id}`] ?? [] const movieTags = assignments[movie.item_key!] ?? []
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
} }
return true return true
@@ -74,7 +78,7 @@ export default function MoviesView({ libraryId }: Props) {
const handleDoomScroll = () => { const handleDoomScroll = () => {
// Use filtered movies — respects any active search/tag filters automatically // Use filtered movies — respects any active search/tag filters automatically
const items: DoomScrollItem[] = filtered.map((m) => ({ const items: DoomScrollItem[] = filtered.filter((m) => isBrowserPlayable(m.videoPath)).map((m) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`,
name: m.title, name: m.title,
mediaType: 'video' as const, mediaType: 'video' as const,
@@ -200,11 +204,13 @@ 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}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onDeleted={handleDeleted} onDeleted={handleDeleted}
onMetadataRefreshed={fetchMovies}
/> />
)} )}
</div> </div>

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

@@ -5,8 +5,11 @@ import type { Tag, TagCategory } from '@/types'
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
interface Props { interface Props {
mediaKey: string itemKey: string
onTagsChanged?: () => void onTagsChanged?: () => void
refreshKey?: number
hideDescription?: boolean
readOnly?: boolean
} }
interface AllTags { interface AllTags {
@@ -14,7 +17,7 @@ interface AllTags {
tags: Tag[] tags: Tag[]
} }
export default function TagSelector({ mediaKey, onTagsChanged }: 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: [],
@@ -23,6 +26,11 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null) const [busy, setBusy] = useState<string | null>(null)
// AI description state
const [aiDescription, setAiDescription] = useState<string | null>(null)
const [generatingDesc, setGeneratingDesc] = useState(false)
const [descError, setDescError] = useState<string | null>(null)
// Per-category search text // Per-category search text
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({}) const [categorySearches, setCategorySearches] = useState<Record<string, string>>({})
@@ -39,10 +47,10 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
const [savingCategory, setSavingCategory] = useState(false) const [savingCategory, setSavingCategory] = useState(false)
const fetchAssigned = useCallback(() => { const fetchAssigned = useCallback(() => {
return fetch(`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}`) return fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setAssigned(data)) .then((data) => setAssigned(data))
}, [mediaKey]) }, [itemKey])
const fetchAll = useCallback(() => { const fetchAll = useCallback(() => {
return Promise.all([ return Promise.all([
@@ -53,10 +61,25 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
}) })
}, []) }, [])
const fetchAiFields = useCallback(() => {
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { aiDescription: string | null }) => {
setAiDescription(data.aiDescription)
})
.catch(() => {})
}, [itemKey])
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
Promise.all([fetchAssigned(), fetchAll()]).finally(() => setLoading(false)) Promise.all([fetchAssigned(), fetchAll(), fetchAiFields()]).finally(() => setLoading(false))
}, [fetchAssigned, fetchAll]) }, [fetchAssigned, fetchAll, fetchAiFields])
useEffect(() => {
if (refreshKey !== undefined && refreshKey > 0) {
fetchAssigned()
}
}, [refreshKey, fetchAssigned])
const isAssigned = (tagId: string) => assigned.tags.some((t) => t.id === tagId) const isAssigned = (tagId: string) => assigned.tags.some((t) => t.id === tagId)
@@ -66,14 +89,14 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
try { try {
if (isAssigned(tag.id)) { if (isAssigned(tag.id)) {
await fetch( await fetch(
`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}&tagId=${encodeURIComponent(tag.id)}`, `/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}&tagId=${encodeURIComponent(tag.id)}`,
{ method: 'DELETE' } { method: 'DELETE' }
) )
} else { } else {
await fetch('/api/tags/assignments', { await fetch('/api/tags/assignments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaKey, tagId: tag.id }), body: JSON.stringify({ itemKey, tagId: tag.id }),
}) })
} }
await fetchAssigned() await fetchAssigned()
@@ -106,7 +129,7 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
fetch('/api/tags/assignments', { fetch('/api/tags/assignments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaKey, tagId: newTag.id }), body: JSON.stringify({ itemKey, tagId: newTag.id }),
}), }),
fetchAll(), fetchAll(),
]) ])
@@ -158,8 +181,70 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
const assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c])) const assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c]))
const handleGenerateDescription = async () => {
setGeneratingDesc(true)
setDescError(null)
try {
const res = await fetch('/api/ai-tagging/describe', {
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 ?? 'Failed to generate description')
}
if (res.status === 202) {
setDescError('Queued — check AI Integrations for progress')
setTimeout(() => setDescError(null), 4000)
return
}
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)
}
}
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* AI description */}
{!hideDescription && (
<div className="flex flex-col gap-1">
{aiDescription && (
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
{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>
</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">
@@ -193,6 +278,7 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
style={{ backgroundColor: 'var(--surface-hover)' }} style={{ backgroundColor: 'var(--surface-hover)' }}
> >
{tag.name} {tag.name}
{!readOnly && (
<button <button
onClick={() => toggleTag(tag)} onClick={() => toggleTag(tag)}
className="ml-0.5 leading-none transition-colors" className="ml-0.5 leading-none transition-colors"
@@ -203,13 +289,14 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
> >
</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)} />
))} ))}
</> </>
) )
@@ -218,13 +305,17 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
)} )}
{/* 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] ?? ''
const visibleTags = categoryTags const filtered = categoryTags.filter(
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase())) (t) => !search || t.name.toLowerCase().includes(search.toLowerCase())
.slice(0, 25) )
const visibleTags = [
...filtered.filter((t) => isAssigned(t.id)),
...filtered.filter((t) => !isAssigned(t.id)),
].slice(0, 25)
return ( return (
<div key={category.id}> <div key={category.id}>
@@ -443,7 +534,7 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
</button> </button>
)} )}
</div> </div>
</div> </div>}
</div> </div>
) )
} }

View File

@@ -1,15 +1,36 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react'
import type { TvEpisode } from '@/types' import type { TvEpisode } from '@/types'
interface Props { interface Props {
episode: TvEpisode episode: TvEpisode
onClick: () => void onClick: () => void
onTag?: () => void onTag?: () => void
onDelete?: () => void
onRename?: (newName: string) => Promise<boolean>
downloadUrl?: string
} }
export default function EpisodeCard({ episode, onClick, onTag }: 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 [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [renaming, setRenaming] = useState(false)
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false)
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
return ( return (
<div <div
@@ -58,7 +79,147 @@ export default function EpisodeCard({ episode, onClick, onTag }: Props) {
🏷 🏷
</button> </button>
)} )}
{/* Kebab menu */}
{(onDelete || downloadUrl) && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
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)' }}
>
{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 && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
// Extract filename from videoPath (last segment, without extension for user friendliness)
const fileName = episode.videoPath.split('/').pop() ?? ''
setRenameName(fileName)
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 file
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); 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 episode
</button>
</div> </div>
)}
</div>
)}
</div>
{/* Delete confirmation overlay */}
{confirming && (
<div
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
<button
onClick={() => setConfirming(false)}
className="px-2 py-0.5 rounded transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => { setDeleting(true); onDelete!() }}
disabled={deleting}
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{deleting ? 'Deleting…' : 'Yes'}
</button>
</div>
)}
{/* Rename overlay */}
{renaming && (
<div
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = renameName.trim()
if (!trimmed || !onRename) return
setRenameSaving(true)
setRenameError(null)
onRename(trimmed).then((ok) => {
if (ok) setRenaming(false)
else setRenameError('Rename failed or name already exists')
}).finally(() => setRenameSaving(false))
}
if (e.key === 'Escape') setRenaming(false)
}}
className="w-full px-2 py-1 rounded text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<div className="flex gap-1 justify-end">
<button onClick={() => setRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
<button
onClick={() => {
const trimmed = renameName.trim()
if (!trimmed || !onRename) return
setRenameSaving(true)
setRenameError(null)
onRename(trimmed).then((ok) => {
if (ok) setRenaming(false)
else setRenameError('Rename failed or name already exists')
}).finally(() => setRenameSaving(false))
}}
disabled={renameSaving}
className="px-2 py-0.5 rounded disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
<div className="p-2"> <div className="p-2">
{epLabel && ( {epLabel && (
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}> <p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>

View File

@@ -2,20 +2,24 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import type { DirectoryListing } 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'
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[]>([])
@@ -28,16 +32,35 @@ export default function TvView({ libraryId }: Props) {
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 [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(
const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null) () => 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 [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [refreshingMeta, setRefreshingMeta] = useState(false)
const [editingMeta, setEditingMeta] = useState(false)
const [savingMeta, setSavingMeta] = useState(false)
const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' })
const [warnRefresh, setWarnRefresh] = useState(false)
const [renaming, setRenaming] = useState(false)
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false)
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) => {
@@ -66,7 +89,17 @@ export default function TvView({ libraryId }: Props) {
useEffect(() => { fetchAssignments() }, [fetchAssignments]) useEffect(() => { fetchAssignments() }, [fetchAssignments])
const fetchSeriesEpisodeTags = useCallback(() => {
fetch(`/api/tv/series-episode-tags?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setSeriesEpisodeTags)
.catch(() => {})
}, [libraryId])
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)
@@ -76,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(
@@ -114,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 = () => {
@@ -139,6 +181,105 @@ export default function TvView({ libraryId }: Props) {
.catch(() => setDeleting(false)) .catch(() => setDeleting(false))
} }
const doRefreshSeriesMetadata = () => {
if (!selectedSeries) return
setRefreshingMeta(true)
setWarnRefresh(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
const currentId = selectedSeries.id
fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`,
{ method: 'POST' }
)
.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))
}
const handleRefreshSeriesMetadata = () => {
setMenuOpen(false)
if (selectedSeries?.manuallyEdited) {
setWarnRefresh(true)
} else {
doRefreshSeriesMetadata()
}
}
const handleStartEditingMeta = () => {
if (!selectedSeries) return
setMenuOpen(false)
setEditForm({
title: selectedSeries.title,
year: selectedSeries.year?.toString() ?? '',
plot: selectedSeries.plot ?? '',
genres: selectedSeries.genres.join(', '),
})
setEditingMeta(true)
}
const handleSaveSeriesMetadata = () => {
if (!selectedSeries) return
setSavingMeta(true)
const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean)
const yearNum = editForm.year ? parseInt(editForm.year, 10) : null
fetch('/api/metadata', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey: selectedSeries.item_key,
title: editForm.title,
year: isNaN(yearNum as number) ? null : yearNum,
plot: editForm.plot || null,
genres,
}),
})
.then(() => { setEditingMeta(false); fetchSeries() })
.finally(() => setSavingMeta(false))
}
const handleStartRename = () => {
if (!selectedSeries) return
setMenuOpen(false)
setRenameName(decodeURIComponent(selectedSeries.id))
setRenameError(null)
setRenaming(true)
}
const handleRename = () => {
if (!selectedSeries) return
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(selectedSeries.id),
newName: trimmed,
itemType: 'tv_series',
}),
})
.then(async (res) => {
if (res.status === 409) {
const data = await res.json()
setRenameError(data.error)
return
}
if (!res.ok) throw new Error()
setRenaming(false)
fetchSeries()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
const handleDoomScroll = async () => { const handleDoomScroll = async () => {
setDoomScrollLoading(true) setDoomScrollLoading(true)
try { try {
@@ -160,19 +301,36 @@ export default function TvView({ libraryId }: Props) {
return seasonEps.flat() return seasonEps.flat()
}) })
) )
items = episodeLists.flat().map((ep) => ({ items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title, name: ep.title,
mediaType: 'video' as const, mediaType: 'video' as const,
})) }))
} else { } else {
// No filters — use full recursive browse // No filters — fetch all episodes via the TV API hierarchy
const data: DirectoryListing = await fetch( const allSeries: TvSeries[] = await fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true` `/api/tv?libraryId=${encodeURIComponent(libraryId)}`
).then((r) => r.json()) ).then((r) => r.json())
items = data.entries const episodeLists = await Promise.all(
.filter((e) => e.type === 'file' && e.mediaType === 'video' && e.url) allSeries.map(async (s) => {
.map((e) => ({ url: e.url!, name: e.name, mediaType: 'video' as const })) const seasons: TvSeason[] = await fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`
).then((r) => r.json())
const seasonEps = await Promise.all(
seasons.map((season) =>
fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}`
).then((r) => r.json() as Promise<TvEpisode[]>)
)
)
return seasonEps.flat()
})
)
items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title,
mediaType: 'video' as const,
}))
} }
setDoomScrollItems(items) setDoomScrollItems(items)
setDoomScrollActive(true) setDoomScrollActive(true)
@@ -183,17 +341,86 @@ 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) => {
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
const tags = assignments[`${libraryId}:${s.id}`] ?? [] const seriesTags = assignments[s.item_key!] ?? []
if (![...selectedTagIds].every((id) => tags.includes(id))) return false const episodeTags = seriesEpisodeTags[s.id] ?? []
const allTags = seriesTags.length === 0 ? episodeTags
: episodeTags.length === 0 ? seriesTags
: [...new Set([...seriesTags, ...episodeTags])]
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
} }
return true return true
}) })
const filteredEpisodes = episodes.filter((ep) => {
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) {
const epTags = assignments[ep.item_key!] ?? []
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
}
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) {
@@ -202,12 +429,13 @@ export default function TvView({ libraryId }: Props) {
<VideoPlayerModal <VideoPlayerModal
url={videoUrl} url={videoUrl}
name={playingEpisode.title} name={playingEpisode.title}
mediaKey={`${libraryId}:${playingEpisode.id}`} itemKey={playingEpisode.item_key!}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
onClose={() => setPlayingEpisodeIndex(null)} onClose={() => setPlayingEpisodeIndex(null)}
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}
/> />
) )
} }
@@ -337,7 +565,7 @@ export default function TvView({ libraryId }: Props) {
<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>
)} )}
<button <button
onClick={(e) => { e.stopPropagation(); setTagPanel({ mediaKey: `${libraryId}:${s.id}`, title: s.title }) }} onClick={(e) => { e.stopPropagation(); setTagPanel({ itemKey: s.item_key!, title: s.title }) }}
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex" className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label={`Tag ${s.title}`} aria-label={`Tag ${s.title}`}
@@ -360,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 */}
@@ -392,6 +687,34 @@ export default function TvView({ libraryId }: Props) {
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)' }}
> >
<button
onClick={handleRefreshSeriesMetadata}
disabled={refreshingMeta}
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')}
>
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button
onClick={handleStartEditingMeta}
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 <button
onClick={() => { setMenuOpen(false); setConfirming(true) }} onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors" className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
@@ -405,6 +728,102 @@ export default function TvView({ libraryId }: Props) {
)} )}
</div> </div>
</div> </div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mt-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
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>
)}
{editingMeta ? (
<div className="flex flex-col gap-3 mt-2">
<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={() => setEditingMeta(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={handleSaveSeriesMetadata}
disabled={savingMeta}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingMeta ? 'Saving…' : 'Save'}
</button>
</div>
</div>
) : (
<>
{(selectedSeries.year || selectedSeries.genres.length > 0) && ( {(selectedSeries.year || selectedSeries.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mt-1"> <div className="flex flex-wrap items-center gap-2 mt-1">
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>} {selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
@@ -416,8 +835,40 @@ 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>
</div> </div>
{/* NFO refresh warning */}
{warnRefresh && (
<div
className="flex items-center gap-3 mt-3 px-3 py-2.5 rounded-lg"
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={doRefreshSeriesMetadata}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
>
Overwrite
</button>
</div>
)}
{/* Confirmation banner */} {/* Confirmation banner */}
{confirming && ( {confirming && (
<div <div
@@ -463,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) => {
@@ -499,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 ? (
@@ -510,54 +961,127 @@ export default function TvView({ libraryId }: Props) {
</div> </div>
) : ( ) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{episodes.map((ep, idx) => ( {filteredEpisodes.map((ep) => (
<EpisodeCard <EpisodeCard
key={ep.id} key={ep.id}
episode={ep} episode={ep}
onClick={() => setPlayingEpisodeIndex(idx)} onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }}
downloadUrl={`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`}
onDelete={() => {
fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
{ method: 'DELETE' }
).then(() => {
setEpisodes((prev) => prev.filter((e) => e.id !== ep.id))
})
}}
onRename={async (newName) => {
const res = await fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: ep.videoPath, newName, itemType: 'tv_episode' }),
})
if (!res.ok) return false
// Refetch episodes to get updated data
const seasonId = selectedSeason!.id
const data = await fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&seasonId=${encodeURIComponent(seasonId)}`
).then((r) => r.json())
setEpisodes(data)
return true
}}
/> />
))} ))}
</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> </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()}>
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
<button <button
onClick={() => setTagPanel(null)} onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors" className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')} 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" aria-label="Close"
> >
</button> </button>
</div> </div>
<div className="px-5 py-4">
<TagSelector {/* Prev — series in seasons view, season in episodes view */}
mediaKey={tagPanel.mediaKey} {(view === 'seasons'
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }} ? 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>
{/* Right tag panel */}
{showTagPanel && (
<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>
)} )}

View File

@@ -5,5 +5,8 @@ export async function register() {
const { startScheduler } = await import('./lib/scheduler') const { startScheduler } = await import('./lib/scheduler')
startScheduler() startScheduler()
const { initJobProcessor } = await import('./lib/ai-jobs')
initJobProcessor()
} }
} }

355
src/lib/ai-jobs.ts Normal file
View File

@@ -0,0 +1,355 @@
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
payload: 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,
payload?: Record<string, 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, payload)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
// 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
// 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(
"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, 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()
}
}

812
src/lib/ai-tagger.ts Normal file
View File

@@ -0,0 +1,812 @@
import fs from 'fs'
import path from 'path'
import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db'
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getAiImagePath, getOcrImagePath, getVideoFramePaths } from './thumbnails'
import { findFile } from './media-utils'
import { getLibrary, resolveLibraryRoot } from './libraries'
const BATCH_LIMIT = 50
const REQUEST_TIMEOUT_MS = 30_000
const MAX_CONSECUTIVE_FAILURES = 3
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
const VIDEO_FRAME_PERCENTAGES = [0.10, 0.25, 0.50, 0.75, 0.90]
interface ResolvedMedia {
path: string
mediaType: 'image' | 'video'
}
interface MediaItemRow {
item_key: string
item_type: string
file_path: string | null
metadata: string | null
}
/**
* Resolve the absolute path to the best image (or video) for a media item.
* Returns null if no suitable media is found.
*/
function resolveItemImage(libraryRoot: string, item: MediaItemRow): ResolvedMedia | null {
switch (item.item_type) {
case 'movie':
case 'tv_series': {
// metadata.posterUrl is an API URL like /api/thumbnail?libraryId=...&path=dir/poster.jpg
// Extract the relative path from the URL and resolve to absolute
const meta = item.metadata ? JSON.parse(item.metadata) : {}
const apiUrl = meta.posterUrl as string | undefined
if (!apiUrl) return null
try {
const relPath = decodeURIComponent(
new URL(apiUrl, 'http://localhost').searchParams.get('path') ?? ''
)
if (!relPath) return null
const absPath = path.join(libraryRoot, relPath)
if (fs.existsSync(absPath)) return { path: absPath, mediaType: 'image' }
} catch {
return null
}
return null
}
case 'game':
case 'game_series': {
const meta = item.metadata ? JSON.parse(item.metadata) : {}
const apiUrl = meta.coverUrl as string | undefined
if (!apiUrl) return null
try {
const relPath = decodeURIComponent(
new URL(apiUrl, 'http://localhost').searchParams.get('path') ?? ''
)
if (!relPath) return null
const absPath = path.join(libraryRoot, relPath)
if (fs.existsSync(absPath)) return { path: absPath, mediaType: 'image' }
} catch {
return null
}
return null
}
case 'tv_season': {
// Seasons may have a poster in their directory
if (!item.file_path) return null
const seasonDir = path.join(libraryRoot, item.file_path)
const posterFile = findFile(seasonDir, /^(poster|cover|folder)$/i)
if (posterFile) return { path: path.join(seasonDir, posterFile), mediaType: 'image' }
return null
}
case 'mixed_file': {
if (!item.file_path) return null
const ext = path.extname(item.file_path).toLowerCase()
if (IMAGE_EXTENSIONS.has(ext)) return { path: path.join(libraryRoot, item.file_path), mediaType: 'image' }
if (VIDEO_EXTENSIONS.has(ext)) return { path: path.join(libraryRoot, item.file_path), mediaType: 'video' }
return null
}
default:
return null
}
}
/**
* Build the system prompt that instructs the LLM to select matching tags.
* If currentTags are provided they are included as context to help the model
* understand the content before selecting additional tags.
*/
interface TagPromptContext {
currentTags?: Tag[]
mediaContext?: 'image' | 'video'
aiDescription?: string | null
extractedText?: string | null
customInstruction?: string
}
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string {
const { currentTags, mediaContext = 'image', aiDescription, extractedText, customInstruction } = ctx
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const grouped: Record<string, { id: string; name: string }[]> = {}
for (const tag of tags) {
const catName = categoryMap.get(tag.categoryId) ?? 'Uncategorized'
;(grouped[catName] ??= []).push({ id: tag.id, name: tag.name })
}
const lines: string[] = []
for (const [catName, catTags] of Object.entries(grouped)) {
const tagList = catTags.map((t) => `${t.name} (id: ${t.id})`).join(', ')
lines.push(`[${catName}] ${tagList}`)
}
const isVideo = mediaContext === 'video'
const contentWord = isVideo ? 'video frames' : 'image'
const parts: string[] = [
`You are a media tagger. Given the ${contentWord}, select which of the following tags apply.`,
'Return ONLY a JSON array of tag IDs that match (e.g., ["tag-apple", "tag-orange"]). Do not invent new tags. Do not return any text other than what is inside the JSON array.',
'If no tags match, return an empty array (e.i., [])',
]
if (customInstruction) {
parts.push('')
parts.push(customInstruction)
}
if (aiDescription) {
parts.push('')
parts.push(`AI-generated description of this content: ${aiDescription}`)
parts.push('Use this description as additional context when selecting tags.')
}
if (extractedText) {
parts.push('')
parts.push(`Text extracted from the image: ${extractedText}`)
parts.push('Use this text as additional context when selecting tags. If the text contains dialogue, it may provide important clues about the content.')
}
if (currentTags && currentTags.length > 0) {
const currentTagNames = currentTags.map((t) => t.name).join(', ')
parts.push('')
parts.push(`This content already has the following tags applied: ${currentTagNames}`)
parts.push('Use these as context to better understand the content when selecting tags.')
}
parts.push('')
parts.push('Available tags:')
parts.push(...lines)
return parts.join('\n')
}
/**
* Call the OpenAI-compatible vision API to get tag suggestions for one or more images.
*/
async function callVisionApi(
endpoint: string,
model: string,
base64Images: string[],
systemPrompt: string,
maxTokens: number,
): Promise<string[]> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: base64Images.map((b64) => ({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${b64}` },
})),
},
],
max_tokens: maxTokens,
temperature: 0.1,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`LLM API returned ${res.status}: ${text.slice(0, 200)}`)
}
const data = await res.json() as {
choices?: Array<{ message?: { content?: string } }>
}
const content = data.choices?.[0]?.message?.content?.trim() ?? ''
// Extract JSON array from the response (handle markdown code blocks)
const jsonMatch = content.match(/\[[\s\S]*\]/)
if (!jsonMatch) return []
const parsed = JSON.parse(jsonMatch[0])
if (!Array.isArray(parsed)) return []
return parsed.filter((v): v is string => typeof v === 'string')
} finally {
clearTimeout(timeout)
}
}
/**
* Run AI tagging for a single library. Called after the scanner finishes.
* Enqueues up to BATCH_LIMIT untagged items as jobs for the processor.
*/
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
const config = getEffectiveAiConfig(library.id)
const taggingModel = config.modelTagging || config.model
if (!config.enabled || !config.endpoint || !taggingModel) return
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
const allTags = getTags()
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
if (tags.length === 0) return
const db = getDb()
const untaggedItems = db
.prepare(
`SELECT item_key, item_type, file_path, metadata
FROM media_items
WHERE library_id = ? AND ai_tagged_at IS NULL
LIMIT ?`
)
.all(library.id, BATCH_LIMIT) as MediaItemRow[]
if (untaggedItems.length === 0) return
// Import enqueueJob lazily to avoid circular dependency
const { enqueueJob } = await import('./ai-jobs')
let enqueued = 0
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
for (const item of untaggedItems) {
const resolvedMedia = resolveItemImage(libraryRoot, item)
if (!resolvedMedia) {
// No image or video available — mark as tagged so we don't retry every scan
markTagged.run(Date.now(), item.item_key)
continue
}
enqueueJob(item.item_key, 'tag', library.id)
// Mark as tagged immediately so subsequent scans don't re-enqueue
markTagged.run(Date.now(), item.item_key)
enqueued++
}
if (enqueued > 0) {
console.log(`[ai-tagger] Enqueued ${enqueued} tagging jobs for library "${library.name}"`)
}
}
/**
* Tag a single item on-demand by itemKey.
* Bypasses the ai_tagged_at check and batch limit — user explicitly requested this.
* Throws descriptive errors so the API route can return appropriate status codes.
*/
export async function tagSingleItem(itemKey: string): Promise<string[]> {
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const taggingModel = config.modelTagging || config.model
if (!config.endpoint || !taggingModel) {
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
const allTags = getTags()
const allCategories = getCategories()
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
const categories = allCategories.filter((c) => activeCategoryIds.has(c.id))
if (tags.length === 0) {
return []
}
const validTagIds = new Set(tags.map((t) => t.id))
const db = getDb()
const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
.get(itemKey) as MediaItemRow | undefined
if (!item) {
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
}
const library = getLibrary(libraryId)
if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
}
const libraryRoot = resolveLibraryRoot(library)
const imagePath = resolveItemImage(libraryRoot, item)
if (!imagePath) {
throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
}
let base64Images: string[]
if (imagePath.mediaType === 'video') {
const framePaths = await getVideoFramePaths(imagePath.path, libraryId, VIDEO_FRAME_PERCENTAGES)
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
} else {
const thumbnailPath = await getAiImagePath(imagePath.path, libraryId)
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
}
const { tags: currentItemTags } = getResolvedTagsForItem(itemKey)
const aiFields = getAiFields(itemKey)
const systemPromptWithContext = buildTagPrompt(tags, categories, {
currentTags: currentItemTags,
mediaContext: imagePath.mediaType,
aiDescription: aiFields.aiDescription,
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
customInstruction: config.promptTagger || undefined,
})
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) {
addTagToItem(itemKey, tagId)
}
db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?').run(Date.now(), itemKey)
return validIds
}
// ─── Vision / Chat text helpers ──────────────────────────────────────────────
/**
* Call the vision API and return raw text content (no JSON parsing).
*/
async function callVisionApiText(
endpoint: string,
model: string,
base64Images: string[],
systemPrompt: string,
maxTokens: number,
): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: base64Images.map((b64) => ({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${b64}` },
})),
},
],
max_tokens: maxTokens,
temperature: 0.1,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`LLM API returned ${res.status}: ${text.slice(0, 200)}`)
}
const data = await res.json() as {
choices?: Array<{ message?: { content?: string } }>
}
return data.choices?.[0]?.message?.content?.trim() ?? ''
} finally {
clearTimeout(timeout)
}
}
/**
* Call the chat completions API with text-only input (no images).
*/
async function callChatApiText(
endpoint: string,
model: string,
systemPrompt: string,
userMessage: string,
maxTokens: number,
): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
max_tokens: maxTokens,
temperature: 0.1,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`LLM API returned ${res.status}: ${text.slice(0, 200)}`)
}
const data = await res.json() as {
choices?: Array<{ message?: { content?: string } }>
}
return data.choices?.[0]?.message?.content?.trim() ?? ''
} finally {
clearTimeout(timeout)
}
}
// ─── AI description ──────────────────────────────────────────────────────────
/**
* Generate an AI description for a media item using a vision model.
* Stores the result in the ai_description column and returns it.
*/
export async function generateItemDescription(itemKey: string): Promise<string> {
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const describeModel = config.modelDescribe || config.model
if (!config.endpoint || !describeModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const db = getDb()
const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
.get(itemKey) as MediaItemRow | undefined
if (!item) {
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
}
const library = getLibrary(libraryId)
if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
}
const libraryRoot = resolveLibraryRoot(library)
const resolvedMedia = resolveItemImage(libraryRoot, item)
if (!resolvedMedia) {
throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
}
let base64Images: string[]
if (resolvedMedia.mediaType === 'video') {
const framePaths = await getVideoFramePaths(resolvedMedia.path, libraryId, VIDEO_FRAME_PERCENTAGES)
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
} else {
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
}
const { tags: currentTags } = getResolvedTagsForItem(itemKey)
const tagContext = currentTags.length > 0
? ` This content has the following tags applied describing it: ${currentTags.map((t) => t.name).join(', ')}. Use these as additional context and treat them as a source of truth, overriding any conflicting assumptions made from the image.`
: ''
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, config.maxTokensDescribe)
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
return description
}
// ─── Text extraction ─────────────────────────────────────────────────────────
/**
* Run Tesseract OCR on a preprocessed image file.
* Returns the extracted text and a mean confidence score (0100).
* A confidence of 0 with empty text means no recognisable text was found.
*/
async function extractWithTesseract(
imagePath: string,
languages: string,
): Promise<{ text: string; confidence: number }> {
const { createWorker } = await import('tesseract.js')
const workerPath = path.join(process.cwd(), 'node_modules/tesseract.js/src/worker-script/node/index.js')
const worker = await createWorker(languages, 1, { workerPath })
try {
const { data } = await worker.recognize(imagePath)
return { text: data.text.trim(), confidence: data.confidence }
} finally {
await worker.terminate()
}
}
/**
* 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 config = getEffectiveAiConfig(libraryId)
const db = getDb()
const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
.get(itemKey) as MediaItemRow | undefined
if (!item) {
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
}
if (item.item_type !== 'mixed_file') {
throw Object.assign(new Error('Text extraction is only available for mixed library items'), { code: 'INVALID_TYPE' })
}
const library = getLibrary(libraryId)
if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
}
if (library.type !== 'mixed') {
throw Object.assign(new Error('Text extraction is only available for mixed libraries'), { code: 'INVALID_TYPE' })
}
const libraryRoot = resolveLibraryRoot(library)
const resolvedMedia = resolveItemImage(libraryRoot, item)
if (!resolvedMedia || resolvedMedia.mediaType !== '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 base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
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]`
console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`)
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
if (!extractedText || extractedText === '[NO 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(extractedText, itemKey)
return { extractedText, translatedText: null }
}
/**
* Translate the extracted_text of an item into the preferred language.
* Returns the translated text or null if no text to translate.
*/
export async function translateItemText(itemKey: string, sourceLanguage?: string): Promise<string | null> {
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const translateModel = config.modelTranslate || config.model
if (!config.endpoint || !translateModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const db = getDb()
const row = db
.prepare('SELECT extracted_text FROM media_items WHERE item_key = ?')
.get(itemKey) as { extracted_text: string | null } | undefined
if (!row) {
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
}
if (!row.extracted_text) {
return null
}
const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
return translatedText
}
/**
* Update the extracted_text of an item.
*/
export function updateExtractedText(itemKey: string, text: string): void {
const db = getDb()
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.
* Returns null if the text is already in the target language.
*/
async function translateText(
endpoint: string,
model: string,
text: string,
targetLanguage: string,
customInstruction = '',
maxTokens = 8192,
sourceLanguage?: string,
): Promise<string | null> {
let systemPrompt: string
if (sourceLanguage) {
systemPrompt = `You are a translator. Translate the following text from ${sourceLanguage} to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
} else {
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, maxTokens)
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
return null
}
return result || null
}
/**
* Extract text from all images in a directory within a mixed library.
* Returns the number of items processed.
*/
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
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 library = getLibrary(libraryId)
if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
}
if (library.type !== 'mixed') {
throw Object.assign(new Error('Text extraction is only available for mixed libraries'), { code: 'INVALID_TYPE' })
}
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, metadata FROM media_items WHERE item_key LIKE ? AND item_type = ?')
.all(`${prefix}%`, 'mixed_file') as MediaItemRow[]
const libraryRoot = resolveLibraryRoot(library)
let processed = 0
for (const item of items) {
// Only process images
if (!item.file_path) continue
const ext = path.extname(item.file_path).toLowerCase()
if (!IMAGE_EXTENSIONS.has(ext)) continue
try {
await extractItemText(item.item_key)
processed++
} catch (err) {
console.warn(
`[ai-tagger] Failed to extract text from "${item.item_key}":`,
err instanceof Error ? err.message : err
)
}
}
return processed
}
/**
* Generate AI descriptions for all media items in a directory within a mixed library.
* Returns the number of items processed.
*/
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
const config = getEffectiveAiConfig(libraryId)
const describeModel = config.modelDescribe || config.model
if (!config.endpoint || !describeModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const library = getLibrary(libraryId)
if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
}
if (library.type !== 'mixed') {
throw Object.assign(new Error('Description generation is only available for mixed libraries'), { code: 'INVALID_TYPE' })
}
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, metadata FROM media_items WHERE item_key LIKE ? AND item_type = ?')
.all(`${prefix}%`, 'mixed_file') as MediaItemRow[]
let processed = 0
for (const item of items) {
if (!item.file_path) continue
const ext = path.extname(item.file_path).toLowerCase()
if (!IMAGE_EXTENSIONS.has(ext) && !VIDEO_EXTENSIONS.has(ext)) continue
try {
await generateItemDescription(item.item_key)
processed++
} catch (err) {
console.warn(
`[ai-tagger] Failed to describe "${item.item_key}":`,
err instanceof Error ? err.message : err
)
}
}
return processed
}
/**
* Get the AI fields (description, extracted text, translation) for a media item.
*/
export function getAiFields(itemKey: string): { aiDescription: string | null; extractedText: string | null; extractedTextTranslated: string | null } {
const db = getDb()
const row = db
.prepare('SELECT ai_description, extracted_text, extracted_text_translated FROM media_items WHERE item_key = ?')
.get(itemKey) as { ai_description: string | null; extracted_text: string | null; extracted_text_translated: string | null } | undefined
if (!row) {
return { aiDescription: null, extractedText: null, extractedTextTranslated: null }
}
return {
aiDescription: row.ai_description,
extractedText: row.extracted_text,
extractedTextTranslated: row.extracted_text_translated,
}
}

View File

@@ -36,3 +36,249 @@ export function updateScanConfig(schedule: string, enabled: boolean): void {
export function setScanLastRan(ts: number): void { export function setScanLastRan(ts: number): void {
setSetting('scan_last_ran', String(ts)) setSetting('scan_last_ran', String(ts))
} }
// ─── AI Settings ─────────────────────────────────────────────────────────────
const DEFAULT_PROMPT_DESCRIBE =
'Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
const DEFAULT_PROMPT_TAGGER = ''
const DEFAULT_PROMPT_EXTRACT =
'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.'
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
export interface AiConfig {
endpoint: string
model: string
modelTagging: string
modelDescribe: string
modelExtract: string
modelTranslate: string
enabled: boolean
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
ocrMode: OcrMode
ocrLanguages: string
ocrConfidenceThreshold: number
}
export function getAiConfig(): AiConfig {
const endpoint = getSetting('ai_endpoint') ?? ''
const model = getSetting('ai_model') ?? ''
const modelTagging = getSetting('ai_model_tagging') ?? ''
const modelDescribe = getSetting('ai_model_describe') ?? ''
const modelExtract = getSetting('ai_model_extract') ?? ''
const modelTranslate = getSetting('ai_model_translate') ?? ''
const enabled = getSetting('ai_enabled') === 'true'
const promptDescribeRaw = getSetting('ai_prompt_describe')
const promptDescribe = promptDescribeRaw !== null ? promptDescribeRaw : DEFAULT_PROMPT_DESCRIBE
const promptTaggerRaw = getSetting('ai_prompt_tagger')
const promptTagger = promptTaggerRaw !== null ? promptTaggerRaw : DEFAULT_PROMPT_TAGGER
const promptExtractRaw = getSetting('ai_prompt_extract')
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_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 {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
}
}
export function updateAiConfig(
endpoint: string,
model: string,
enabled: boolean,
modelTagging?: string,
modelDescribe?: string,
modelExtract?: string,
modelTranslate?: string,
promptDescribe?: string,
promptTagger?: string,
promptExtract?: string,
promptTranslate?: string,
maxTokensTag?: number,
maxTokensDescribe?: number,
maxTokensExtract?: number,
maxTokensTranslate?: number,
ocrMode?: OcrMode,
ocrLanguages?: string,
ocrConfidenceThreshold?: number,
): void {
setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model)
setSetting('ai_enabled', enabled ? 'true' : 'false')
if (modelTagging !== undefined) setSetting('ai_model_tagging', modelTagging)
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
if (promptDescribe !== undefined) setSetting('ai_prompt_describe', promptDescribe)
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
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 {
return getSetting('preferred_language') ?? 'English'
}
export function setPreferredLanguage(language: string): void {
setSetting('preferred_language', language)
}
// ─── Per-library AI overrides ─────────────────────────────────────────────────
export interface LibraryAiOverrides {
modelTagging: string
modelDescribe: string
modelExtract: string
modelTranslate: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
}
interface LibraryAiSettingsRow {
model_tagging: string | null
model_describe: string | null
model_extract: string | null
model_translate: string | null
prompt_describe: string | null
prompt_tagger: string | null
prompt_extract: 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 {
const db = getDb()
const row = db
.prepare('SELECT * FROM library_ai_settings WHERE library_id = ?')
.get(libraryId) as LibraryAiSettingsRow | undefined
return {
modelTagging: row?.model_tagging ?? '',
modelDescribe: row?.model_describe ?? '',
modelExtract: row?.model_extract ?? '',
modelTranslate: row?.model_translate ?? '',
promptDescribe: row?.prompt_describe ?? '',
promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '',
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,
}
}
export function setLibraryAiOverrides(libraryId: string, overrides: Partial<LibraryAiOverrides>): void {
const db = getDb()
// Ensure a row exists
db.prepare(
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId)
const stringFields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract,
model_translate: overrides.modelTranslate,
prompt_describe: overrides.promptDescribe,
prompt_tagger: overrides.promptTagger,
prompt_extract: overrides.promptExtract,
prompt_translate: overrides.promptTranslate,
}
for (const [col, val] of Object.entries(stringFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val,
libraryId,
)
}
}
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 {
const global = getAiConfig()
const overrides = getLibraryAiOverrides(libraryId)
return {
endpoint: global.endpoint,
model: global.model,
enabled: global.enabled,
modelTagging: overrides.modelTagging || global.modelTagging,
modelDescribe: overrides.modelDescribe || global.modelDescribe,
modelExtract: overrides.modelExtract || global.modelExtract,
modelTranslate: overrides.modelTranslate || global.modelTranslate,
promptDescribe: overrides.promptDescribe || global.promptDescribe,
promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract,
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,
}
}
// ─── AI Max Retries ──────────────────────────────────────────────────────────
export function getAiMaxRetries(): number {
const raw = getSetting('ai_max_retries')
const parsed = parseInt(raw ?? '3', 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3
}
export function setAiMaxRetries(n: number): void {
setSetting('ai_max_retries', String(Math.max(0, Math.floor(n))))
}

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
} }

19
src/lib/browser-media.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Browser-native media formats safe for use in <video> and <img> elements.
* Kept separate from the broader scanner extension sets (media-utils.ts, files.ts)
* which include server-side-only formats like .mkv, .avi, .tiff, etc.
*/
export const BROWSER_VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.m4v'])
export const BROWSER_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
/**
* Returns true if the file at `filename` (or path) has a browser-playable extension.
* Uses lastIndexOf to avoid importing the Node `path` module in client components.
*/
export function isBrowserPlayable(filename: string): boolean {
const dot = filename.lastIndexOf('.')
if (dot === -1) return false
const ext = filename.slice(dot).toLowerCase()
return BROWSER_VIDEO_EXTENSIONS.has(ext) || BROWSER_IMAGE_EXTENSIONS.has(ext)
}

View File

@@ -32,9 +32,9 @@ function initDb(db: Database.Database): void {
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id); CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id);
CREATE TABLE IF NOT EXISTS media_tags ( CREATE TABLE IF NOT EXISTS media_tags (
media_key TEXT NOT NULL, item_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (media_key, tag_id) PRIMARY KEY (item_key, tag_id)
); );
CREATE TABLE IF NOT EXISTS libraries ( CREATE TABLE IF NOT EXISTS libraries (
@@ -81,21 +81,32 @@ function initDb(db: Database.Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
item_key TEXT NOT NULL UNIQUE, item_key TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series')), item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series','mixed_file')),
parent_key TEXT, parent_key TEXT,
title TEXT, title TEXT,
year INTEGER, year INTEGER,
plot TEXT, plot TEXT,
genres TEXT, genres TEXT,
metadata TEXT, metadata TEXT,
file_path TEXT,
fingerprint TEXT,
scanned_at INTEGER NOT NULL scanned_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id); CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id);
CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key); CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key);
CREATE INDEX IF NOT EXISTS media_items_fingerprint ON media_items(fingerprint);
`) `)
migrateLibrariesType(db) migrateLibrariesType(db)
migrateMediaItemsSchema(db)
migrateMediaItemsFingerprint(db)
migrateMediaTagsToItemKey(db)
migrateMediaItemsAiTagged(db)
migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db)
migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -104,6 +115,15 @@ function seedAppSettings(db: Database.Database): void {
scan_schedule: '0 * * * *', scan_schedule: '0 * * * *',
scan_enabled: 'true', scan_enabled: 'true',
scan_last_ran: '', scan_last_ran: '',
ai_enabled: 'false',
ai_endpoint: '',
ai_model: '',
preferred_language: 'English',
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 (?, ?)'
@@ -113,6 +133,169 @@ function seedAppSettings(db: Database.Database): void {
} }
} }
function migrateMediaItemsSchema(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
.get() as { sql: string } | undefined
if (!row) return
const needsFilePath = !row.sql.includes('file_path')
const needsMixedFile = !row.sql.includes("'mixed_file'")
if (!needsFilePath && !needsMixedFile) return
// Determine whether the current table already has file_path (partial migration)
const hasFilePath = !needsFilePath ? 'file_path,' : 'NULL as file_path,'
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE media_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
item_key TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL CHECK(item_type IN (
'movie','tv_series','tv_season','tv_episode',
'game','game_series','mixed_file')),
parent_key TEXT,
title TEXT,
year INTEGER,
plot TEXT,
genres TEXT,
metadata TEXT,
file_path TEXT,
scanned_at INTEGER NOT NULL
);
INSERT INTO media_items_new
SELECT id, library_id, item_key, item_type, parent_key,
title, year, plot, genres, metadata,
${hasFilePath}
scanned_at
FROM media_items;
DROP TABLE media_items;
ALTER TABLE media_items_new RENAME TO media_items;
CREATE INDEX media_items_library_id ON media_items(library_id);
CREATE INDEX media_items_parent_key ON media_items(parent_key);
COMMIT;
`)
}
function migrateMediaItemsFingerprint(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('fingerprint')) {
db.exec(`
ALTER TABLE media_items ADD COLUMN fingerprint TEXT;
CREATE INDEX IF NOT EXISTS media_items_fingerprint ON media_items(fingerprint);
`)
}
}
function migrateMediaTagsToItemKey(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_tags'")
.get() as { sql: string } | undefined
if (!row || !row.sql.includes('media_key')) return // Already migrated or table doesn't exist
// Create replacement table with item_key column
db.exec(`
CREATE TABLE media_tags_new (
item_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_key, tag_id)
)
`)
// Build reverse mapping: short media_key → full item_key
// Uses same logic as the old itemKeyToMediaKey: libraryId + lastSegment
const items = db
.prepare('SELECT item_key FROM media_items')
.all() as { item_key: string }[]
const shortToFull: Record<string, string[]> = {}
for (const { item_key } of items) {
const firstColon = item_key.indexOf(':')
const lastColon = item_key.lastIndexOf(':')
const libraryId = item_key.slice(0, firstColon)
const shortId = item_key.slice(lastColon + 1)
const mediaKey = `${libraryId}:${shortId}`
;(shortToFull[mediaKey] ??= []).push(item_key)
}
const tagRows = db
.prepare('SELECT media_key, tag_id FROM media_tags')
.all() as { media_key: string; tag_id: string }[]
const insert = db.prepare('INSERT OR IGNORE INTO media_tags_new (item_key, tag_id) VALUES (?, ?)')
db.transaction(() => {
for (const { media_key, tag_id } of tagRows) {
const candidates = shortToFull[media_key]
if (!candidates || candidates.length !== 1) continue // orphaned or ambiguous collision
insert.run(candidates[0], tag_id)
}
})()
db.exec(`
DROP TABLE media_tags;
ALTER TABLE media_tags_new RENAME TO media_tags;
`)
}
function migrateMediaItemsAiTagged(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('ai_tagged_at')) {
db.exec('ALTER TABLE media_items ADD COLUMN ai_tagged_at INTEGER')
}
}
function migrateMediaItemsAiFields(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
.get() as { sql: string } | undefined
if (!row) return
if (!row.sql.includes('ai_description')) {
db.exec('ALTER TABLE media_items ADD COLUMN ai_description TEXT')
}
if (!row.sql.includes('extracted_text')) {
db.exec('ALTER TABLE media_items ADD COLUMN extracted_text TEXT')
}
if (!row.sql.includes('extracted_text_translated')) {
db.exec('ALTER TABLE media_items ADD COLUMN extracted_text_translated TEXT')
}
}
function migrateLibraryAiSettings(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS library_ai_settings (
library_id TEXT PRIMARY KEY REFERENCES libraries(id) ON DELETE CASCADE,
model_tagging TEXT,
model_describe TEXT,
model_extract TEXT,
model_translate TEXT,
prompt_describe TEXT,
prompt_tagger TEXT,
prompt_extract 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 {
const row = db const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'") .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
@@ -135,3 +318,42 @@ 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 {
db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs (
id TEXT PRIMARY KEY,
item_key TEXT NOT NULL,
library_id TEXT NOT NULL,
job_type TEXT NOT NULL CHECK(job_type IN ('tag','describe','extract','translate')),
status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','completed','failed')),
error TEXT,
attempt INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
created_at INTEGER NOT NULL,
started_at INTEGER,
completed_at INTEGER,
item_title TEXT
);
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);
`)
// 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

@@ -2,9 +2,10 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import type { DirectoryListing, FileEntry, MediaType } from '@/types' import type { DirectoryListing, FileEntry, MediaType } from '@/types'
import { resolveAndJail } from '@/lib/libraries' import { resolveAndJail } from '@/lib/libraries'
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl } from './media-utils'
const HIDDEN_FILES = /^\./ // Web-playable video formats for the Mixed Media browser (distinct from the
// broader set used by dedicated movie/TV scanners).
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v']) const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
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'])
@@ -15,14 +16,6 @@ function getMediaType(filename: string): MediaType {
return 'other' return 'other'
} }
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
export function scanDirectory( export function scanDirectory(
libraryRoot: string, libraryRoot: string,
libraryId: string, libraryId: string,

36
src/lib/fingerprint.ts Normal file
View File

@@ -0,0 +1,36 @@
import fs from 'fs'
import crypto from 'crypto'
const CHUNK_SIZE = 64 * 1024 // 64 KB
/**
* Computes a stable partial-content fingerprint for a file.
* Uses SHA-256 of the file size + first 64 KB of content.
* Fast enough for large video files (~instant) and collision-resistant
* for real-world media libraries.
*
* Returns null if the file cannot be read (missing, permission error, etc.).
*/
export function computeFingerprint(absolutePath: string): string | null {
try {
const stat = fs.statSync(absolutePath)
const size = stat.size
const chunkLen = Math.min(CHUNK_SIZE, size)
const buf = Buffer.alloc(chunkLen)
if (chunkLen > 0) {
const fd = fs.openSync(absolutePath, 'r')
try {
fs.readSync(fd, buf, 0, chunkLen, 0)
} finally {
fs.closeSync(fd)
}
}
return crypto
.createHash('sha256')
.update(`${size}:`)
.update(buf)
.digest('hex')
} catch {
return null
}
}

View File

@@ -1,32 +1,32 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Game, GameSeries } from '@/types' import type { Game, GameFile, GamePlatform, GameSeries } from '@/types'
import { getDb } from './db'
const HIDDEN_FILES = /^\./ import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
/** /**
* Finds the first file in a directory whose basename (without extension) * Returns the platform for a given filename, or null if not a known game archive.
* matches the given pattern (case-insensitive).
*/ */
function findFile(dir: string, pattern: RegExp): string | null { function platformForFile(name: string): GamePlatform | null {
let entries: string[] const lower = name.toLowerCase()
try { if (lower.endsWith('.zip')) return 'windows'
entries = fs.readdirSync(dir) if (lower.endsWith('.tar.gz')) return 'linux'
} catch { if (lower.endsWith('.tar.bz2')) return 'linux'
if (lower.endsWith('.tar.xz')) return 'linux'
if (lower.endsWith('.tar.zst')) return 'linux'
if (lower.endsWith('.tgz')) return 'linux'
if (lower.endsWith('.dmg')) return 'macos'
if (lower.endsWith('.apk')) return 'android'
return null return null
}
const match = entries.find(
(entry) => !HIDDEN_FILES.test(entry) && pattern.test(path.basename(entry, path.extname(entry)))
)
return match ?? null
} }
function fileApiUrl(libraryId: string, relativePath: string): string { /**
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` * Returns true if the Dirent is a game archive file or .app bundle directory.
} */
function isGameArchiveEntry(entry: fs.Dirent): boolean {
function thumbnailApiUrl(libraryId: string, relativePath: string): string { if (entry.isFile()) return platformForFile(entry.name) !== null
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` if (entry.isDirectory()) return entry.name.toLowerCase().endsWith('.app')
return false
} }
/** /**
@@ -35,7 +35,7 @@ function thumbnailApiUrl(libraryId: string, relativePath: string): string {
* @param dirName The directory's own name (used as title). * @param dirName The directory's own name (used as title).
* @param relPath Path relative to the library root (used for IDs and file URLs). * @param relPath Path relative to the library root (used for IDs and file URLs).
* @param libraryId Library identifier. * @param libraryId Library identifier.
* @returns Game, or null if the directory contains no .zip file. * @returns Game, or null if the directory contains no known game files.
*/ */
function buildGame( function buildGame(
absPath: string, absPath: string,
@@ -43,17 +43,41 @@ function buildGame(
relPath: string, relPath: string,
libraryId: string libraryId: string
): Game | null { ): Game | null {
let allFiles: string[] let entries: fs.Dirent[]
try { try {
allFiles = fs.readdirSync(absPath) entries = fs.readdirSync(absPath, { withFileTypes: true })
} catch { } catch {
return null return null
} }
const zipFiles = allFiles const gameFiles: GameFile[] = []
.filter((f) => f.toLowerCase().endsWith('.zip'))
.sort((a, b) => a.localeCompare(b)) for (const entry of entries) {
if (zipFiles.length === 0) return null if (HIDDEN_FILES.test(entry.name)) continue
if (entry.isFile()) {
const platform = platformForFile(entry.name)
if (platform) {
gameFiles.push({
path: path.join(relPath, entry.name),
platform,
filename: entry.name,
})
}
} else if (entry.isDirectory() && entry.name.toLowerCase().endsWith('.app')) {
gameFiles.push({
path: path.join(relPath, entry.name),
platform: 'macos',
filename: entry.name,
isAppBundle: true,
})
}
}
if (gameFiles.length === 0) return null
gameFiles.sort((a, b) => a.filename.localeCompare(b.filename))
const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))]
const coverFile = findFile(absPath, /^cover$/i) const coverFile = findFile(absPath, /^cover$/i)
const wideCoverFile = findFile(absPath, /^widecover$/i) const wideCoverFile = findFile(absPath, /^widecover$/i)
@@ -67,58 +91,54 @@ function buildGame(
wideCoverUrl: wideCoverFile wideCoverUrl: wideCoverFile
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile)) ? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
: null, : null,
zipFiles: zipFiles.map((f) => path.join(relPath, f)), gameFiles,
platforms,
} }
} }
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] { export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
let topDirs: string[] let topEntries: fs.Dirent[]
try { try {
topDirs = fs topEntries = fs
.readdirSync(libraryRoot, { withFileTypes: true }) .readdirSync(libraryRoot, { withFileTypes: true })
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
.map((d) => d.name)
} catch { } catch {
return [] return []
} }
const results: (Game | GameSeries)[] = [] const results: (Game | GameSeries)[] = []
for (const dirName of topDirs) { for (const topEntry of topEntries) {
const dirName = topEntry.name
const absPath = path.join(libraryRoot, dirName) const absPath = path.join(libraryRoot, dirName)
let allFiles: string[] let entries: fs.Dirent[]
try { try {
allFiles = fs.readdirSync(absPath) entries = fs.readdirSync(absPath, { withFileTypes: true })
} catch { } catch {
continue continue
} }
// Standalone game: directory directly contains a .zip // Standalone game: directory directly contains a game archive or .app bundle
const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip')) const hasGameFiles = entries.some((e) => isGameArchiveEntry(e))
if (hasZip) { if (hasGameFiles) {
const game = buildGame(absPath, dirName, dirName, libraryId) const game = buildGame(absPath, dirName, dirName, libraryId)
if (game) results.push(game) if (game) results.push(game)
continue continue
} }
// No .zip here — check subdirectories (series detection) // No game files here — check subdirectories (series detection).
let subDirs: string[] // Exclude .app-suffixed directories from series candidates — those belong to the parent game.
try { const subDirs = entries.filter(
subDirs = fs (e) => e.isDirectory() && !HIDDEN_FILES.test(e.name) && !e.name.toLowerCase().endsWith('.app')
.readdirSync(absPath, { withFileTypes: true }) )
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
.map((d) => d.name)
} catch {
continue
}
const seriesGames: Game[] = [] const seriesGames: Game[] = []
for (const subDir of subDirs) { for (const subDir of subDirs) {
const game = buildGame( const game = buildGame(
path.join(absPath, subDir), path.join(absPath, subDir.name),
subDir, subDir.name,
path.join(dirName, subDir), path.join(dirName, subDir.name),
libraryId libraryId
) )
if (game) seriesGames.push(game) if (game) seriesGames.push(game)
@@ -145,3 +165,85 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game
return results.sort((a, b) => a.title.localeCompare(b.title)) return results.sort((a, b) => a.title.localeCompare(b.title))
} }
export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
const db = getDb()
type DbRow = {
item_key: string
item_type: string
parent_key: string | null
title: string | null
metadata: string | null
}
const allRows = db
.prepare(`SELECT item_key, item_type, parent_key, title, metadata
FROM media_items
WHERE library_id = ? AND item_type IN ('game', 'game_series')
ORDER BY title`)
.all(libraryId) as DbRow[]
const seriesMap = new Map<string, GameSeries>()
const standaloneGames: Game[] = []
// First pass: build series
for (const row of allRows) {
if (row.item_type !== 'game_series') continue
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key
seriesMap.set(row.item_key, {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null,
games: [],
})
}
// Second pass: attach games
for (const row of allRows) {
if (row.item_type !== 'game') continue
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// Build gameFiles with backward-compat for old zipFiles format
let gameFiles: GameFile[]
if (meta.gameFiles) {
gameFiles = meta.gameFiles
} else if (meta.zipFiles) {
// Legacy: map old zipFiles to GameFile with platform 'windows'
gameFiles = (meta.zipFiles as string[]).map((p: string) => ({
path: p,
platform: 'windows' as GamePlatform,
filename: p.split('/').pop() ?? p,
}))
} else {
gameFiles = []
}
const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))]
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
const game: Game = {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null,
gameFiles,
platforms,
}
if (row.parent_key && seriesMap.has(row.parent_key)) {
seriesMap.get(row.parent_key)!.games.push(game)
} else {
standaloneGames.push(game)
}
}
const results: (Game | GameSeries)[] = [
...Array.from(seriesMap.values()),
...standaloneGames,
]
return results.sort((a, b) => a.title.localeCompare(b.title))
}

31
src/lib/media-utils.ts Normal file
View File

@@ -0,0 +1,31 @@
import fs from 'fs'
import path from 'path'
export const HIDDEN_FILES = /^\./
/** Video extensions for dedicated media libraries (movies, TV). */
export const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
export function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
export function thumbnailApiUrl(libraryId: string, relativePath: string): string {
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
/**
* Finds the first non-hidden file in a directory whose basename (without extension)
* matches the given pattern (case-insensitive).
*/
export function findFile(dir: string, pattern: RegExp): string | null {
let entries: string[]
try {
entries = fs.readdirSync(dir)
} catch {
return null
}
return entries.find(
(e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e)))
) ?? null
}

View File

@@ -1,35 +1,8 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Movie } from '@/types' import type { Movie } from '@/types'
import { parseMovieNfo } from './nfo' import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
const HIDDEN_FILES = /^\./
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
/**
* Finds the first file in a directory whose basename (without extension)
* matches the given pattern (case-insensitive).
*/
function findFile(dir: string, pattern: RegExp): string | null {
let entries: string[]
try {
entries = fs.readdirSync(dir)
} catch {
return null
}
return entries.find(
(e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e)))
) ?? null
}
function findVideoFile(dir: string): string | null { function findVideoFile(dir: string): string | null {
let entries: string[] let entries: string[]
@@ -43,7 +16,7 @@ function findVideoFile(dir: string): string | null {
) ?? null ) ?? null
} }
function findNfoFile(dir: string, dirName: string): string | null { export function findNfoFile(dir: string, dirName: string): string | null {
// Try {dirName}.nfo first, then movie.nfo, then any .nfo // Try {dirName}.nfo first, then movie.nfo, then any .nfo
const candidates = [`${dirName}.nfo`, 'movie.nfo'] const candidates = [`${dirName}.nfo`, 'movie.nfo']
let entries: string[] let entries: string[]
@@ -53,9 +26,8 @@ function findNfoFile(dir: string, dirName: string): string | null {
return null return null
} }
for (const candidate of candidates) { for (const candidate of candidates) {
if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) { const match = entries.find((e) => e.toLowerCase() === candidate.toLowerCase())
return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())! if (match) return match
}
} }
return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null
} }
@@ -79,9 +51,6 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
const videoFile = findVideoFile(moviePath) const videoFile = findVideoFile(moviePath)
if (!videoFile) continue if (!videoFile) continue
const nfoFile = findNfoFile(moviePath, dirName)
const nfo = nfoFile ? parseMovieNfo(path.join(moviePath, nfoFile)) : null
const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i) const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i)
const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i) const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i)
@@ -90,12 +59,12 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
movies.push({ movies.push({
id, id,
title: nfo?.title ?? dirName, title: dirName,
year: nfo?.year ?? null, year: null,
plot: nfo?.plot ?? null, plot: null,
rating: nfo?.rating ?? null, rating: null,
genres: nfo?.genres ?? [], genres: [],
runtime: nfo?.runtime ?? null, runtime: null,
posterUrl: posterFile posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null, : null,
@@ -108,3 +77,37 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
return movies.sort((a, b) => a.title.localeCompare(b.title)) return movies.sort((a, b) => a.title.localeCompare(b.title))
} }
export function moviesFromDb(libraryId: string): Movie[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'movie' ORDER BY title`)
.all(libraryId) as Array<{
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}>
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':movie:')[1] ?? row.item_key
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
rating: meta.rating ?? null,
genres: row.genres ? JSON.parse(row.genres) : [],
runtime: meta.runtime ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
videoPath: row.file_path ?? '',
manuallyEdited: meta.manuallyEdited === true,
}
})
}

View File

@@ -8,9 +8,9 @@ import { scanMoviesLibrary } from './movies'
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv' import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
import { scanGamesLibrary } from './games' import { scanGamesLibrary } from './games'
import { getThumbnailPath } from './thumbnails' import { getThumbnailPath } from './thumbnails'
import { computeFingerprint } from './fingerprint'
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) import { reKeyMediaItem } from './tags'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']) import { runAiTagging } from './ai-tagger'
let scanRunning = false let scanRunning = false
@@ -39,6 +39,20 @@ export async function runFullScan(): Promise<void> {
} }
} }
export async function runSingleLibraryScan(library: Library): Promise<void> {
if (scanRunning) return
scanRunning = true
console.log(`[scanner] Starting single library scan for "${library.name}"`)
try {
await runLibraryScan(library)
const now = Date.now()
setScanLastRan(now)
console.log(`[scanner] Single library scan complete for "${library.name}"`)
} finally {
scanRunning = false
}
}
export async function runLibraryScan(library: Library): Promise<void> { export async function runLibraryScan(library: Library): Promise<void> {
const libraryRoot = resolveLibraryRoot(library) const libraryRoot = resolveLibraryRoot(library)
console.log(`[scanner] Scanning library "${library.name}" (${library.type}) at ${libraryRoot}`) console.log(`[scanner] Scanning library "${library.name}" (${library.type}) at ${libraryRoot}`)
@@ -57,6 +71,10 @@ export async function runLibraryScan(library: Library): Promise<void> {
await scanMixed(library, libraryRoot) await scanMixed(library, libraryRoot)
break break
} }
await runAiTagging(library, libraryRoot).catch((err) =>
console.error(`[ai-tagger] Error tagging library "${library.name}":`, err)
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -68,22 +86,50 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
const db = getDb() const db = getDb()
const now = Date.now() const now = Date.now()
clearLibraryItems(db, library.id) // Load existing fingerprints for incremental hashing (skip re-reading unchanged files)
const existingFps = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND fingerprint IS NOT NULL')
.all(library.id) as Array<{ item_key: string; fingerprint: string }>
const existingFpMap = new Map(existingFps.map((r) => [r.item_key, r.fingerprint]))
// Build new items map: item_key → { fingerprint, movie }
type MovieEntry = { fingerprint: string | null; movie: Movie }
const newItems = new Map<string, MovieEntry>()
for (const movie of movies) {
const itemKey = `${library.id}:movie:${movie.id}`
const fingerprint =
existingFpMap.get(itemKey) ??
(movie.videoPath ? computeFingerprint(path.join(libraryRoot, movie.videoPath)) : null)
newItems.set(itemKey, { fingerprint, movie })
}
// Detect moves using fingerprints
const moves = detectMoves(db, library.id, newItems)
// Apply renames + prune stale rows
reconcileAndPrune(db, library.id, new Set(newItems.keys()), moves)
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title, title = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.title ELSE excluded.title END,
year = excluded.year, year = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.year ELSE excluded.year END,
plot = excluded.plot, plot = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.plot ELSE excluded.plot END,
genres = excluded.genres, genres = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.genres ELSE excluded.genres END,
metadata = excluded.metadata, metadata = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1
THEN json_set(media_items.metadata, '$.rating', json_extract(excluded.metadata, '$.rating'),
'$.runtime', json_extract(excluded.metadata, '$.runtime'),
'$.posterUrl', json_extract(excluded.metadata, '$.posterUrl'),
'$.backdropUrl', json_extract(excluded.metadata, '$.backdropUrl'))
ELSE excluded.metadata END,
file_path = excluded.file_path,
fingerprint = excluded.fingerprint,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
for (const movie of movies) { db.transaction(() => {
const itemKey = `${library.id}:movie:${movie.id}` for (const [itemKey, { fingerprint, movie }] of newItems) {
upsert.run({ upsert.run({
library_id: library.id, library_id: library.id,
item_key: itemKey, item_key: itemKey,
@@ -92,11 +138,21 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
year: movie.year ?? null, year: movie.year ?? null,
plot: movie.plot ?? null, plot: movie.plot ?? null,
genres: JSON.stringify(movie.genres), genres: JSON.stringify(movie.genres),
metadata: JSON.stringify({ rating: movie.rating, runtime: movie.runtime }), metadata: JSON.stringify({
rating: movie.rating,
runtime: movie.runtime,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
}),
file_path: movie.videoPath,
fingerprint,
scanned_at: now, scanned_at: now,
}) })
}
})()
// Pre-generate poster thumbnail // Prewarm poster thumbnails after the transaction (bounded by number of movies)
for (const [, { movie }] of newItems) {
if (movie.posterUrl) { if (movie.posterUrl) {
await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image') await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image')
} }
@@ -110,27 +166,76 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function scanTv(library: Library, libraryRoot: string): Promise<void> { async function scanTv(library: Library, libraryRoot: string): Promise<void> {
const series = scanTvLibrary(libraryRoot, library.id)
const db = getDb() const db = getDb()
const now = Date.now() const now = Date.now()
clearLibraryItems(db, library.id) // Single filesystem pass — collect everything before touching the DB
type SeasonRow = { season: TvSeason; seasonKey: string; episodes: EpisodeRow[] }
type EpisodeRow = { episode: TvEpisode; episodeKey: string; fingerprint: string | null }
type SeriesRow = { show: TvSeries; seriesKey: string; seasons: SeasonRow[] }
// Load existing episode fingerprints for incremental hashing
const existingEpFps = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND item_type = ? AND fingerprint IS NOT NULL')
.all(library.id, 'tv_episode') as Array<{ item_key: string; fingerprint: string }>
const existingEpFpMap = new Map(existingEpFps.map((r) => [r.item_key, r.fingerprint]))
const allSeries: SeriesRow[] = []
const newKeys = new Set<string>()
const newEpisodes = new Map<string, { fingerprint: string | null }>()
for (const show of scanTvLibrary(libraryRoot, library.id)) {
const seriesKey = `${library.id}:tv_series:${show.id}`
newKeys.add(seriesKey)
const seasonRows: SeasonRow[] = []
for (const season of scanTvSeasons(libraryRoot, library.id, show.id)) {
const seasonKey = `${library.id}:tv_season:${show.id}:${season.id}`
newKeys.add(seasonKey)
const episodeRows: EpisodeRow[] = []
for (const episode of scanTvEpisodes(libraryRoot, library.id, show.id, season.id)) {
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
newKeys.add(episodeKey)
const fingerprint =
existingEpFpMap.get(episodeKey) ??
(episode.videoPath ? computeFingerprint(path.join(libraryRoot, episode.videoPath)) : null)
episodeRows.push({ episode, episodeKey, fingerprint })
newEpisodes.set(episodeKey, { fingerprint })
}
seasonRows.push({ season, seasonKey, episodes: episodeRows })
}
allSeries.push({ show, seriesKey, seasons: seasonRows })
}
// Detect moves among episodes (only episodes have fingerprints)
const moves = detectMoves(db, library.id, newEpisodes)
// Apply renames + prune stale rows (series, seasons, and episodes)
reconcileAndPrune(db, library.id, newKeys, moves)
const upsertSeries = db.prepare(` const upsertSeries = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title, title = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.title ELSE excluded.title END,
year = excluded.year, year = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.year ELSE excluded.year END,
plot = excluded.plot, plot = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.plot ELSE excluded.plot END,
genres = excluded.genres, genres = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.genres ELSE excluded.genres END,
metadata = excluded.metadata, metadata = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1
THEN json_set(media_items.metadata, '$.status', json_extract(excluded.metadata, '$.status'),
'$.seasonCount', json_extract(excluded.metadata, '$.seasonCount'),
'$.posterUrl', json_extract(excluded.metadata, '$.posterUrl'),
'$.backdropUrl', json_extract(excluded.metadata, '$.backdropUrl'))
ELSE excluded.metadata END,
file_path = excluded.file_path,
fingerprint = excluded.fingerprint,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
const upsertChild = db.prepare(` const upsertChild = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key, parent_key = excluded.parent_key,
title = excluded.title, title = excluded.title,
@@ -138,13 +243,16 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
plot = excluded.plot, plot = excluded.plot,
genres = excluded.genres, genres = excluded.genres,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
fingerprint = excluded.fingerprint,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
let episodeCount = 0 let episodeCount = 0
for (const show of series) { // Phase 1: all DB writes in a single transaction
const seriesKey = `${library.id}:tv_series:${show.id}` db.transaction(() => {
for (const { show, seriesKey, seasons } of allSeries) {
upsertSeries.run({ upsertSeries.run({
library_id: library.id, library_id: library.id,
item_key: seriesKey, item_key: seriesKey,
@@ -153,17 +261,18 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
year: show.year ?? null, year: show.year ?? null,
plot: show.plot ?? null, plot: show.plot ?? null,
genres: JSON.stringify(show.genres), genres: JSON.stringify(show.genres),
metadata: JSON.stringify({ status: show.status, seasonCount: show.seasonCount }), metadata: JSON.stringify({
status: show.status,
seasonCount: show.seasonCount,
posterUrl: show.posterUrl,
backdropUrl: show.backdropUrl,
}),
file_path: null,
fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
if (show.posterUrl) { for (const { season, seasonKey, episodes } of seasons) {
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
}
const seasons = scanTvSeasons(libraryRoot, library.id, show.id)
for (const season of seasons) {
const seasonKey = `${library.id}:tv_season:${show.id}:${season.id}`
upsertChild.run({ upsertChild.run({
library_id: library.id, library_id: library.id,
item_key: seasonKey, item_key: seasonKey,
@@ -173,17 +282,17 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
year: null, year: null,
plot: null, plot: null,
genres: JSON.stringify([]), genres: JSON.stringify([]),
metadata: JSON.stringify({ seasonNumber: season.seasonNumber, episodeCount: season.episodeCount }), metadata: JSON.stringify({
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount,
posterUrl: season.posterUrl,
}),
file_path: null,
fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
if (season.posterUrl) { for (const { episode, episodeKey, fingerprint } of episodes) {
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
}
const episodes = scanTvEpisodes(libraryRoot, library.id, show.id, season.id)
for (const episode of episodes) {
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
upsertChild.run({ upsertChild.run({
library_id: library.id, library_id: library.id,
item_key: episodeKey, item_key: episodeKey,
@@ -198,27 +307,43 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
seasonNumber: episode.seasonNumber, seasonNumber: episode.seasonNumber,
aired: episode.aired, aired: episode.aired,
rating: episode.rating, rating: episode.rating,
thumbnailUrl: episode.thumbnailUrl,
}), }),
file_path: episode.videoPath,
fingerprint,
scanned_at: now, scanned_at: now,
}) })
episodeCount++
}
}
}
})()
// Pre-generate video thumbnail (seek-based frame extraction) // Phase 2: async thumbnail generation (bounded — one at a time, awaited)
for (const { show, seasons } of allSeries) {
if (show.posterUrl) {
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
}
for (const { season, episodes } of seasons) {
if (season.posterUrl) {
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
}
for (const { episode } of episodes) {
const videoAbsPath = path.join(libraryRoot, episode.videoPath) const videoAbsPath = path.join(libraryRoot, episode.videoPath)
try { try {
await getThumbnailPath(videoAbsPath, library.id, 'video') await getThumbnailPath(videoAbsPath, library.id, 'video')
} catch (err) { } catch (err) {
console.warn(`[scanner] Could not generate thumbnail for ${episode.videoPath}:`, err instanceof Error ? err.message : err) console.warn(`[scanner] Could not generate thumbnail for ${episode.videoPath}:`, err instanceof Error ? err.message : err)
} }
episodeCount++
} }
} }
} }
console.log(`[scanner] tv: indexed ${series.length} series, ${episodeCount} episodes`) console.log(`[scanner] tv: indexed ${allSeries.length} series, ${episodeCount} episodes`)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Games // Games (v1: no fingerprinting — clear+upsert pattern retained)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function scanGames(library: Library, libraryRoot: string): Promise<void> { async function scanGames(library: Library, libraryRoot: string): Promise<void> {
@@ -229,21 +354,25 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
clearLibraryItems(db, library.id) clearLibraryItems(db, library.id)
const upsertGame = db.prepare(` const upsertGame = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, fingerprint, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @fingerprint, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title, title = excluded.title,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
fingerprint = excluded.fingerprint,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
const upsertChildGame = db.prepare(` const upsertChildGame = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, fingerprint, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @fingerprint, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key, parent_key = excluded.parent_key,
title = excluded.title, title = excluded.title,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
fingerprint = excluded.fingerprint,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
@@ -251,7 +380,6 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
for (const item of items) { for (const item of items) {
if ('games' in item) { if ('games' in item) {
// GameSeries
const series = item as GameSeries const series = item as GameSeries
const seriesKey = `${library.id}:game_series:${series.id}` const seriesKey = `${library.id}:game_series:${series.id}`
upsertGame.run({ upsertGame.run({
@@ -259,7 +387,13 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_key: seriesKey, item_key: seriesKey,
item_type: 'game_series', item_type: 'game_series',
title: series.title, title: series.title,
metadata: JSON.stringify({ gameCount: series.games.length }), metadata: JSON.stringify({
gameCount: series.games.length,
coverUrl: series.coverUrl,
wideCoverUrl: series.wideCoverUrl,
}),
file_path: null,
fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
@@ -275,7 +409,13 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_type: 'game', item_type: 'game',
parent_key: seriesKey, parent_key: seriesKey,
title: game.title, title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }), metadata: JSON.stringify({
gameFiles: game.gameFiles,
coverUrl: game.coverUrl,
wideCoverUrl: game.wideCoverUrl,
}),
file_path: null,
fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
@@ -285,7 +425,6 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
gameCount++ gameCount++
} }
} else { } else {
// Standalone Game
const game = item as Game const game = item as Game
const gameKey = `${library.id}:game:${game.id}` const gameKey = `${library.id}:game:${game.id}`
upsertGame.run({ upsertGame.run({
@@ -293,7 +432,13 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_key: gameKey, item_key: gameKey,
item_type: 'game', item_type: 'game',
title: game.title, title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }), metadata: JSON.stringify({
gameFiles: game.gameFiles,
coverUrl: game.coverUrl,
wideCoverUrl: game.wideCoverUrl,
}),
file_path: null,
fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
@@ -308,38 +453,87 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mixed (thumbnail pre-generation only — no DB indexing) // Mixed
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function scanMixed(library: Library, libraryRoot: string): Promise<void> { async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
const fs = await import('fs') const fsSync = await import('fs') as typeof import('fs')
let entries: string[] const db = getDb()
const now = Date.now()
// Load existing fingerprints for incremental hashing (skip re-reading unchanged files)
const existingMixedFps = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND item_type = ? AND fingerprint IS NOT NULL')
.all(library.id, 'mixed_file') as Array<{ item_key: string; fingerprint: string }>
const existingMixedFpMap = new Map(existingMixedFps.map((r) => [r.item_key, r.fingerprint]))
// Collect all new items with fingerprints
type MixedEntry = { fingerprint: string | null; relPath: string; title: string }
const newItems = new Map<string, MixedEntry>()
function walk(absDir: string, relDir: string): void {
let dirents: import('fs').Dirent[]
try { try {
entries = fs.readdirSync(libraryRoot, { withFileTypes: true }) dirents = fsSync.readdirSync(absDir, { withFileTypes: true, encoding: 'utf-8' }) as import('fs').Dirent[]
.filter((d) => d.isFile() && !d.name.startsWith('.'))
.map((d) => d.name) as unknown as string[]
} catch { } catch {
return return
} }
for (const d of dirents) {
let count = 0 const name = d.name as string
for (const filename of entries) { if (name.startsWith('.')) continue
const ext = path.extname(filename).toLowerCase() const relPath = relDir ? path.join(relDir, name) : name
let mediaType: 'image' | 'video' | null = null if (d.isDirectory()) {
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image' walk(path.join(absDir, name), relPath)
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video' } else {
if (!mediaType) continue const itemKey = `${library.id}:mixed_file:${encodeURIComponent(relPath)}`
// Reuse stored fingerprint if the path is unchanged; only read for new/unknown files
const absPath = path.join(libraryRoot, filename) const fingerprint =
try { existingMixedFpMap.get(itemKey) ??
await getThumbnailPath(absPath, library.id, mediaType) computeFingerprint(path.join(absDir, name))
count++ newItems.set(itemKey, {
} catch (err) { fingerprint,
console.warn(`[scanner] Could not generate thumbnail for ${filename}:`, err instanceof Error ? err.message : err) relPath,
title: path.basename(name, path.extname(name)),
})
}
} }
} }
console.log(`[scanner] mixed: pre-generated thumbnails for ${count} files`) walk(libraryRoot, '')
// Detect moves + reconcile
const moves = detectMoves(db, library.id, newItems)
reconcileAndPrune(db, library.id, new Set(newItems.keys()), moves)
const upsert = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, file_path, fingerprint, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @file_path, @fingerprint, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
file_path = excluded.file_path,
fingerprint = excluded.fingerprint,
scanned_at = excluded.scanned_at
`)
// All upserts in a single transaction — critical for large libraries (48k+ files)
db.transaction(() => {
for (const [itemKey, { fingerprint, relPath, title }] of newItems) {
upsert.run({
library_id: library.id,
item_key: itemKey,
item_type: 'mixed_file',
title,
file_path: relPath,
fingerprint,
scanned_at: now,
})
}
})()
// Thumbnails for mixed libraries are generated on-demand by /api/thumbnail.
// Pre-warming 48k+ files simultaneously was the cause of the post-scan CPU spike.
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -350,6 +544,79 @@ function clearLibraryItems(db: Database.Database, libraryId: string): void {
db.prepare('DELETE FROM media_items WHERE library_id = ?').run(libraryId) db.prepare('DELETE FROM media_items WHERE library_id = ?').run(libraryId)
} }
/**
* Given a map of new items (item_key → { fingerprint }), compare against
* existing DB rows for this library to find items that moved (same fingerprint,
* different item_key). Returns an array of { oldKey, newKey } pairs.
*
* Only items that have a non-null fingerprint and whose old key is NOT already
* present in the new scan (i.e. the file truly moved, not a hash collision)
* are treated as moves.
*/
function detectMoves(
db: Database.Database,
libraryId: string,
newItems: Map<string, { fingerprint: string | null }>
): Array<{ oldKey: string; newKey: string }> {
const existing = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND fingerprint IS NOT NULL')
.all(libraryId) as Array<{ item_key: string; fingerprint: string }>
const fingerprintToOldKey = new Map<string, string>()
for (const row of existing) {
fingerprintToOldKey.set(row.fingerprint, row.item_key)
}
const moves: Array<{ oldKey: string; newKey: string }> = []
for (const [newKey, { fingerprint }] of newItems) {
if (!fingerprint) continue
const oldKey = fingerprintToOldKey.get(fingerprint)
if (oldKey && oldKey !== newKey && !newItems.has(oldKey)) {
// File moved: same fingerprint, different key, old key is no longer present
moves.push({ oldKey, newKey })
}
}
return moves
}
/**
* Applies detected moves to the DB (renames item_key and updates media_tags),
* then deletes any rows for this library whose item_key is not in newKeys.
* Tags on deleted items are intentionally left as orphans — harmless and
* recoverable if the file reappears.
*/
function reconcileAndPrune(
db: Database.Database,
libraryId: string,
newKeys: Set<string>,
moves: Array<{ oldKey: string; newKey: string }>
): void {
const renameItem = db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?')
// Apply moves first (outside transaction so console.log is visible as they happen)
for (const { oldKey, newKey } of moves) {
renameItem.run(newKey, oldKey)
if (oldKey !== newKey) {
reKeyMediaItem(oldKey, newKey)
}
console.log(`[scanner] fingerprint match: renamed "${oldKey}" → "${newKey}"`)
}
const existing = db
.prepare('SELECT item_key FROM media_items WHERE library_id = ?')
.all(libraryId) as Array<{ item_key: string }>
// Batch all deletes in a single transaction
const deleteItem = db.prepare('DELETE FROM media_items WHERE item_key = ?')
db.transaction(() => {
for (const { item_key } of existing) {
if (!newKeys.has(item_key)) {
deleteItem.run(item_key)
}
}
})()
}
/** /**
* Extract the `path` query param from an /api/thumbnail URL and pre-warm * Extract the `path` query param from an /api/thumbnail URL and pre-warm
* the thumbnail cache for that file. * the thumbnail cache for that file.

View File

@@ -18,6 +18,25 @@ export function getCategories(): TagCategory[] {
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[] return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[]
} }
/**
* Returns the distinct category IDs that have at least one tag assigned to any
* item in the given library. Used by the AI tagger to restrict the tag prompt
* to categories that are actually in use within the target library.
*/
export function getActiveCategoryIdsForLibrary(libraryId: string): string[] {
const db = getDb()
const rows = db
.prepare(
`SELECT DISTINCT t.category_id
FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id
JOIN media_items mi ON mi.item_key = mt.item_key
WHERE mi.library_id = ?`
)
.all(libraryId) as { category_id: string }[]
return rows.map((r) => r.category_id)
}
export function addCategory(name: string): TagCategory { export function addCategory(name: string): TagCategory {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) throw new Error('Category name is required.') if (!trimmed) throw new Error('Category name is required.')
@@ -164,20 +183,20 @@ export function deleteTag(id: string): void {
// ─── Assignments ────────────────────────────────────────────────────────────── // ─── Assignments ──────────────────────────────────────────────────────────────
export function addTagToItem(mediaKey: string, tagId: string): void { export function addTagToItem(itemKey: string, tagId: string): void {
const db = getDb() const db = getDb()
const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId) const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId)
if (!tag) throw new Error(`Tag not found: ${tagId}`) if (!tag) throw new Error(`Tag not found: ${tagId}`)
// INSERT OR IGNORE handles duplicate gracefully // INSERT OR IGNORE handles duplicate gracefully
db.prepare('INSERT OR IGNORE INTO media_tags (media_key, tag_id) VALUES (?, ?)').run(mediaKey, tagId) db.prepare('INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)').run(itemKey, tagId)
} }
export function removeTagFromItem(mediaKey: string, tagId: string): void { export function removeTagFromItem(itemKey: string, tagId: string): void {
const db = getDb() const db = getDb()
db.prepare('DELETE FROM media_tags WHERE media_key = ? AND tag_id = ?').run(mediaKey, tagId) db.prepare('DELETE FROM media_tags WHERE item_key = ? AND tag_id = ?').run(itemKey, tagId)
} }
export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categories: TagCategory[] } { export function getResolvedTagsForItem(itemKey: string): { tags: Tag[]; categories: TagCategory[] } {
const db = getDb() const db = getDb()
const tags = db const tags = db
@@ -185,10 +204,10 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
`SELECT t.id, t.name, t.category_id as categoryId `SELECT t.id, t.name, t.category_id as categoryId
FROM tags t FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id JOIN media_tags mt ON mt.tag_id = t.id
WHERE mt.media_key = ? WHERE mt.item_key = ?
ORDER BY t.name` ORDER BY t.name`
) )
.all(mediaKey) as Tag[] .all(itemKey) as Tag[]
const categoryIds = [...new Set(tags.map((t) => t.categoryId))] const categoryIds = [...new Set(tags.map((t) => t.categoryId))]
const categories: TagCategory[] = const categories: TagCategory[] =
@@ -206,21 +225,56 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> { export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> {
const db = getDb() const db = getDb()
const rows = db const rows = db
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?') .prepare('SELECT item_key, tag_id FROM media_tags WHERE item_key LIKE ?')
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[] .all(`${libraryId}:%`) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {} const result: Record<string, string[]> = {}
for (const row of rows) { for (const row of rows) {
;(result[row.media_key] ??= []).push(row.tag_id) ;(result[row.item_key] ??= []).push(row.tag_id)
}
return result
}
// Returns seriesId -> tagIds[] aggregated from all episodes in each series.
// Requires media_items to be populated by the scanner. Returns {} if not yet scanned.
export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string[]> {
const db = getDb()
const prefix = `${libraryId}:tv_episode:`
// Join media_items with media_tags directly on item_key
const rows = db
.prepare(
`SELECT mi.item_key, mt.tag_id
FROM media_items mi
JOIN media_tags mt ON mt.item_key = mi.item_key
WHERE mi.library_id = ? AND mi.item_type = 'tv_episode'`
)
.all(libraryId) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {}
for (const { item_key, tag_id } of rows) {
if (!item_key.startsWith(prefix)) continue
// item_key: "libraryId:tv_episode:seriesId:seasonId:episodeId"
const parts = item_key.split(':')
if (parts.length < 5) continue
const seriesId = parts[2]
const seriesTags = (result[seriesId] ??= [])
if (!seriesTags.includes(tag_id)) seriesTags.push(tag_id)
} }
return result return result
} }
export function removeAllAssignmentsForLibrary(libraryId: string): void { export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb() const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`) db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
} }
export function removeAllAssignmentsForItem(mediaKey: string): void { export function removeAllAssignmentsForItem(itemKey: string): void {
const db = getDb() const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey) db.prepare('DELETE FROM media_tags WHERE item_key = ?').run(itemKey)
}
export function reKeyMediaItem(oldKey: string, newKey: string): void {
getDb()
.prepare('UPDATE media_tags SET item_key = ? WHERE item_key = ?')
.run(newKey, oldKey)
} }

View File

@@ -7,6 +7,8 @@ import sharp from 'sharp'
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails') const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
const THUMBNAIL_WIDTH = 400 const THUMBNAIL_WIDTH = 400
const JPEG_QUALITY = 75 const JPEG_QUALITY = 75
const AI_IMAGE_WIDTH = 1920
const AI_JPEG_QUALITY = 90
/** Ensure the cache directory exists. */ /** Ensure the cache directory exists. */
function ensureCacheDir(): void { function ensureCacheDir(): void {
@@ -47,6 +49,30 @@ async function generateImageThumbnail(src: string, dest: string): Promise<void>
fs.renameSync(tmp, dest) fs.renameSync(tmp, dest)
} }
/** Generate a high-resolution JPEG for AI vision use. Images smaller than
* AI_IMAGE_WIDTH are not upscaled — they are converted at their native size. */
async function generateAiImage(src: string, dest: string): Promise<void> {
const tmp = dest + '.tmp'
await sharp(src)
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
.jpeg({ quality: AI_JPEG_QUALITY })
.toFile(tmp)
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) => {
@@ -87,22 +113,13 @@ async function getVideoDuration(src: string): Promise<number> {
}) })
} }
/** Generate a thumbnail from a video using ffmpeg. */ /** Extract a single frame from a video at the given offset (seconds) and write to dest. */
async function generateVideoThumbnail(src: string, dest: string): Promise<void> { async function generateVideoFrameAtOffset(src: string, dest: string, offsetSeconds: number): Promise<void> {
const tmp = dest + '.tmp' const tmp = dest + '.tmp'
// Seek to 10% of the video duration for a representative frame
let offset = 0
try {
const duration = await getVideoDuration(src)
offset = Math.max(0, duration * 0.1)
} catch {
// If ffprobe fails, fall back to seeking to 0
}
const args = [ const args = [
'-y', // overwrite output '-y', // overwrite output
'-ss', String(offset), // seek before input (fast) '-ss', String(offsetSeconds), // seek before input (fast)
'-i', src, '-i', src,
'-frames:v', '1', '-frames:v', '1',
'-q:v', '5', '-q:v', '5',
@@ -115,6 +132,95 @@ async function generateVideoThumbnail(src: string, dest: string): Promise<void>
fs.renameSync(tmp, dest) fs.renameSync(tmp, dest)
} }
/** Generate a thumbnail from a video using ffmpeg (seeks to 10% of duration). */
async function generateVideoThumbnail(src: string, dest: string): Promise<void> {
let offset = 0
try {
const duration = await getVideoDuration(src)
offset = Math.max(0, duration * 0.1)
} catch {
// If ffprobe fails, fall back to seeking to 0
}
await generateVideoFrameAtOffset(src, dest, offset)
}
/**
* Extract frames from a video at each given percentage of its duration.
* Returns the absolute paths to the cached frame JPEGs, in the same order as `percentages`.
* Uses a per-frame cache key so each frame is cached independently.
*/
export async function getVideoFramePaths(
absoluteFilePath: string,
libraryId: string,
percentages: number[]
): Promise<string[]> {
ensureCacheDir()
let duration = 0
try {
duration = await getVideoDuration(absoluteFilePath)
} catch {
// Fall back to 0; all frames will seek to position 0
}
const framePaths: string[] = []
for (const pct of percentages) {
const offset = Math.max(0, duration * pct)
const key = crypto
.createHash('sha1')
.update(libraryId + ':' + absoluteFilePath + ':' + pct)
.digest('hex')
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
const cached = getCachedPath(cacheFile, absoluteFilePath)
if (!cached) {
await generateVideoFrameAtOffset(absoluteFilePath, cacheFile, offset)
}
framePaths.push(cacheFile)
}
return framePaths
}
/**
* Returns the absolute path to a high-resolution JPEG suitable for AI vision
* APIs (1920px wide max, quality 90). Cached alongside display thumbnails with
* an `_ai` suffix so display performance is unaffected.
* Generates on first call or when the source file has been modified.
*/
export async function getAiImagePath(
absoluteFilePath: string,
libraryId: string
): Promise<string> {
ensureCacheDir()
const key = cacheKey(libraryId, absoluteFilePath)
const cacheFile = path.join(CACHE_DIR, key + '_ai.jpg')
const cached = getCachedPath(cacheFile, absoluteFilePath)
if (cached) return cached
await generateAiImage(absoluteFilePath, 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

@@ -1,40 +1,14 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import { parseTvShowNfo, parseEpisodeNfo } from './nfo' import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
const HIDDEN_FILES = /^\./ import { parseTvShowNfo } from './nfo'
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
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())
} }
/**
* Finds the first file in a directory whose basename (without extension)
* matches the given pattern (case-insensitive).
*/
function findFile(dir: string, pattern: RegExp): string | null {
let entries: string[]
try {
entries = fs.readdirSync(dir)
} catch {
return null
}
return entries.find(
(e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e)))
) ?? null
}
function readDirs(dir: string): string[] { function readDirs(dir: string): string[] {
try { try {
return fs return fs
@@ -76,11 +50,10 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
for (const dirName of seriesDirs) { for (const dirName of seriesDirs) {
const seriesPath = path.join(libraryRoot, dirName) const seriesPath = path.join(libraryRoot, dirName)
const nfoFile = path.join(seriesPath, 'tvshow.nfo')
const nfo = parseTvShowNfo(nfoFile)
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) => {
@@ -195,24 +168,17 @@ export function scanTvEpisodes(
for (const videoFile of videoFiles) { for (const videoFile of videoFiles) {
const baseName = path.basename(videoFile, path.extname(videoFile)) const baseName = path.basename(videoFile, path.extname(videoFile))
const nfoFileName = files.find(
(f) => path.basename(f, path.extname(f)) === baseName && path.extname(f).toLowerCase() === '.nfo'
)
const nfo = nfoFileName
? parseEpisodeNfo(path.join(seasonPath, nfoFileName))
: null
const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile) const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile)
const id = encodeURIComponent(videoFile) const id = encodeURIComponent(videoFile)
episodes.push({ episodes.push({
id, id,
title: nfo?.title ?? baseName, title: baseName,
episodeNumber: nfo?.episode ?? null, episodeNumber: null,
seasonNumber: nfo?.season ?? null, seasonNumber: null,
plot: nfo?.plot ?? null, plot: null,
aired: nfo?.aired ?? null, aired: null,
rating: nfo?.rating ?? null, rating: null,
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath), thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
videoPath: videoRelPath, videoPath: videoRelPath,
}) })
@@ -225,3 +191,111 @@ export function scanTvEpisodes(
return (a.title ?? '').localeCompare(b.title ?? '') return (a.title ?? '').localeCompare(b.title ?? '')
}) })
} }
// ---------------------------------------------------------------------------
// DB readers
// ---------------------------------------------------------------------------
type DbRow = {
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'tv_series' ORDER BY title`)
.all(libraryId) as DbRow[]
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
genres: row.genres ? JSON.parse(row.genres) : [],
status: meta.status ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
seasonCount: meta.seasonCount ?? 0,
manuallyEdited: meta.manuallyEdited === true,
}
})
}
export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[] {
const db = getDb()
const parentKey = `${libraryId}:tv_series:${seriesId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_season'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_season:{seriesId}:{seasonId}
const parts = row.item_key.split(':tv_season:')
const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key
return {
id: seasonId,
item_key: row.item_key,
seriesId,
title: row.title ?? seasonId,
seasonNumber: meta.seasonNumber ?? null,
posterUrl: meta.posterUrl ?? null,
episodeCount: meta.episodeCount ?? 0,
}
})
.sort((a, b) => {
if (a.seasonNumber !== null && b.seasonNumber !== null) {
return a.seasonNumber - b.seasonNumber
}
return a.title.localeCompare(b.title)
})
}
export function tvEpisodesFromDb(
libraryId: string,
seriesId: string,
seasonId: string
): TvEpisode[] {
const db = getDb()
const parentKey = `${libraryId}:tv_season:${seriesId}:${seasonId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_episode'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_episode:{seriesId}:{seasonId}:{episodeId}
const suffix = row.item_key.split(':tv_episode:')[1] ?? ''
const episodeId = suffix.split(':').slice(2).join(':')
return {
id: episodeId,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(episodeId),
episodeNumber: meta.episodeNumber ?? null,
seasonNumber: meta.seasonNumber ?? null,
plot: row.plot ?? null,
aired: meta.aired ?? null,
rating: meta.rating ?? null,
thumbnailUrl: meta.thumbnailUrl ?? null,
videoPath: row.file_path ?? '',
}
})
.sort((a, b) => {
if (a.episodeNumber !== null && b.episodeNumber !== null) {
return a.episodeNumber - b.episodeNumber
}
return (a.title ?? '').localeCompare(b.title ?? '')
})
}

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,18 +6,31 @@ 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 interface GameFile {
path: string
platform: GamePlatform
filename: string
isAppBundle?: boolean
} }
export interface Game { export interface Game {
id: string id: string
item_key?: string
title: string title: string
coverUrl: string | null coverUrl: string | null
wideCoverUrl: string | null wideCoverUrl: string | null
zipFiles: string[] gameFiles: GameFile[]
platforms: GamePlatform[]
} }
export interface GameSeries { export interface GameSeries {
id: string id: string
item_key?: string
title: string title: string
coverUrl: string | null coverUrl: string | null
wideCoverUrl: string | null wideCoverUrl: string | null
@@ -32,10 +45,12 @@ 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 {
id: string id: string
item_key?: string
title: string title: string
year: number | null year: number | null
plot: string | null plot: string | null
@@ -45,10 +60,12 @@ export interface Movie {
posterUrl: string | null posterUrl: string | null
backdropUrl: string | null backdropUrl: string | null
videoPath: string videoPath: string
manuallyEdited?: boolean
} }
export interface TvSeries { export interface TvSeries {
id: string id: string
item_key?: string
title: string title: string
year: number | null year: number | null
plot: string | null plot: string | null
@@ -57,10 +74,12 @@ export interface TvSeries {
posterUrl: string | null posterUrl: string | null
backdropUrl: string | null backdropUrl: string | null
seasonCount: number seasonCount: number
manuallyEdited?: boolean
} }
export interface TvSeason { export interface TvSeason {
id: string id: string
item_key?: string
seriesId: string seriesId: string
title: string title: string
seasonNumber: number | null seasonNumber: number | null
@@ -70,6 +89,7 @@ export interface TvSeason {
export interface TvEpisode { export interface TvEpisode {
id: string id: string
item_key?: string
title: string title: string
episodeNumber: number | null episodeNumber: number | null
seasonNumber: number | null seasonNumber: number | null