(null)
const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal
@@ -354,7 +355,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
- onAiTag={onAiTag}
+ onAiTag={readOnly ? undefined : onAiTag}
+ readOnly={readOnly}
>
{/* Description section */}
diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx
index 4fd7c4b..8e393e4 100644
--- a/src/components/mixed/MixedView.tsx
+++ b/src/components/mixed/MixedView.tsx
@@ -13,6 +13,7 @@ interface Props {
libraryId: string
libraryName: string
initialPath: string
+ readOnly?: boolean
}
type ModalState =
@@ -22,7 +23,7 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null
-export default function MixedView({ libraryId, libraryName, initialPath }: Props) {
+export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState(null)
const [loading, setLoading] = useState(true)
@@ -550,7 +551,8 @@ export default function MixedView({ libraryId, libraryName, initialPath }: Props
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
- onAiTag={modal.itemKey ? async () => {
+ readOnly={readOnly}
+ onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -576,7 +578,8 @@ export default function MixedView({ libraryId, libraryName, initialPath }: Props
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
- onAiTag={async () => {
+ readOnly={readOnly}
+ onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
diff --git a/src/components/mixed/VideoPlayerModal.tsx b/src/components/mixed/VideoPlayerModal.tsx
index 9309943..82e3ca4 100644
--- a/src/components/mixed/VideoPlayerModal.tsx
+++ b/src/components/mixed/VideoPlayerModal.tsx
@@ -16,9 +16,10 @@ interface Props {
context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
+ readOnly?: boolean
}
-export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange }: Props) {
+export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
@@ -143,7 +144,8 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
- onAiTag={onAiTag}
+ onAiTag={readOnly ? undefined : onAiTag}
+ readOnly={readOnly}
/>
)}
diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx
index e18c091..a424ec6 100644
--- a/src/components/movies/MovieDetailModal.tsx
+++ b/src/components/movies/MovieDetailModal.tsx
@@ -15,9 +15,10 @@ interface Props {
onTagsChanged?: () => void
onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void
+ readOnly?: boolean
}
-export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
+export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
const overlayRef = useRef(null)
const menuRef = useRef(null)
const [playing, setPlaying] = useState(false)
@@ -238,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
)}
{/* Kebab menu */}
-
+ {!readOnly &&
)}
-
+ }
{/* Rename inline input */}
@@ -572,6 +573,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
+ readOnly={readOnly}
/>
)}
diff --git a/src/components/movies/MoviesView.tsx b/src/components/movies/MoviesView.tsx
index 64f87c6..577467b 100644
--- a/src/components/movies/MoviesView.tsx
+++ b/src/components/movies/MoviesView.tsx
@@ -9,9 +9,10 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
+ readOnly?: boolean
}
-export default function MoviesView({ libraryId }: Props) {
+export default function MoviesView({ libraryId, readOnly }: Props) {
const [movies, setMovies] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -203,6 +204,7 @@ export default function MoviesView({ libraryId }: Props) {
setSelectedIndex(null)}
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
diff --git a/src/components/tags/MediaTagPanel.tsx b/src/components/tags/MediaTagPanel.tsx
index 6111965..4ee8c89 100644
--- a/src/components/tags/MediaTagPanel.tsx
+++ b/src/components/tags/MediaTagPanel.tsx
@@ -12,6 +12,7 @@ interface Props {
onAiTag?: () => Promise
disabled?: boolean
disabledMessage?: string
+ readOnly?: boolean
children?: React.ReactNode
}
@@ -26,6 +27,7 @@ export default function MediaTagPanel({
onAiTag,
disabled,
disabledMessage,
+ readOnly,
children,
}: Props) {
const [aiTagging, setAiTagging] = useState(false)
@@ -126,6 +128,7 @@ export default function MediaTagPanel({
onTagsChanged={onTagsChanged}
refreshKey={internalRefreshKey + externalRefreshKey}
hideDescription
+ readOnly={readOnly}
/>
>
)}
diff --git a/src/components/tags/TagSelector.tsx b/src/components/tags/TagSelector.tsx
index 7d3a756..1c353d7 100644
--- a/src/components/tags/TagSelector.tsx
+++ b/src/components/tags/TagSelector.tsx
@@ -9,6 +9,7 @@ interface Props {
onTagsChanged?: () => void
refreshKey?: number
hideDescription?: boolean
+ readOnly?: boolean
}
interface AllTags {
@@ -16,7 +17,7 @@ interface AllTags {
tags: Tag[]
}
-export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) {
+export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [],
categories: [],
@@ -277,23 +278,25 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
style={{ backgroundColor: 'var(--surface-hover)' }}
>
{tag.name}
-
+ {!readOnly && (
+
+ )}
))}
)
})}
{ungrouped.map((tag) => (
- toggleTag(tag)} />
+ toggleTag(tag)} />
))}
>
)
@@ -302,7 +305,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
)}
{/* Tag picker grouped by category */}
-
+ {!readOnly &&
{all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? ''
@@ -531,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
)}
-
+ }
)
}
diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx
index b3f8f47..eb6cf0e 100644
--- a/src/components/tv/TvView.tsx
+++ b/src/components/tv/TvView.tsx
@@ -14,11 +14,12 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
+ readOnly?: boolean
}
type ViewLevel = 'series' | 'seasons' | 'episodes'
-export default function TvView({ libraryId }: Props) {
+export default function TvView({ libraryId, readOnly }: Props) {
const [view, setView] = useState('series')
const [series, setSeries] = useState([])
const [seasons, setSeasons] = useState([])
@@ -434,6 +435,7 @@ export default function TvView({ libraryId }: Props) {
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
context="tv"
+ readOnly={readOnly}
/>
)
}
@@ -1000,7 +1002,7 @@ export default function TvView({ libraryId }: Props) {
{/* Floating controls — tag + close */}
e.stopPropagation()}>
- {view === 'seasons' && selectedSeries?.item_key && !showTagPanel && (
+ {view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 3385ddf..6724c0b 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise }
+type AuthSuccess = { session: IronSession; accessLevel?: 'admin' | 'write' | 'read' }
type AuthResult = AuthSuccess | NextResponse
// 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) {
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
- const { getPermittedLibraryIds } = await import('./users')
- const permitted = getPermittedLibraryIds(session.userId)
- if (!permitted.includes(libraryId)) {
+ const { getLibraryAccessLevel } = await import('./users')
+ const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
+ if (!accessLevel) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
- return { session }
+ return { session, accessLevel }
+}
+
+export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise {
+ 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
}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index bb6d2a4..da3f9eb 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -106,6 +106,7 @@ function initDb(db: Database.Database): void {
migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db)
migrateAiJobs(db)
+ migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(db)
}
@@ -318,6 +319,15 @@ function migrateLibrariesType(db: Database.Database): void {
}
}
+function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
+ const row = db
+ .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
+ .get() as { sql: string } | undefined
+ if (row && !row.sql.includes('access_level')) {
+ db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
+ }
+}
+
function migrateAiJobs(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs (
diff --git a/src/lib/users.ts b/src/lib/users.ts
index 63eacc4..c88aeed 100644
--- a/src/lib/users.ts
+++ b/src/lib/users.ts
@@ -77,43 +77,60 @@ export function listUsers(): User[] {
}))
}
-export function getPermittedLibraryIds(userId: string): string[] {
- const db = getDb()
- const rows = db
- .prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
- .all(userId) as { library_id: string }[]
- return rows.map((r) => r.library_id)
+export interface LibraryPermission {
+ libraryId: string
+ accessLevel: 'read' | 'write'
}
-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 tx = db.transaction(() => {
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
- const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
- for (const libraryId of libraryIds) {
- insert.run(userId, libraryId)
+ const insert = db.prepare(
+ 'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)'
+ )
+ for (const { libraryId, accessLevel } of permissions) {
+ insert.run(userId, libraryId, accessLevel)
}
})
tx()
}
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 rows = db
.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
INNER JOIN library_permissions lp ON lp.library_id = l.id
WHERE lp.user_id = ?
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) => ({
id: r.id,
name: r.name,
path: r.path,
type: r.type as Library['type'],
coverExt: r.cover_ext,
+ accessLevel: r.access_level as 'read' | 'write',
}))
}
diff --git a/src/types/index.ts b/src/types/index.ts
index fcf8b88..718a17e 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -6,6 +6,7 @@ export interface Library {
path: string
type: LibraryType
coverExt: string | null
+ accessLevel?: 'admin' | 'read' | 'write'
}
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'