diff --git a/backend/internal/jellyfin.go b/backend/internal/jellyfin.go index 1e0e842..729e072 100644 --- a/backend/internal/jellyfin.go +++ b/backend/internal/jellyfin.go @@ -15,9 +15,8 @@ import ( type jfAuthResult struct { AccessToken string `json:"AccessToken"` User struct { - Id string `json:"Id"` - Name string `json:"Name"` - Username string `json:"Name"` + Id string `json:"Id"` + Name string `json:"Name"` } `json:"User"` } @@ -41,7 +40,7 @@ func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, req, _ := http.NewRequest("POST", j.BaseURL+"/Users/AuthenticateByName", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") - // Provide Jellyfin's MediaBrowser client descriptor (no token yet) + // Jellyfin client descriptor (no token yet) req.Header.Set("Authorization", `MediaBrowser Client="Pegasus", Device="Pegasus Web", DeviceId="pegasus-web", Version="1.0.0"`) @@ -60,7 +59,6 @@ func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, } func (j *Jellyfin) RefreshLibrary(token string) { - // GET /Library/Refresh with token header is widely supported req, _ := http.NewRequest("GET", j.BaseURL+"/Library/Refresh", nil) req.Header.Set("X-MediaBrowser-Token", token) _, _ = j.Client.Do(req) diff --git a/backend/internal/usermap.go b/backend/internal/usermap.go index 4502990..5e3c87b 100644 --- a/backend/internal/usermap.go +++ b/backend/internal/usermap.go @@ -5,28 +5,82 @@ import ( "fmt" "os" "path/filepath" + "strings" "gopkg.in/yaml.v3" ) +// StringOrList allows both a single string ("Stein_90s") +// and a list of strings (["Stein_90s","Home Videos"]) in YAML. +type StringOrList []string + +func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + var str string + if err := value.Decode(&str); err != nil { + return err + } + *s = StringOrList{str} + return nil + case yaml.SequenceNode: + var list []string + if err := value.Decode(&list); err != nil { + return err + } + *s = StringOrList(list) + return nil + default: + return fmt.Errorf("expected string or list, got kind=%v", value.Kind) + } +} + type UserMap struct { - // Maps Jellyfin usernames -> relative media subdir (e.g., "mary_grace_allison" -> "Allison") - Map map[string]string `yaml:"map"` + // Maps Jellyfin username -> one or more relative media subdirs + Map map[string]StringOrList `yaml:"map"` } func LoadUserMap(path string) (*UserMap, error) { b, err := os.ReadFile(path) - if err != nil { return nil, err } + if err != nil { + return nil, err + } var m UserMap - if err := yaml.Unmarshal(b, &m); err != nil { return nil, err } + if err := yaml.Unmarshal(b, &m); err != nil { + return nil, err + } return &m, nil } +// Resolve returns the first configured library for the user (back-compat). func (m *UserMap) Resolve(username string) (string, error) { - if dir, ok := m.Map[username]; ok && dir != "" { - clean := filepath.Clean(dir) - if clean == "." || clean == "/" { return "", fmt.Errorf("invalid target dir") } - return clean, nil + roots, err := m.ResolveAll(username) + if err != nil { + return "", err } - return "", fmt.Errorf("no mapping for user %q", username) + return roots[0], nil +} + +// ResolveAll returns all configured libraries for the user. +func (m *UserMap) ResolveAll(username string) ([]string, error) { + v, ok := m.Map[username] + if !ok || len(v) == 0 { + return nil, fmt.Errorf("no mapping for user %q", username) + } + out := make([]string, 0, len(v)) + for _, dir := range v { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + clean := filepath.Clean(dir) + if clean == "." || clean == "/" { + continue + } + out = append(out, clean) + } + if len(out) == 0 { + return nil, fmt.Errorf("no valid mapping for user %q", username) + } + return out, nil } diff --git a/backend/main.go b/backend/main.go index 05f99d0..2d2ffec 100644 --- a/backend/main.go +++ b/backend/main.go @@ -13,6 +13,7 @@ import ( "regexp" "strings" "time" + "sort" "github.com/go-chi/chi/v5" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -44,6 +45,11 @@ type loggingRW struct { status int } +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) } func main() { @@ -55,6 +61,17 @@ func main() { um, err := internal.LoadUserMap(userMapFile) must(err, "load user map") + if internal.Debug { + keys := make([]string, 0, len(um.Map)) + for k := range um.Map { keys = append(keys, k) } + sort.Strings(keys) + show := keys + if len(keys) > 10 { show = keys[:10] } + internal.Logf("user-map loaded (%d): %v%s", len(keys), show, func() string { + if len(keys) > len(show) { return " ..." } + return "" + }()) + } // === tusd setup (resumable uploads) === store := filestore.FileStore{Path: tusDir} @@ -69,11 +86,11 @@ func main() { // completeC := make(chan tusd.HookEvent) config := tusd.Config{ - BasePath: "/tus/", - StoreComposer: composer, - NotifyCompleteUploads: true, - // CompleteUploads: completeC, - MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file + BasePath: "/tus/", + StoreComposer: composer, + NotifyCompleteUploads: true, + MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file + RespectForwardedHeaders: true, // <<< important for https behind Traefik } tusHandler, err := tusd.NewUnroutedHandler(config) must(err, "init tus handler") @@ -153,7 +170,7 @@ func main() { _, _ = w.Write([]byte("ok")) }) r.Get("/version", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ + writeJSON(w, map[string]any{ "version": version, "git": git, "built_at": builtAt, @@ -175,15 +192,23 @@ func main() { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } - if err := internal.SetSession(w, res.User.Username, res.AccessToken); err != nil { + // ✅ ensure this username is mapped before creating a session + if _, err := um.Resolve(f.Username); err != nil { + internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name) + http.Error(w, "no mapping", http.StatusForbidden) + return + } + // ✅ store the typed login name in the session + if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil { http.Error(w, "session error", http.StatusInternalServerError) return } - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + writeJSON(w, map[string]any{"ok": true}) }) + r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) { internal.ClearSession(w) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + writeJSON(w, map[string]any{"ok": true}) }) // whoami @@ -198,11 +223,24 @@ func main() { http.Error(w, "no mapping", http.StatusForbidden) return } - _ = json.NewEncoder(w).Encode(map[string]any{"username": cl.Username, "root": dr}) + all, _ := um.ResolveAll(cl.Username) // ignore error here since Resolve succeeded + writeJSON(w, map[string]any{ + "username": cl.Username, + "root": dr, // first root (back-compat) + "roots": all, // all roots (for future UI) + }) }) // list entries r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) { + type entry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Mtime int64 `json:"mtime"` + } + cl, err := internal.CurrentUser(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -210,8 +248,8 @@ func main() { } rootRel, err := um.Resolve(cl.Username) if err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return + internal.Logf("mkdir map missing for %q", cl.Username) + http.Error(w, "no mapping", http.StatusForbidden); return } q := strings.TrimPrefix(r.URL.Query().Get("path"), "/") rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) @@ -258,7 +296,7 @@ func main() { }(), }) } - _ = json.NewEncoder(w).Encode(out) + writeJSON(w, out) }) // rename @@ -304,7 +342,7 @@ func main() { return } jf.RefreshLibrary(cl.JFToken) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + writeJSON(w, map[string]any{"ok": true}) }) // delete @@ -333,7 +371,7 @@ func main() { return } jf.RefreshLibrary(cl.JFToken) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + writeJSON(w, map[string]any{"ok": true}) }) // mkdir (create subdirectory under the user's mapped root) @@ -354,7 +392,7 @@ func main() { } else if err := os.MkdirAll(abs, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return } - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + writeJSON(w, map[string]any{"ok": true}) }) // mount tus (behind auth) @@ -386,7 +424,7 @@ func main() { http.Error(w, "disabled", http.StatusForbidden) return } - _ = json.NewEncoder(w).Encode(map[string]any{ + writeJSON(w, map[string]any{ "mediaRoot": mediaRoot, "tusDir": tusDir, "userMapFile": userMapFile, @@ -414,7 +452,7 @@ func main() { } else { _ = os.WriteFile(test, []byte("ok\n"), 0o644) } - _ = json.NewEncoder(w).Encode(map[string]string{"wrote": test}) + writeJSON(w, map[string]string{"wrote": test}) }) r.NotFound(func(w http.ResponseWriter, r *http.Request) { diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx index 2c3e856..d4482c7 100644 --- a/frontend/src/Uploader.tsx +++ b/frontend/src/Uploader.tsx @@ -1,4 +1,3 @@ -// frontend/src/Uploader.tsx import React, { useEffect, useRef, useState } from 'react' import { api, WhoAmI } from './api' import * as tus from 'tus-js-client' @@ -6,6 +5,9 @@ import * as tus from 'tus-js-client' type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } +// Simple bundle heartbeat so you can confirm the loaded JS is current +console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) + function sanitizeDesc(s:string){ s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_') if(!s) s = 'upload' @@ -25,7 +27,7 @@ function isDetailedError(e: unknown): e is tus.DetailedError { export default function Uploader(){ const [me, setMe] = useState() const [cwd, setCwd] = useState('') // relative to user's mapped root - const [rows, setRows] = useState([]) // <-- missing before + const [rows, setRows] = useState([]) const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}` const [status, setStatus] = useState('') const [globalDate, setGlobalDate] = useState(new Date().toISOString().slice(0,10)) @@ -33,7 +35,7 @@ export default function Uploader(){ const [sel, setSel] = useState([]) const folderInputRef = useRef(null) // to set webkitdirectory - // enable directory selection on the folder picker (non-standard attr) + // Enable directory selection on the folder picker (non-standard attr) useEffect(() => { const el = folderInputRef.current if (el) { @@ -47,11 +49,20 @@ export default function Uploader(){ const m = await api('/api/whoami'); setMe(m) const list = await api('/api/list?path='+encodeURIComponent(path)) setRows(list); setCwd(path) - } catch (e: any) { - setStatus(`List error: ${e.message || e}`) + setStatus(`Ready · Destination: /${[m.root, path].filter(Boolean).join('/')}`) + console.log('[Pegasus] list ok', { path, count: list.length }) + } catch (e:any) { + const msg = String(e?.message || e || '') + console.error('[Pegasus] list error', e) + setStatus(`List error: ${msg}`) + if (msg.toLowerCase().includes('no mapping')) { + alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.') + try { await api('/api/logout', { method:'POST' }) } catch {} + location.replace('/') // back to login + } } } - useEffect(()=>{ refresh('') }, []) + useEffect(()=>{ setStatus('Loading profile & folder list…'); refresh('') }, []) function handleChoose(files: FileList){ const arr = Array.from(files).map(f=>{ @@ -60,80 +71,112 @@ export default function Uploader(){ const desc = '' // start empty; user must fill before upload return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 } }) + console.log('[Pegasus] selected files', arr.map(a=>a.file.name)) setSel(arr) } + // When the global date changes, recompute per-file finalName (leave per-file date in place) useEffect(()=>{ setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)}))) }, [globalDate]) + // Warn before closing mid-upload useEffect(()=>{ const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } } window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler) }, [uploading]) async function doUpload(){ - if(!me) return + if(!me) { setStatus('Not signed in'); return } if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } - setStatus('Starting upload...') + setStatus('Starting upload…') setUploading(true) - for(const s of sel){ - await new Promise((resolve,reject)=>{ - const opts: tus.UploadOptions & { withCredentials?: boolean } = { - endpoint: '/tus/', - chunkSize: 5*1024*1024, - retryDelays: [0, 1000, 3000, 5000, 10000], - metadata: { - filename: s.file.name, - subdir: cwd || "", - date: s.date, // per-file YYYY-MM-DD - desc: s.desc // server composes final - }, - onError: (err: Error | tus.DetailedError)=>{ - let msg = String(err) - if (isDetailedError(err)) { - const status = (err.originalRequest as any)?.status - const statusText = (err.originalRequest as any)?.statusText - if (status) msg = `HTTP ${status} ${statusText || ''}`.trim() - } - setSel(old => old.map(x => x.file===s.file ? ({...x, err: msg}) : x)) - setStatus(`✖ ${s.file.name}: ${msg}`) - alert(`Upload failed: ${s.file.name}\n\n${msg}`) - reject(err as any) - }, - onProgress: (sent: number, total: number)=>{ - const pct = Math.floor(sent/Math.max(total,1)*100) - setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x)) - setStatus(`⬆ ${s.finalName}: ${pct}%`) - }, - onSuccess: ()=>{ - setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x)) - setStatus(`✔ ${s.finalName} uploaded`); resolve() - }, - } - // ensure cookie is sent even if your tus typings don’t have this field - opts.withCredentials = true + try{ + for(const s of sel){ + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve,reject)=>{ + const opts: tus.UploadOptions & { withCredentials?: boolean } = { + endpoint: '/tus/', + chunkSize: 5*1024*1024, + retryDelays: [0, 1000, 3000, 5000, 10000], + resume: false, // ignore any old http:// resume URLs + removeFingerprintOnSuccess: true, // keep storage clean going forward + metadata: { + filename: s.file.name, + subdir: cwd || "", + date: s.date, // per-file YYYY-MM-DD + desc: s.desc // server composes final + }, + onError: (err: Error | tus.DetailedError)=>{ + let msg = String(err) + if (isDetailedError(err)) { + const status = (err.originalRequest as any)?.status + const statusText = (err.originalRequest as any)?.statusText + if (status) msg = `HTTP ${status} ${statusText || ''}`.trim() + } + console.error('[Pegasus] tus error', s.file.name, err) + setSel(old => old.map(x => x.file===s.file ? ({...x, err: msg}) : x)) + setStatus(`✖ ${s.file.name}: ${msg}`) + alert(`Upload failed: ${s.file.name}\n\n${msg}`) + reject(err as any) + }, + onProgress: (sent: number, total: number)=>{ + const pct = Math.floor(sent/Math.max(total,1)*100) + setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x)) + setStatus(`⬆ ${s.finalName}: ${pct}%`) + }, + onSuccess: ()=>{ + setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x)) + setStatus(`✔ ${s.finalName} uploaded`) + console.log('[Pegasus] tus success', s.file.name) + resolve() + }, + } + // Ensure cookie is sent even if the tus typings don’t list this field + opts.withCredentials = true - const up = new tus.Upload(s.file, opts) - up.start() - }) + const up = new tus.Upload(s.file, opts) + console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, cwd }) + up.start() + }) + } + setStatus('All uploads complete') + setSel([]) + await refresh(cwd) + } finally { + setUploading(false) } - setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete') } async function rename(oldp:string){ const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return - await api('/api/rename', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')}) }) - refresh(cwd) + try{ + await api('/api/rename', { + method:'POST', + headers:{'content-type':'application/json'}, + body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')}) + }) + await refresh(cwd) + } catch(e:any){ + console.error('[Pegasus] rename error', e) + alert(`Rename failed:\n${e?.message || e}`) + } } + async function del(p:string, recursive:boolean){ if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return - await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) - refresh(cwd) + try{ + await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) + await refresh(cwd) + } catch(e:any){ + console.error('[Pegasus] delete error', e) + alert(`Delete failed:\n${e?.message || e}`) + } } + function goUp(){ const up = cwd.split('/').slice(0,-1).join('/') - setCwd(up); refresh(up) + setCwd(up); void refresh(up) } return (<> @@ -142,32 +185,64 @@ export default function Uploader(){
Signed in: {me?.username} · root: /{me?.root}
Destination: {destPath || '/(unknown)'}
+
setGlobalDate(e.target.value)} />
+
- e.target.files && handleChoose(e.target.files)} /> + e.target.files && handleChoose(e.target.files)} + />
{/* set webkitdirectory/directory via ref to satisfy TS */} - e.target.files && handleChoose(e.target.files)} /> + e.target.files && handleChoose(e.target.files)} + />
- - {sel.length>0 && sel.some(s=>!s.desc.trim()) && ( -
Add a short description for every file to enable Upload.
- )} + + {(() => { + const uploadDisabled = !sel.length || uploading || sel.some(s=>!s.desc.trim()) + let disabledReason = '' + if (!sel.length) disabledReason = 'Select at least one file.' + else if (uploading) disabledReason = 'Upload in progress…' + else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.' + + return ( + <> + + {disabledReason &&
{disabledReason}
} + + ) + })()}
+ {sel.length>0 && (

Ready to upload

@@ -175,28 +250,40 @@ export default function Uploader(){ {sel.map((s,i)=>(
{s.file.name}
- { - const v=e.target.value - setSel(old => old.map((x,idx)=> idx===i - ? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)}) - : x )) - }} /> + { + const v=e.target.value + setSel(old => old.map((x,idx)=> idx===i + ? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)}) + : x )) + }} + />
Date - { - const v = e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, date:v, finalName: composeName(v, x.desc, x.file.name)}) : x)) - }} /> + { + const v = e.target.value + setSel(old => old.map((x,idx)=> idx===i ? ({...x, date:v, finalName: composeName(v, x.desc, x.file.name)}) : x)) + }} + />
→ {s.finalName}
- {typeof s.progress === 'number' && (<> - - {s.err &&
Error: {s.err}
} - )} + {typeof s.progress === 'number' && ( + <> + + {s.err &&
Error: {s.err}
} + + )}
))}
)} +
{status}
@@ -207,7 +294,7 @@ export default function Uploader(){
No items to show here. You’ll upload into {destPath}.
- { setCwd(p); refresh(p) }} /> + { setCwd(p); void refresh(p) }} /> ) : ( @@ -242,14 +329,20 @@ function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_'); if (!clean) return; const path = [cwd, clean].filter(Boolean).join('/'); - await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) }); - onCreate(path); - setName(''); + console.log('[Pegasus] mkdir click', { path }) + try { + await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) }); + onCreate(path); + setName(''); + } catch (e:any) { + console.error('[Pegasus] mkdir error', e) + alert(`Create folder failed:\n${e?.message || e}`) + } } return (
setName(e.target.value)} /> - +
); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8b7cb50..39833eb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,8 +1,31 @@ // frontend/src/api.ts +import 'tus-js-client' + +import type { UrlStorage } from 'tus-js-client' + +declare module 'tus-js-client' { + interface UploadOptions { + /** Send cookies on CORS requests */ + withCredentials?: boolean + /** Disable reading/writing resume URLs (older defs may miss this) */ + resume?: boolean + /** Do not store fingerprints for resuming */ + storeFingerprintForResuming?: boolean + /** Remove any stored fingerprint after a successful upload */ + removeFingerprintOnSuccess?: boolean + /** Allow providing a no-op storage to fully disable resuming */ + urlStorage?: UrlStorage + } +} + export async function api(path: string, init?: RequestInit): Promise { const r = await fetch(path, { credentials:'include', ...init }); if (!r.ok) throw new Error(await r.text()); const ct = r.headers.get('content-type') || ''; - return (ct.includes('json') ? r.json() : (r.text() as any)) as T; + if (ct.includes('json')) return r.json() as Promise; + // Fallback: try to parse body as JSON; otherwise return text + const txt = await r.text(); + try { return JSON.parse(txt) as T } catch { return txt as any as T } } + export type WhoAmI = { username: string; root: string };