diff --git a/.gitignore b/.gitignore
index fb01f17b5..3e2e4b954 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,4 @@ debug_whisperx_parsing.go
debug_parsing.go
scriberr-optimized
tests/database_test.db-shm
-
-# Project documentation (local only)
-project-docs
+tests
diff --git a/Dockerfile b/Dockerfile
index 238f899b0..6935b5abd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,20 +23,23 @@ RUN cd frontend \
FROM golang:1.24-bookworm AS go-builder
WORKDIR /src
-# Pre-cache modules
+# Pre-cache modules for better build caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
+# Ensure go.sum is up-to-date if go.mod changed later in the build cache chain and download sums
+RUN go mod tidy && go mod download
+
# Copy built UI into embed path
RUN rm -rf internal/web/dist && mkdir -p internal/web
COPY --from=ui-builder /web/frontend/dist internal/web/dist
# Build binary (arch matches builder platform)
RUN CGO_ENABLED=0 \
- go build -o /out/scriberr cmd/server/main.go
+ go build -mod=mod -o /out/scriberr cmd/server/main.go
########################
@@ -68,14 +71,6 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& chmod 755 /usr/local/bin/uv \
&& uv --version
-# Install Deno (JavaScript runtime required for yt-dlp YouTube downloads)
-# YouTube now requires JS execution for video cipher decryption
-# See: https://github.com/yt-dlp/yt-dlp/issues/14404
-RUN curl -fsSL https://deno.land/install.sh | sh \
- && cp /root/.deno/bin/deno /usr/local/bin/deno \
- && chmod 755 /usr/local/bin/deno \
- && deno --version
-
# Create default user (will be modified at runtime if needed)
RUN groupadd -g 1000 appuser \
&& useradd -m -u 1000 -g 1000 appuser \
diff --git a/Dockerfile.cuda b/Dockerfile.cuda
index c91ebacc4..24a4d6ab8 100644
--- a/Dockerfile.cuda
+++ b/Dockerfile.cuda
@@ -71,14 +71,6 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& chmod 755 /usr/local/bin/uv \
&& uv --version
-# Install Deno (JavaScript runtime required for yt-dlp YouTube downloads)
-# YouTube now requires JS execution for video cipher decryption
-# See: https://github.com/yt-dlp/yt-dlp/issues/14404
-RUN curl -fsSL https://deno.land/install.sh | sh \
- && cp /root/.deno/bin/deno /usr/local/bin/deno \
- && chmod 755 /usr/local/bin/deno \
- && deno --version
-
# Create default user (will be modified at runtime if needed)
# Use 10001 to avoid conflicts with existing users in CUDA base image
RUN groupadd -g 10001 appuser \
diff --git a/README.md b/README.md
index 9240ecb8c..f5de87a30 100644
--- a/README.md
+++ b/README.md
@@ -190,11 +190,6 @@ See the full guide: https://scriberr.app/docs/diarization.html
-## Summarization (Ollama)
-
-Scribber uses different models from Ollama (local, open-source and free) or OpenAi (online, propietary, paid) in order to automatically summarize the transcriptions. To connect, just go to settings and introduce either the Ollama port or the OpenAI API.
-A common error is that if Ollama has been installed through Docker, rather then connecting via "http://localhost:11434" you sohuld instead connect through "http://host.docker.internal:11434" (change the port to whichever you have used, automatically uses that one). That way Scriberr directly connects to the Docker, avoiding a "Failed to fetch model" error and alike.
-
## API
Scriberr exposes a clean REST API for most features (transcription, chat, notes, summaries, admin, and more). Authentication supports JWT or API keys depending on endpoint.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index bca9d9922..200c2fef6 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -7,7 +7,6 @@ import (
"net/http"
"os"
"os/signal"
- "path/filepath"
"syscall"
"time"
@@ -17,11 +16,10 @@ import (
"scriberr/internal/database"
"scriberr/internal/queue"
"scriberr/internal/transcription"
- "scriberr/internal/transcription/adapters"
- "scriberr/internal/transcription/registry"
"scriberr/pkg/logger"
_ "scriberr/api-docs" // Import generated Swagger docs
+ _ "scriberr/internal/transcription/adapters" // Import adapters for auto-registration
)
// Version information (set by GoReleaser)
@@ -75,9 +73,6 @@ func main() {
logger.Startup("config", "Loading configuration")
cfg := config.Load()
- // Register adapters with config-based paths
- registerAdapters(cfg)
-
// Initialize database
logger.Startup("database", "Connecting to database")
if err := database.Initialize(cfg.DatabasePath); err != nil {
@@ -161,27 +156,3 @@ func main() {
logger.Info("Server stopped")
}
-
-// registerAdapters registers all transcription and diarization adapters with config-based paths
-func registerAdapters(cfg *config.Config) {
- logger.Info("Registering adapters with environment path", "whisperx_env", cfg.WhisperXEnv)
-
- // Shared environment path for NVIDIA models (NeMo-based)
- nvidiaEnvPath := filepath.Join(cfg.WhisperXEnv, "parakeet")
-
- // Register transcription adapters
- registry.RegisterTranscriptionAdapter("whisperx",
- adapters.NewWhisperXAdapter(cfg.WhisperXEnv))
- registry.RegisterTranscriptionAdapter("parakeet",
- adapters.NewParakeetAdapter(nvidiaEnvPath))
- registry.RegisterTranscriptionAdapter("canary",
- adapters.NewCanaryAdapter(nvidiaEnvPath)) // Shares with Parakeet
-
- // Register diarization adapters
- registry.RegisterDiarizationAdapter("pyannote",
- adapters.NewPyAnnoteAdapter(nvidiaEnvPath)) // Shares with Parakeet
- registry.RegisterDiarizationAdapter("sortformer",
- adapters.NewSortformerAdapter(nvidiaEnvPath)) // Shares with Parakeet
-
- logger.Info("Adapter registration complete")
-}
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
index 62c42f357..125e1bf58 100644
--- a/docker-compose.build.yml
+++ b/docker-compose.build.yml
@@ -22,6 +22,7 @@ services:
volumes:
- ./scriberr-data:/app/data
- ./env-data:/app/whisperx-env
+ - ./Transferordner:/app/Transferordner:ro
restart: unless-stopped
volumes:
diff --git a/docs/assets/index-CTEra42u.js b/docs/assets/index-CTEra42u.js
index ac0e1a091..bbce048e1 100644
--- a/docs/assets/index-CTEra42u.js
+++ b/docs/assets/index-CTEra42u.js
@@ -1 +1 @@
-import{j as e,r as i,G as n,c as o,R as l}from"./styles-DGJLjUaE.js";import{W as c}from"./Window-BJy5jSY4.js";function a({className:s=""}){return e.jsx("img",{src:"/scriberr-logo.png",alt:"Scriberr",className:`w-auto select-none ${s}`})}function d(){const[s,r]=i.useState(!1);return e.jsxs("header",{className:"sticky top-0 z-40 bg-white/80 backdrop-blur shadow-soft",children:[e.jsxs("div",{className:"container-narrow py-4 flex items-center justify-between",children:[e.jsx("a",{href:"#",className:"flex items-center gap-3",children:e.jsx(a,{className:"h-8 sm:h-10"})}),e.jsxs("nav",{className:"hidden md:flex items-center gap-6 text-sm text-gray-600",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",children:"API"})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("button",{className:"md:hidden inline-flex items-center justify-center rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-gray-700 hover:bg-gray-50","aria-label":"Menu",onClick:()=>r(t=>!t),children:e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:"size-5",children:e.jsx("path",{d:"M4 7h16M4 12h16M4 17h16"})})}),e.jsx(n,{})]})]}),s&&e.jsx("div",{className:"md:hidden border-t border-gray-200 bg-white",children:e.jsxs("div",{className:"container-narrow py-3 flex flex-col gap-2 text-sm text-gray-700",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"API"})]})})]})}function m(){return e.jsx("section",{className:"relative",children:e.jsxs("div",{className:"container-narrow section text-center",children:[e.jsx("span",{className:"eyebrow mb-3 inline-block",children:"Self-hosted offline audio transcription"}),e.jsx("h1",{className:"headline flex justify-center mb-4",children:e.jsx(a,{className:"h-16 sm:h-20 md:h-24 lg:h-28 xl:h-32"})}),e.jsx("p",{className:"subcopy mt-3 mx-auto max-w-2xl",children:"Transcribe audio locally into text - Summarize and Chat with your audio. No GPU Required."}),e.jsxs("div",{className:"mt-8 flex items-center justify-center gap-3",children:[e.jsx("a",{href:"/docs/installation.html",className:"button-primary",children:"Get Started"}),e.jsx("a",{href:"#features",className:"button-ghost",children:"Learn more"})]}),e.jsx("div",{className:"mt-6 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",onClick:s=>{s.preventDefault(),window.open("https://ko-fi.com/H2H41KQZA3","_blank","noopener,noreferrer")},children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})}),e.jsx("div",{className:"mt-12 max-w-5xl mx-auto",children:e.jsxs("div",{className:"rounded-3xl shadow-soft overflow-hidden bg-white hover-lift",children:[e.jsxs("div",{className:"flex items-center gap-2 px-3 py-2 bg-gray-100",children:[e.jsx("span",{className:"size-3 rounded-full bg-red-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-yellow-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-green-400/80"})]}),e.jsx("img",{src:"/screenshots/scriberr-homepage.png",alt:"Scriberr homepage",className:"w-full object-cover"})]})}),e.jsxs("div",{className:"mt-6 flex items-center justify-center gap-4 text-xs text-gray-500",children:[e.jsx("span",{children:"Privacy preserving"}),e.jsx("span",{children:"Mobile ready"}),e.jsx("span",{children:"Fast and responsive"})]})]})})}const h=[{title:"Precise transcription",desc:"Tweak advanced transcription parameters to get the best quality output"},{title:"Built-in recorder",desc:"Capture audio directly in-app and transcribe instantly."},{title:"Summarize & chat",desc:"Extract key points or chat over transcripts using LLMs."},{title:"Lightweight notes",desc:"Highlight, annotate, and tag important moments as you listen/read."},{title:"Speaker diarization",desc:"Identify and label distinct speakers in your audio."},{title:"Profiles & presets",desc:"Save configurations for different audio scenarios."}];function x({name:s}){const r="size-4";switch(s){case"Precise transcription":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M12 3v10a3 3 0 1 1-6 0V8"}),e.jsx("path",{d:"M19 10v3a7 7 0 0 1-14 0"})]});case"Built-in recorder":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("rect",{x:"9",y:"9",width:"6",height:"6",rx:"3"}),e.jsx("circle",{cx:"12",cy:"12",r:"9"})]});case"Summarize & chat":return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M21 15a4 4 0 0 1-4 4H7l-4 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"})});case"Lightweight notes":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M9 7h6M9 12h6M9 17h6"}),e.jsx("rect",{x:"5",y:"3",width:"14",height:"18",rx:"2"})]});case"Speaker diarization":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("circle",{cx:"7",cy:"8",r:"3"}),e.jsx("path",{d:"M2 19a5 5 0 0 1 10 0"}),e.jsx("circle",{cx:"17",cy:"10",r:"2.5"}),e.jsx("path",{d:"M13 19c.5-2.5 2.5-4 5-4"})]});case"Profiles & presets":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M6 3v12"}),e.jsx("path",{d:"M12 3v18"}),e.jsx("path",{d:"M18 3v8"})]});default:return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M12 6v12m6-6H6"})})}}function p(){return e.jsxs("section",{id:"features",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Capabilities"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"Key Features"}),e.jsx("p",{className:"subcopy mt-2",children:"Curated set of features to manage and work with transcripts."})]}),e.jsx("div",{className:"grid sm:grid-cols-2 lg:grid-cols-3 gap-6",children:h.map(s=>e.jsxs("article",{className:"rounded-2xl p-6 bg-gray-50 shadow-soft hover-lift",children:[e.jsxs("div",{className:"mb-3 flex items-center gap-3",children:[e.jsx("span",{className:"inline-flex items-center justify-center size-9 rounded-xl bg-gray-100 text-gray-600 shadow-subtle",children:e.jsx(x,{name:s.title})}),e.jsx("h3",{className:"font-medium text-base md:text-lg text-gray-900",children:s.title})]}),e.jsx("p",{className:"subcopy",children:s.desc})]},s.title))})]})}const u=[{title:"Transcript view",desc:"Minimal reading experience with timestamps with playback follow along that highlights currently playing word.",img:"scriberr-transcript page.png",bullets:["Jump from audio timestamp to corresponding word","Jump from text to corresponding audio segment","View transcript as paragraph or as timestamped/speaker segments"]},{title:"Record right in Scriberr",desc:"Capture audio directly in-app and transcribe",img:"scriberr-inbuilt audio recorder for directly recording and transcribing audio within the app.png",bullets:[]},{title:"Summaries at a glance",desc:"Turn long recordings into brief, actionable summaries you can scan in seconds.",img:"scriberr-summarize transcripts.png",bullets:["Write your own custom prompts for summarization","Supports both Ollama/OpenAI (needs API Key) LLM providers","Save multiple summarization presets to reuse quickly"]},{title:"Annotate transcripts",desc:"Highlight important moments, jot down concise notes, and keep insights attached to the exact timestamp.",img:"scriberr-annotate transcript and take notes.png",bullets:["Highlight text to add a note","Timestamped notes allow jumping to exact segment"]},{title:"Advanced controls",desc:"Fine-tune model settings, language, and diarization for optimal results",img:"scriberr-fine tune advanced transcription parameters as you see fit to improve transcription quality.png",bullets:["Language hints and temperature","Diarization and VAD options","Profiles for repeatable setups"]},{title:"Bring your own providers",desc:"Use OpenAI or local models via Ollama for summaries and chat — your keys, your choice.",img:"scriberr-Ollama openAI llm providers for chat and summarization.png",bullets:["Works with OpenAI or Ollama"]},{title:"Export transcripts",desc:"Download your transcripts in multiple formats",img:"scriberr-download-transcript-in-different-formats.png",bullets:["Export to TXT, Markdown, JSON","Keep timestamps and speaker info"]},{title:"Chat with your transcript",desc:"Ask questions about your recording, extract insights, and clarify details without scrubbing through audio.",img:"scriberr-chat-with-your-recording-transcript.png",bullets:["Works with OpenAI or Ollama"]},{title:"API keys and REST API",desc:"Manage API keys and use the full REST API to build automations or integrate Scriberr into your own applications.",img:"scriberr-api-key-management.png",bullets:["Secure API key management","Endpoints for transcription, chat, notes and more"]},{title:"Transcribe YouTube videos",desc:"Paste a YouTube link to transcribe the audio directly — no downloads required.",img:"scriberr-youtube-video.png",bullets:["Grab insights from talks, podcasts and lectures","Works with summarization and notes"]}];function g(){return e.jsxs("section",{id:"details",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Details"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"A closer look"})]}),e.jsx("div",{className:"space-y-16 md:space-y-24",children:u.map((s,r)=>e.jsxs("div",{className:"grid md:grid-cols-2 gap-8 md:gap-10 items-center",children:[e.jsx("div",{className:r%2===0?"order-1 md:order-1":"order-2 md:order-2",children:e.jsxs("div",{children:[e.jsx("h3",{className:"text-xl md:text-2xl font-semibold text-gray-900",children:s.title}),e.jsx("p",{className:"subcopy mt-2",children:s.desc}),s.bullets&&e.jsx("ul",{className:"mt-4 space-y-2 text-sm text-gray-600 list-disc list-inside",children:s.bullets.map(t=>e.jsx("li",{children:t},t))})]})}),e.jsx("div",{className:r%2===0?"order-2 md:order-2":"order-1 md:order-1",children:e.jsx(c,{src:`/screenshots/${s.img}`,alt:s.title})})]},s.title))})]})}function f(){return e.jsx("footer",{className:"mt-8 bg-gray-50",children:e.jsxs("div",{className:"container-narrow py-10 text-center text-sm text-gray-700",children:[e.jsxs("p",{children:["If you like Scriberr, consider giving the project a star on"," ",e.jsx("a",{href:"https://github.com/rishikanthc/scriberr",target:"_blank",rel:"noreferrer",className:"text-blue-600 hover:text-blue-700 underline-offset-2 hover:underline",children:"GitHub"}),"."]}),e.jsx("div",{className:"mt-4 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})})]})})}function j(){return e.jsxs("div",{className:"min-h-screen flex flex-col",children:[e.jsx(d,{}),e.jsxs("main",{className:"flex-1",children:[e.jsx(m,{}),e.jsx(p,{}),e.jsx(g,{})]}),e.jsx(f,{})]})}const b=o(document.getElementById("root"));b.render(e.jsx(l.StrictMode,{children:e.jsx(j,{})}));
+import{j as e,r as i,G as n,c as o,R as l}from"./styles-DGJLjUaE.js";import{W as c}from"./Window-BJy5jSY4.js";function a({className:s=""}){return e.jsx("img",{src:"/scriberr-logo.png",alt:"Scriberr",className:`w-auto select-none ${s}`})}function d(){const[s,r]=i.useState(!1);return e.jsxs("header",{className:"sticky top-0 z-40 bg-white/80 backdrop-blur shadow-soft",children:[e.jsxs("div",{className:"container-narrow py-4 flex items-center justify-between",children:[e.jsx("a",{href:"#",className:"flex items-center gap-3",children:e.jsx(a,{className:"h-8 sm:h-10"})}),e.jsxs("nav",{className:"hidden md:flex items-center gap-6 text-sm text-gray-600",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",children:"API"})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("button",{className:"md:hidden inline-flex items-center justify-center rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-gray-700 hover:bg-gray-50","aria-label":"Menu",onClick:()=>r(t=>!t),children:e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:"size-5",children:e.jsx("path",{d:"M4 7h16M4 12h16M4 17h16"})})}),e.jsx(n,{})]})]}),s&&e.jsx("div",{className:"md:hidden border-t border-gray-200 bg-white",children:e.jsxs("div",{className:"container-narrow py-3 flex flex-col gap-2 text-sm text-gray-700",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"API"})]})})]})}function m(){return e.jsx("section",{className:"relative",children:e.jsxs("div",{className:"container-narrow section text-center",children:[e.jsx("span",{className:"eyebrow mb-3 inline-block",children:"Self-hosted offline audio transcription"}),e.jsx("h1",{className:"headline flex justify-center mb-4",children:e.jsx(a,{className:"h-16 sm:h-20 md:h-24 lg:h-28 xl:h-32"})}),e.jsx("p",{className:"subcopy mt-3 mx-auto max-w-2xl",children:"Transcribe audio locally into text - Summarize and Chat with your audio. No GPU Required."}),e.jsxs("div",{className:"mt-8 flex items-center justify-center gap-3",children:[e.jsx("a",{href:"/docs/installation.html",className:"button-primary",children:"Get Started"}),e.jsx("a",{href:"#features",className:"button-ghost",children:"Learn more"})]}),e.jsx("div",{className:"mt-6 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",onClick:s=>{s.preventDefault(),window.open("https://ko-fi.com/H2H41KQZA3","_blank","noopener,noreferrer")},children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})}),e.jsx("div",{className:"mt-12 max-w-5xl mx-auto",children:e.jsxs("div",{className:"rounded-3xl shadow-soft overflow-hidden bg-white hover-lift",children:[e.jsxs("div",{className:"flex items-center gap-2 px-3 py-2 bg-gray-100",children:[e.jsx("span",{className:"size-3 rounded-full bg-red-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-yellow-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-green-400/80"})]}),e.jsx("img",{src:"/screenshots/scriberr-homepage.png",alt:"Scriberr homepage",className:"w-full object-cover"})]})}),e.jsxs("div",{className:"mt-6 flex items-center justify-center gap-4 text-xs text-gray-500",children:[e.jsx("span",{children:"Privacy preserving"}),e.jsx("span",{children:"Mobile ready"}),e.jsx("span",{children:"Fast and responsive"})]})]})})}const h=[{title:"Precise transcription",desc:"Tweak advanced transcription parameters to get the best quality output"},{title:"Built-in recorder",desc:"Capture audio directly in-app and transcribe instantly."},{title:"Summarize & chat",desc:"Extract key points or chat over transcripts using LLMs."},{title:"Lightweight notes",desc:"Highlight, annotate, and tag important moments as you listen/read."},{title:"Speaker diarization",desc:"Identify and label distinct speakers in your audio."},{title:"Profiles & presets",desc:"Save configurations for different audio scenarios."}];function x({name:s}){const r="size-4";switch(s){case"Precise transcription":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M12 3v10a3 3 0 1 1-6 0V8"}),e.jsx("path",{d:"M19 10v3a7 7 0 0 1-14 0"})]});case"Built-in recorder":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("rect",{x:"9",y:"9",width:"6",height:"6",rx:"3"}),e.jsx("circle",{cx:"12",cy:"12",r:"9"})]});case"Summarize & chat":return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M21 15a4 4 0 0 1-4 4H7l-4 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"})});case"Lightweight notes":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M9 7h6M9 12h6M9 17h6"}),e.jsx("rect",{x:"5",y:"3",width:"14",height:"18",rx:"2"})]});case"Speaker diarization":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("circle",{cx:"7",cy:"8",r:"3"}),e.jsx("path",{d:"M2 19a5 5 0 0 1 10 0"}),e.jsx("circle",{cx:"17",cy:"10",r:"2.5"}),e.jsx("path",{d:"M13 19c.5-2.5 2.5-4 5-4"})]});case"Profiles & presets":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M6 3v12"}),e.jsx("path",{d:"M12 3v18"}),e.jsx("path",{d:"M18 3v8"})]});default:return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M12 6v12m6-6H6"})})}}function p(){return e.jsxs("section",{id:"features",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Capabilities"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"Key Features"}),e.jsx("p",{className:"subcopy mt-2",children:"Curated set of features to manage and work with transcripts."})]}),e.jsx("div",{className:"grid sm:grid-cols-2 lg:grid-cols-3 gap-6",children:h.map(s=>e.jsxs("article",{className:"rounded-2xl p-6 bg-gray-50 shadow-soft hover-lift",children:[e.jsxs("div",{className:"mb-3 flex items-center gap-3",children:[e.jsx("span",{className:"inline-flex items-center justify-center size-9 rounded-xl bg-gray-100 text-gray-600 shadow-subtle",children:e.jsx(x,{name:s.title})}),e.jsx("h3",{className:"font-medium text-base md:text-lg text-gray-900",children:s.title})]}),e.jsx("p",{className:"subcopy",children:s.desc})]},s.title))})]})}const u=[{title:"Transcript view",desc:"Minimal reading experience with timestamps with playback follow along that highlights currently playing word.",img:"scriberr-transcript page.png",bullets:["Jump from audio timestamp to corresponding word","Jump from text to corresponding audio segment","View transcript as paragraph or as timestamped/speaker segments"]},{title:"Record right in Scriberr",desc:"Capture audio directly in-app and transcribe",img:"scriberr-inbuilt audio recorder for directly recording and transcribing audio within the app.png",bullets:[]},{title:"Summaries at a glance",desc:"Turn long recordings into brief, actionable summaries you can scan in seconds.",img:"scriberr-summarize transcripts.png",bullets:["Write your own custom prompts for summarization","Supports both Ollama/OpenAI (needs API Key) LLM providers","Save multiple summarization presets to reuse quickly"]},{title:"Annotate transcripts",desc:"Highlight important moments, jot down concise notes, and keep insights attached to the exact timestamp.",img:"scriberr-annotate transcript and take notes.png",bullets:["Highlight text to add a note","Timestamped notes allow jumping to exact segment"]},{title:"Advanced controls",desc:"Fine-tune model settings, language, and diarization for optimal results",img:"scriberr-fine tune advanced transcription parameters as you see fit to improve transcription quality.png",bullets:["Language hints and temperature","Diarization and VAD options","Profiles for repeatable setups"]},{title:"Bring your own providers",desc:"Use OpenAI or local models via Ollama for summaries and chat — your keys, your choice.",img:"scriberr-Ollama:openAI llm providers for chat and summarization.png",bullets:["Works with OpenAI or Ollama"]},{title:"Export transcripts",desc:"Download your transcripts in multiple formats",img:"scriberr-download-transcript-in-different-formats.png",bullets:["Export to TXT, Markdown, JSON","Keep timestamps and speaker info"]},{title:"Chat with your transcript",desc:"Ask questions about your recording, extract insights, and clarify details without scrubbing through audio.",img:"scriberr-chat-with-your-recording-transcript.png",bullets:["Works with OpenAI or Ollama"]},{title:"API keys and REST API",desc:"Manage API keys and use the full REST API to build automations or integrate Scriberr into your own applications.",img:"scriberr-api-key-management.png",bullets:["Secure API key management","Endpoints for transcription, chat, notes and more"]},{title:"Transcribe YouTube videos",desc:"Paste a YouTube link to transcribe the audio directly — no downloads required.",img:"scriberr-youtube-video.png",bullets:["Grab insights from talks, podcasts and lectures","Works with summarization and notes"]}];function g(){return e.jsxs("section",{id:"details",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Details"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"A closer look"})]}),e.jsx("div",{className:"space-y-16 md:space-y-24",children:u.map((s,r)=>e.jsxs("div",{className:"grid md:grid-cols-2 gap-8 md:gap-10 items-center",children:[e.jsx("div",{className:r%2===0?"order-1 md:order-1":"order-2 md:order-2",children:e.jsxs("div",{children:[e.jsx("h3",{className:"text-xl md:text-2xl font-semibold text-gray-900",children:s.title}),e.jsx("p",{className:"subcopy mt-2",children:s.desc}),s.bullets&&e.jsx("ul",{className:"mt-4 space-y-2 text-sm text-gray-600 list-disc list-inside",children:s.bullets.map(t=>e.jsx("li",{children:t},t))})]})}),e.jsx("div",{className:r%2===0?"order-2 md:order-2":"order-1 md:order-1",children:e.jsx(c,{src:`/screenshots/${s.img}`,alt:s.title})})]},s.title))})]})}function f(){return e.jsx("footer",{className:"mt-8 bg-gray-50",children:e.jsxs("div",{className:"container-narrow py-10 text-center text-sm text-gray-700",children:[e.jsxs("p",{children:["If you like Scriberr, consider giving the project a star on"," ",e.jsx("a",{href:"https://github.com/rishikanthc/scriberr",target:"_blank",rel:"noreferrer",className:"text-blue-600 hover:text-blue-700 underline-offset-2 hover:underline",children:"GitHub"}),"."]}),e.jsx("div",{className:"mt-4 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})})]})})}function j(){return e.jsxs("div",{className:"min-h-screen flex flex-col",children:[e.jsx(d,{}),e.jsxs("main",{className:"flex-1",children:[e.jsx(m,{}),e.jsx(p,{}),e.jsx(g,{})]}),e.jsx(f,{})]})}const b=o(document.getElementById("root"));b.render(e.jsx(l.StrictMode,{children:e.jsx(j,{})}));
diff --git a/docs/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png b/docs/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png
new file mode 100644
index 000000000..4c925fec0
Binary files /dev/null and b/docs/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png differ
diff --git a/go.mod b/go.mod
index 43eadf34a..d0e780dde 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.32.0
gorm.io/gorm v1.30.1
+ gorm.io/driver/postgres v1.5.7
)
require (
diff --git a/internal/api/chat_handlers.go b/internal/api/chat_handlers.go
index 1dac54752..8a78302fc 100644
--- a/internal/api/chat_handlers.go
+++ b/internal/api/chat_handlers.go
@@ -62,14 +62,27 @@ type ChatSessionWithMessages struct {
}
// getLLMService returns a provider-agnostic LLM service based on active config
-func (h *Handler) getLLMService() (llm.Service, string, error) {
- var cfg models.LLMConfig
- if err := database.DB.Where("is_active = ?", true).First(&cfg).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- return nil, "", fmt.Errorf("no active LLM configuration found")
- }
- return nil, "", fmt.Errorf("failed to get LLM config: %w", err)
- }
+func (h *Handler) getLLMService(c *gin.Context) (llm.Service, string, error) {
+ var cfg models.LLMConfig
+ // Prefer user-scoped config; fallback to global
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("is_active = ? AND user_id = ?", true, uid).First(&cfg).Error; err != nil {
+ if err != gorm.ErrRecordNotFound {
+ return nil, "", fmt.Errorf("failed to get LLM config: %w", err)
+ }
+ if err := database.DB.Where("is_active = ? AND user_id = 0", true).First(&cfg).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, "", fmt.Errorf("no active LLM configuration found")
+ }
+ return nil, "", fmt.Errorf("failed to get LLM config: %w", err)
+ }
+ }
+ } else if err := database.DB.Where("is_active = ?", true).First(&cfg).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, "", fmt.Errorf("no active LLM configuration found")
+ }
+ return nil, "", fmt.Errorf("failed to get LLM config: %w", err)
+ }
switch strings.ToLower(cfg.Provider) {
case "openai":
if cfg.APIKey == nil || *cfg.APIKey == "" {
@@ -97,7 +110,7 @@ func (h *Handler) getLLMService() (llm.Service, string, error) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetChatModels(c *gin.Context) {
- svc, _, err := h.getLLMService()
+ svc, _, err := h.getLLMService(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -136,15 +149,24 @@ func (h *Handler) CreateChatSession(c *gin.Context) {
}
// Verify transcription exists and has completed transcript
- var transcription models.TranscriptionJob
- if err := database.DB.Where("id = ?", req.TranscriptionID).First(&transcription).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription"})
- return
- }
+ var transcription models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", req.TranscriptionID, uid).First(&transcription).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", req.TranscriptionID).First(&transcription).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription"})
+ return
+ }
if transcription.Status != models.StatusCompleted || transcription.Transcript == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription must be completed to create a chat session"})
@@ -152,7 +174,7 @@ func (h *Handler) CreateChatSession(c *gin.Context) {
}
// Verify LLM service is available
- _, _, err := h.getLLMService()
+ _, _, err := h.getLLMService(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -165,16 +187,19 @@ func (h *Handler) CreateChatSession(c *gin.Context) {
}
now := time.Now()
- chatSession := models.ChatSession{
- JobID: req.TranscriptionID, // Use same ID for JobID as TranscriptionID
- TranscriptionID: req.TranscriptionID,
- Title: title,
- Model: req.Model,
- Provider: "openai",
- MessageCount: 0,
- LastActivityAt: &now,
- IsActive: true,
- }
+ chatSession := models.ChatSession{
+ JobID: req.TranscriptionID, // Use same ID for JobID as TranscriptionID
+ TranscriptionID: req.TranscriptionID,
+ Title: title,
+ Model: req.Model,
+ Provider: "openai",
+ MessageCount: 0,
+ LastActivityAt: &now,
+ IsActive: true,
+ }
+ if uid, ok := getContextUserID(c); ok {
+ chatSession.UserID = uid
+ }
if err := database.DB.Create(&chatSession).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create chat session"})
@@ -209,18 +234,31 @@ func (h *Handler) CreateChatSession(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetChatSessions(c *gin.Context) {
- transcriptionID := c.Param("transcription_id")
- if transcriptionID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID is required"})
- return
- }
-
- var sessions []models.ChatSession
- if err := database.DB.Where("transcription_id = ?", transcriptionID).
- Order("updated_at DESC").Find(&sessions).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat sessions"})
- return
- }
+ transcriptionID := c.Param("transcription_id")
+ if transcriptionID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID is required"})
+ return
+ }
+
+ // Verify transcription ownership
+ if uid, ok := getContextUserID(c); ok {
+ var t models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", transcriptionID, uid).First(&t).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ }
+
+ var sessions []models.ChatSession
+ query := database.DB.Where("transcription_id = ?", transcriptionID)
+ if uid, ok := getContextUserID(c); ok {
+ query = query.Where("user_id = ?", uid)
+ }
+ if err := query.
+ Order("updated_at DESC").Find(&sessions).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat sessions"})
+ return
+ }
// Extract session IDs for batch queries
sessionIDs := make([]string, len(sessions))
@@ -301,21 +339,30 @@ func (h *Handler) GetChatSessions(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetChatSession(c *gin.Context) {
- sessionID := c.Param("session_id")
- if sessionID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Session ID is required"})
- return
- }
-
- var session models.ChatSession
- if err := database.DB.Where("id = ?", sessionID).First(&session).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
- return
- }
+ sessionID := c.Param("session_id")
+ if sessionID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Session ID is required"})
+ return
+ }
+
+ var session models.ChatSession
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", sessionID, uid).First(&session).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", sessionID).First(&session).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
+ return
+ }
var messages []models.ChatMessage
if err := database.DB.Where("chat_session_id = ?", sessionID).
@@ -382,17 +429,26 @@ func (h *Handler) SendChatMessage(c *gin.Context) {
// Get chat session
var session models.ChatSession
- if err := database.DB.Preload("Transcription").Where("id = ?", sessionID).First(&session).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
- return
- }
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Preload("Transcription").Where("id = ? AND user_id = ?", sessionID, uid).First(&session).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
+ return
+ }
+ } else if err := database.DB.Preload("Transcription").Where("id = ?", sessionID).First(&session).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
+ return
+ }
// Get LLM service
- svc, _, err := h.getLLMService()
+ svc, _, err := h.getLLMService(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -567,15 +623,24 @@ func (h *Handler) UpdateChatSessionTitle(c *gin.Context) {
return
}
- var session models.ChatSession
- if err := database.DB.Where("id = ?", sessionID).First(&session).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
- return
- }
+ var session models.ChatSession
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", sessionID, uid).First(&session).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", sessionID).First(&session).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get chat session"})
+ return
+ }
session.Title = req.Title
if err := database.DB.Save(&session).Error; err != nil {
@@ -617,18 +682,31 @@ func (h *Handler) DeleteChatSession(c *gin.Context) {
return
}
- // Delete messages first (due to foreign key constraint)
- if err := database.DB.Where("chat_session_id = ?", sessionID).Delete(&models.ChatMessage{}).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete messages"})
- return
- }
-
- // Delete session
- result := database.DB.Where("id = ?", sessionID).Delete(&models.ChatSession{})
- if result.Error != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete chat session"})
- return
- }
+ // Delete messages first (due to foreign key constraint)
+ if err := database.DB.Where("chat_session_id = ?", sessionID).Delete(&models.ChatMessage{}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete messages"})
+ return
+ }
+
+ // Delete session
+ if uid, ok := getContextUserID(c); ok {
+ result := database.DB.Where("id = ? AND user_id = ?", sessionID, uid).Delete(&models.ChatSession{})
+ if result.Error != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete chat session"})
+ return
+ }
+ if result.RowsAffected == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ return
+ }
+ result := database.DB.Where("id = ?", sessionID).Delete(&models.ChatSession{})
+ if result.Error != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete chat session"})
+ return
+ }
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Chat session not found"})
@@ -772,7 +850,7 @@ Return only the title, nothing else.`
}
// Use configured LLM service
- svc, _, err := h.getLLMService()
+ svc, _, err := h.getLLMService(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 06b834c85..1a055ff12 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -1,7 +1,6 @@
package api
import (
- "bytes"
"context"
"crypto/rand"
"crypto/sha256"
@@ -33,12 +32,36 @@ import (
// Handler contains all the API handlers
type Handler struct {
- config *config.Config
- authService *auth.AuthService
- taskQueue *queue.TaskQueue
- unifiedProcessor *transcription.UnifiedJobProcessor
- quickTranscription *transcription.QuickTranscriptionService
- multiTrackProcessor *processing.MultiTrackProcessor
+ config *config.Config
+ authService *auth.AuthService
+ taskQueue *queue.TaskQueue
+ unifiedProcessor *transcription.UnifiedJobProcessor
+ quickTranscription *transcription.QuickTranscriptionService
+ multiTrackProcessor *processing.MultiTrackProcessor
+}
+
+// getContextUserID extracts the user_id from Gin context and normalizes to uint
+func getContextUserID(c *gin.Context) (uint, bool) {
+ v, ok := c.Get("user_id")
+ if !ok {
+ return 0, false
+ }
+ switch id := v.(type) {
+ case uint:
+ return id, true
+ case int:
+ if id < 0 {
+ return 0, false
+ }
+ return uint(id), true
+ case int64:
+ if id < 0 {
+ return 0, false
+ }
+ return uint(id), true
+ default:
+ return 0, false
+ }
}
// NewHandler creates a new handler
@@ -219,8 +242,11 @@ func (h *Handler) UploadAudio(c *gin.Context) {
}
defer file.Close()
- // Create upload directory
- uploadDir := h.config.UploadDir
+ // Create upload directory (user scoped if available)
+ uploadDir := h.config.UploadDir
+ if uid, ok := getContextUserID(c); ok {
+ uploadDir = filepath.Join(uploadDir, fmt.Sprintf("user_%d", uid))
+ }
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
return
@@ -246,11 +272,14 @@ func (h *Handler) UploadAudio(c *gin.Context) {
}
// Create job record with "uploaded" status (not queued for transcription)
- job := models.TranscriptionJob{
- ID: jobID,
- AudioPath: filePath,
- Status: models.StatusUploaded, // New status for uploaded but not transcribed
- }
+ job := models.TranscriptionJob{
+ ID: jobID,
+ AudioPath: filePath,
+ Status: models.StatusUploaded, // New status for uploaded but not transcribed
+ }
+ if uid, ok := getContextUserID(c); ok {
+ job.UserID = uid
+ }
if title := c.PostForm("title"); title != "" {
job.Title = &title
@@ -271,22 +300,22 @@ func (h *Handler) UploadAudio(c *gin.Context) {
var profile models.TranscriptionProfile
var profileFound bool
- if user.DefaultProfileID != nil {
- err = database.DB.Where("id = ?", *user.DefaultProfileID).First(&profile).Error
- profileFound = (err == nil)
- }
+ if user.DefaultProfileID != nil {
+ err = database.DB.Where("id = ? AND user_id = ?", *user.DefaultProfileID, user.ID).First(&profile).Error
+ profileFound = (err == nil)
+ }
- // If no user default or user default not found, try to find a system default
- if !profileFound {
- err = database.DB.Where("is_default = ?", true).First(&profile).Error
- profileFound = (err == nil)
- }
+ // If no user default or user default not found, try to find a system default
+ if !profileFound {
+ err = database.DB.Where("is_default = ? AND user_id = ?", true, user.ID).First(&profile).Error
+ profileFound = (err == nil)
+ }
- // If still no profile found, use the first available profile
- if !profileFound {
- err = database.DB.Order("created_at ASC").First(&profile).Error
- profileFound = (err == nil)
- }
+ // If still no profile found, use the first available profile
+ if !profileFound {
+ err = database.DB.Where("user_id = ?", user.ID).Order("created_at ASC").First(&profile).Error
+ profileFound = (err == nil)
+ }
// If we found a profile, update the job and queue it
if profileFound {
@@ -332,8 +361,11 @@ func (h *Handler) UploadVideo(c *gin.Context) {
}
defer file.Close()
- // Create upload directory
- uploadDir := h.config.UploadDir
+ // Create upload directory (user scoped if available)
+ uploadDir := h.config.UploadDir
+ if uid, ok := getContextUserID(c); ok {
+ uploadDir = filepath.Join(uploadDir, fmt.Sprintf("user_%d", uid))
+ }
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
return
@@ -387,11 +419,14 @@ func (h *Handler) UploadVideo(c *gin.Context) {
}
// Create job record with "uploaded" status (not queued for transcription)
- job := models.TranscriptionJob{
- ID: jobID,
- AudioPath: audioPath,
- Status: models.StatusUploaded, // Same status as audio uploads
- }
+ job := models.TranscriptionJob{
+ ID: jobID,
+ AudioPath: audioPath,
+ Status: models.StatusUploaded, // Same status as audio uploads
+ }
+ if uid, ok := getContextUserID(c); ok {
+ job.UserID = uid
+ }
if title := c.PostForm("title"); title != "" {
job.Title = &title
@@ -412,22 +447,22 @@ func (h *Handler) UploadVideo(c *gin.Context) {
var profile models.TranscriptionProfile
var profileFound bool
- if user.DefaultProfileID != nil {
- err = database.DB.Where("id = ?", *user.DefaultProfileID).First(&profile).Error
- profileFound = (err == nil)
- }
+ if user.DefaultProfileID != nil {
+ err = database.DB.Where("id = ? AND user_id = ?", *user.DefaultProfileID, user.ID).First(&profile).Error
+ profileFound = (err == nil)
+ }
- // If no user default or user default not found, try to find a system default
- if !profileFound {
- err = database.DB.Where("is_default = ?", true).First(&profile).Error
- profileFound = (err == nil)
- }
+ // If no user default or user default not found, try to find a system default
+ if !profileFound {
+ err = database.DB.Where("is_default = ? AND user_id = ?", true, user.ID).First(&profile).Error
+ profileFound = (err == nil)
+ }
- // If still no profile found, use the first available profile
- if !profileFound {
- err = database.DB.Order("created_at ASC").First(&profile).Error
- profileFound = (err == nil)
- }
+ // If still no profile found, use the first available profile
+ if !profileFound {
+ err = database.DB.Where("user_id = ?", user.ID).Order("created_at ASC").First(&profile).Error
+ profileFound = (err == nil)
+ }
// If we found a profile, update the job and queue it
if profileFound {
@@ -503,9 +538,12 @@ func (h *Handler) UploadMultiTrack(c *gin.Context) {
// Generate unique job ID
jobID := uuid.New().String()
- // Create job-specific directory structure
- uploadDir := h.config.UploadDir
- multiTrackFolder := filepath.Join(uploadDir, jobID)
+ // Create job-specific directory structure (user scoped if available)
+ uploadDir := h.config.UploadDir
+ if uid, ok := getContextUserID(c); ok {
+ uploadDir = filepath.Join(uploadDir, fmt.Sprintf("user_%d", uid))
+ }
+ multiTrackFolder := filepath.Join(uploadDir, jobID)
tracksFolder := filepath.Join(multiTrackFolder, "tracks")
if err := os.MkdirAll(tracksFolder, 0755); err != nil {
@@ -592,16 +630,19 @@ func (h *Handler) UploadMultiTrack(c *gin.Context) {
}
// Create transcription job record
- job := models.TranscriptionJob{
- ID: jobID,
- Title: &title,
- AudioPath: firstTrackPath, // Point to first track initially
- Status: models.StatusUploaded,
- IsMultiTrack: true,
- AupFilePath: &aupFilePath,
- MultiTrackFolder: &multiTrackFolder,
- MergeStatus: "none", // No merge processing yet
- }
+ job := models.TranscriptionJob{
+ ID: jobID,
+ Title: &title,
+ AudioPath: firstTrackPath, // Point to first track initially
+ Status: models.StatusUploaded,
+ IsMultiTrack: true,
+ AupFilePath: &aupFilePath,
+ MultiTrackFolder: &multiTrackFolder,
+ MergeStatus: "none", // No merge processing yet
+ }
+ if uid, ok := getContextUserID(c); ok {
+ job.UserID = uid
+ }
// Save job to database
if err := database.DB.Create(&job).Error; err != nil {
@@ -651,13 +692,27 @@ func (h *Handler) UploadMultiTrack(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetMergeStatus(c *gin.Context) {
- jobID := c.Param("id")
-
- status, errorMsg, err := h.multiTrackProcessor.GetMergeStatus(jobID)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
+ jobID := c.Param("id")
+
+ // Verify ownership
+ var check models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", jobID, uid).First(&check).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ } else {
+ if err := database.DB.Select("id").Where("id = ?", jobID).First(&check).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ }
+
+ status, errorMsg, err := h.multiTrackProcessor.GetMergeStatus(jobID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
response := gin.H{
"merge_status": status,
@@ -685,10 +740,15 @@ func (h *Handler) GetTrackProgress(c *gin.Context) {
// Get the main job details
var job models.TranscriptionJob
- if err := database.DB.Preload("MultiTrackFiles").Where("id = ?", jobID).First(&job).Error; err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Preload("MultiTrackFiles").Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ } else if err := database.DB.Preload("MultiTrackFiles").Where("id = ?", jobID).First(&job).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
// Only provide track progress for multi-track jobs
if !job.IsMultiTrack {
@@ -800,8 +860,11 @@ func (h *Handler) SubmitJob(c *gin.Context) {
}
defer file.Close()
- // Create upload directory
- uploadDir := h.config.UploadDir
+ // Create upload directory (user scoped if available)
+ uploadDir := h.config.UploadDir
+ if uid, ok := getContextUserID(c); ok {
+ uploadDir = filepath.Join(uploadDir, fmt.Sprintf("user_%d", uid))
+ }
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
return
@@ -872,13 +935,16 @@ func (h *Handler) SubmitJob(c *gin.Context) {
params.DiarizeModel = diarizeModel
// Create job
- job := models.TranscriptionJob{
- ID: jobID,
- AudioPath: filePath,
- Status: models.StatusPending,
- Diarization: diarize,
- Parameters: params,
- }
+ job := models.TranscriptionJob{
+ ID: jobID,
+ AudioPath: filePath,
+ Status: models.StatusPending,
+ Diarization: diarize,
+ Parameters: params,
+ }
+ if uid, ok := getContextUserID(c); ok {
+ job.UserID = uid
+ }
if title := c.PostForm("title"); title != "" {
job.Title = &title
@@ -911,12 +977,21 @@ func (h *Handler) SubmitJob(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetJobStatus(c *gin.Context) {
- jobID := c.Param("id")
-
- job, err := h.taskQueue.GetJobStatus(jobID)
- if err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ jobID := c.Param("id")
+
+ // Verify ownership
+ if uid, ok := getContextUserID(c); ok {
+ var check models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", jobID, uid).First(&check).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ }
+
+ job, err := h.taskQueue.GetJobStatus(jobID)
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job status"})
@@ -938,17 +1013,26 @@ func (h *Handler) GetJobStatus(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetTranscript(c *gin.Context) {
- jobID := c.Param("id")
-
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
- return
- }
+ jobID := c.Param("id")
+
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
if job.Status != models.StatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{
@@ -1004,7 +1088,10 @@ func (h *Handler) ListJobs(c *gin.Context) {
offset := (page - 1) * limit
- query := database.DB.Model(&models.TranscriptionJob{})
+ query := database.DB.Model(&models.TranscriptionJob{})
+ if uid, ok := getContextUserID(c); ok {
+ query = query.Where("user_id = ?", uid)
+ }
// Filter out temporary track jobs (they have IDs starting with "track_")
query = query.Where("id NOT LIKE 'track_%'")
@@ -1060,15 +1147,24 @@ func (h *Handler) ListJobs(c *gin.Context) {
func (h *Handler) StartTranscription(c *gin.Context) {
jobID := c.Param("id")
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
- return
- }
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
// Allow transcription for uploaded, completed, and failed jobs (re-transcription)
if job.Status != models.StatusUploaded && job.Status != models.StatusCompleted && job.Status != models.StatusFailed {
@@ -1220,15 +1316,24 @@ func (h *Handler) StartTranscription(c *gin.Context) {
func (h *Handler) KillJob(c *gin.Context) {
jobID := c.Param("id")
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
- return
- }
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
// Check if job is currently processing
if job.Status != models.StatusProcessing {
@@ -1274,15 +1379,24 @@ func (h *Handler) UpdateTranscriptionTitle(c *gin.Context) {
return
}
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
- return
- }
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
job.Title = &body.Title
if err := database.DB.Model(&job).Update("title", body.Title).Error; err != nil {
@@ -1311,17 +1425,26 @@ func (h *Handler) UpdateTranscriptionTitle(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) DeleteJob(c *gin.Context) {
- jobID := c.Param("id")
-
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
- return
- }
+ jobID := c.Param("id")
+
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
+ return
+ }
// Prevent deletion of jobs that are currently processing
if job.Status == models.StatusProcessing {
@@ -1474,14 +1597,23 @@ func (h *Handler) GetJobExecutionData(c *gin.Context) {
// Get the transcription job to check if it's multi-track
var job models.TranscriptionJob
- if err := database.DB.Preload("MultiTrackFiles").Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
- return
- }
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Preload("MultiTrackFiles").Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
+ return
+ }
+ } else if err := database.DB.Preload("MultiTrackFiles").Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
+ return
+ }
var execution models.TranscriptionJobExecution
if err := database.DB.Where("transcription_job_id = ? AND status = ?", jobID, models.StatusCompleted).
@@ -1691,17 +1823,19 @@ func (h *Handler) Logout(c *gin.Context) {
// @Success 200 {object} RegistrationStatusResponse
// @Router /api/v1/auth/registration-status [get]
func (h *Handler) GetRegistrationStatus(c *gin.Context) {
- var userCount int64
- if err := database.DB.Model(&models.User{}).Count(&userCount).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check registration status"})
- return
- }
+ var userCount int64
+ if err := database.DB.Model(&models.User{}).Count(&userCount).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check registration status"})
+ return
+ }
- response := RegistrationStatusResponse{
- RegistrationEnabled: userCount == 0,
- }
+ response := RegistrationStatusResponse{
+ // Semantics for frontend: registration_enabled == true => initial admin registration required
+ // Keep this true only when there are no users yet. Open registration is controlled by backend in Register.
+ RegistrationEnabled: userCount == 0,
+ }
- c.JSON(http.StatusOK, response)
+ c.JSON(http.StatusOK, response)
}
// @Summary Register initial admin user
@@ -1715,17 +1849,16 @@ func (h *Handler) GetRegistrationStatus(c *gin.Context) {
// @Failure 409 {object} map[string]string
// @Router /api/v1/auth/register [post]
func (h *Handler) Register(c *gin.Context) {
- // Check if any users already exist
- var userCount int64
- if err := database.DB.Model(&models.User{}).Count(&userCount).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing users"})
- return
- }
-
- if userCount > 0 {
- c.JSON(http.StatusConflict, gin.H{"error": "Registration is not allowed. Admin user already exists"})
- return
- }
+ // Allow registration when enabled or when no users exist
+ var userCount int64
+ if err := database.DB.Model(&models.User{}).Count(&userCount).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing users"})
+ return
+ }
+ if !h.config.AllowRegistration && userCount > 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "Registration is disabled"})
+ return
+ }
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1746,20 +1879,34 @@ func (h *Handler) Register(c *gin.Context) {
return
}
- // Create user
- user := models.User{
- Username: req.Username,
- Password: hashedPassword,
- }
-
- if err := database.DB.Create(&user).Error; err != nil {
- if database.DB.Error.Error() == "UNIQUE constraint failed: users.username" {
- c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
- return
- }
+ // Ensure username is not already taken
+ {
+ var existing models.User
+ if err := database.DB.Where("username = ?", req.Username).First(&existing).Error; err == nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
+ return
+ } else if err != nil && err != gorm.ErrRecordNotFound {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify username"})
+ return
+ }
+ }
+
+ // Create user; first user becomes admin
+ user := models.User{
+ Username: req.Username,
+ Password: hashedPassword,
+ IsAdmin: userCount == 0,
+ }
+
+ if err := database.DB.Create(&user).Error; err != nil {
+ // Handle unique constraint across different drivers
+ if strings.Contains(strings.ToLower(err.Error()), "unique") && strings.Contains(err.Error(), "users.username") {
+ c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
+ return
+ }
// Generate token for immediate login
token, err := h.authService.GenerateToken(&user)
@@ -1994,11 +2141,17 @@ func (h *Handler) ChangeUsername(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/api-keys [get]
func (h *Handler) ListAPIKeys(c *gin.Context) {
- var apiKeys []models.APIKey
- if err := database.DB.Where("is_active = ?", true).Find(&apiKeys).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch API keys"})
- return
- }
+ var apiKeys []models.APIKey
+ // Scope to current user
+ uid, ok := getContextUserID(c)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
+ return
+ }
+ if err := database.DB.Where("is_active = ? AND user_id = ?", true, uid).Find(&apiKeys).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch API keys"})
+ return
+ }
// Transform API keys to list response format
var responseKeys []APIKeyListResponse
@@ -2020,27 +2173,30 @@ func (h *Handler) ListAPIKeys(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/api-keys [post]
func (h *Handler) CreateAPIKey(c *gin.Context) {
- var req CreateAPIKeyRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
- return
- }
-
- // Generate a secure API key
- apiKey := generateSecureAPIKey(32)
-
- // Create the API key record
- newKey := models.APIKey{
- Key: apiKey,
- Name: req.Name,
- Description: &req.Description,
- IsActive: true,
- }
-
- if err := database.DB.Create(&newKey).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create API key"})
- return
- }
+ var req CreateAPIKeyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Generate a secure API key
+ apiKey := generateSecureAPIKey(32)
+
+ // Create the API key record
+ newKey := models.APIKey{
+ Key: apiKey,
+ Name: req.Name,
+ Description: &req.Description,
+ IsActive: true,
+ }
+ if uid, ok := getContextUserID(c); ok {
+ newKey.UserID = uid
+ }
+
+ if err := database.DB.Create(&newKey).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create API key"})
+ return
+ }
// Return full model with 200 to match tests
c.JSON(http.StatusOK, newKey)
@@ -2056,19 +2212,24 @@ func (h *Handler) CreateAPIKey(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/api-keys/{id} [delete]
func (h *Handler) DeleteAPIKey(c *gin.Context) {
- idParam := c.Param("id")
- id, err := strconv.ParseUint(idParam, 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid API key ID"})
- return
- }
-
- // Check if the API key exists
- var apiKey models.APIKey
- if err := database.DB.First(&apiKey, uint(id)).Error; err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "API key not found"})
- return
- }
+ idParam := c.Param("id")
+ id, err := strconv.ParseUint(idParam, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid API key ID"})
+ return
+ }
+
+ // Check if the API key exists
+ var apiKey models.APIKey
+ uid, ok := getContextUserID(c)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
+ return
+ }
+ if err := database.DB.Where("id = ? AND user_id = ?", uint(id), uid).First(&apiKey).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "API key not found"})
+ return
+ }
// Delete the API key (soft delete by setting is_active to false)
if err := database.DB.Model(&apiKey).Update("is_active", false).Error; err != nil {
@@ -2088,15 +2249,26 @@ func (h *Handler) DeleteAPIKey(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/llm/config [get]
func (h *Handler) GetLLMConfig(c *gin.Context) {
- var config models.LLMConfig
- if err := database.DB.Where("is_active = ?", true).First(&config).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "No active LLM configuration found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch LLM configuration"})
- return
- }
+ var config models.LLMConfig
+ uid, has := getContextUserID(c)
+ var err error
+ if has {
+ err = database.DB.Where("is_active = ? AND user_id = ?", true, uid).First(&config).Error
+ if err == gorm.ErrRecordNotFound {
+ // Fallback to legacy global config (user_id = 0)
+ err = database.DB.Where("is_active = ? AND user_id = 0", true).First(&config).Error
+ }
+ } else {
+ err = database.DB.Where("is_active = ?", true).First(&config).Error
+ }
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "No active LLM configuration found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch LLM configuration"})
+ return
+ }
response := LLMConfigResponse{
ID: config.ID,
@@ -2122,11 +2294,11 @@ func (h *Handler) GetLLMConfig(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/llm/config [post]
func (h *Handler) SaveLLMConfig(c *gin.Context) {
- var req LLMConfigRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
- return
- }
+ var req LLMConfigRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
// Validate provider-specific requirements
if req.Provider == "ollama" && (req.BaseURL == nil || *req.BaseURL == "") {
@@ -2139,8 +2311,14 @@ func (h *Handler) SaveLLMConfig(c *gin.Context) {
}
// Check if there's an existing active configuration
- var existingConfig models.LLMConfig
- err := database.DB.Where("is_active = ?", true).First(&existingConfig).Error
+ var existingConfig models.LLMConfig
+ uid, has := getContextUserID(c)
+ var err error
+ if has {
+ err = database.DB.Where("is_active = ? AND user_id = ?", true, uid).First(&existingConfig).Error
+ } else {
+ err = database.DB.Where("is_active = ?", true).First(&existingConfig).Error
+ }
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing configuration"})
@@ -2149,32 +2327,33 @@ func (h *Handler) SaveLLMConfig(c *gin.Context) {
var config models.LLMConfig
- if err == gorm.ErrRecordNotFound {
- // No existing active config, create new one
- config = models.LLMConfig{
- Provider: req.Provider,
- BaseURL: req.BaseURL,
- APIKey: req.APIKey,
- IsActive: req.IsActive,
- }
+ if err == gorm.ErrRecordNotFound {
+ // No existing active config, create new one
+ config = models.LLMConfig{
+ Provider: req.Provider,
+ BaseURL: req.BaseURL,
+ APIKey: req.APIKey,
+ IsActive: req.IsActive,
+ }
+ if has { config.UserID = uid }
if err := database.DB.Create(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create LLM configuration"})
return
}
- } else {
- // Update existing config
- existingConfig.Provider = req.Provider
- existingConfig.BaseURL = req.BaseURL
- existingConfig.APIKey = req.APIKey
- existingConfig.IsActive = req.IsActive
-
- if err := database.DB.Save(&existingConfig).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update LLM configuration"})
- return
- }
- config = existingConfig
- }
+ } else {
+ // Update existing config
+ existingConfig.Provider = req.Provider
+ existingConfig.BaseURL = req.BaseURL
+ existingConfig.APIKey = req.APIKey
+ existingConfig.IsActive = req.IsActive
+
+ if err := database.DB.Save(&existingConfig).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update LLM configuration"})
+ return
+ }
+ config = existingConfig
+ }
response := LLMConfigResponse{
ID: config.ID,
@@ -2297,12 +2476,17 @@ func getFormBoolWithDefault(c *gin.Context, key string, defaultValue bool) bool
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) ListProfiles(c *gin.Context) {
- var profiles []models.TranscriptionProfile
- if err := database.DB.Order("created_at DESC").Find(&profiles).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profiles"})
- return
- }
- c.JSON(http.StatusOK, profiles)
+ var profiles []models.TranscriptionProfile
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("user_id = ?", uid).Order("created_at DESC").Find(&profiles).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profiles"})
+ return
+ }
+ } else if err := database.DB.Order("created_at DESC").Find(&profiles).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profiles"})
+ return
+ }
+ c.JSON(http.StatusOK, profiles)
}
// @Summary Create transcription profile
@@ -2317,11 +2501,11 @@ func (h *Handler) ListProfiles(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) CreateProfile(c *gin.Context) {
- var profile models.TranscriptionProfile
- if err := c.ShouldBindJSON(&profile); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
- return
- }
+ var profile models.TranscriptionProfile
+ if err := c.ShouldBindJSON(&profile); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
+ return
+ }
// Validate required fields
if profile.Name == "" {
@@ -2329,12 +2513,17 @@ func (h *Handler) CreateProfile(c *gin.Context) {
return
}
- // Check if profile name already exists
- var existingProfile models.TranscriptionProfile
- if err := database.DB.Where("name = ?", profile.Name).First(&existingProfile).Error; err == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Profile name already exists"})
- return
- }
+ // Attach owner
+ if uid, ok := getContextUserID(c); ok {
+ profile.UserID = uid
+ }
+
+ // Check if profile name already exists for this user
+ var existingProfile models.TranscriptionProfile
+ if err := database.DB.Where("name = ? AND user_id = ?", profile.Name, profile.UserID).First(&existingProfile).Error; err == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Profile name already exists"})
+ return
+ }
if err := database.DB.Create(&profile).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create profile"})
@@ -2356,17 +2545,26 @@ func (h *Handler) CreateProfile(c *gin.Context) {
// @Security ApiKeyAuth
// @Security BearerAuth
func (h *Handler) GetProfile(c *gin.Context) {
- profileID := c.Param("id")
-
- var profile models.TranscriptionProfile
- if err := database.DB.Where("id = ?", profileID).First(&profile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
- return
- }
+ profileID := c.Param("id")
+
+ var profile models.TranscriptionProfile
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", profileID, uid).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", profileID).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
c.JSON(http.StatusOK, profile)
}
@@ -2388,14 +2586,23 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
profileID := c.Param("id")
var existingProfile models.TranscriptionProfile
- if err := database.DB.Where("id = ?", profileID).First(&existingProfile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
- return
- }
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", profileID, uid).First(&existingProfile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", profileID).First(&existingProfile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
var updatedProfile models.TranscriptionProfile
if err := c.ShouldBindJSON(&updatedProfile); err != nil {
@@ -2410,11 +2617,11 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
}
// Check if profile name already exists (excluding current profile)
- var nameCheck models.TranscriptionProfile
- if err := database.DB.Where("name = ? AND id != ?", updatedProfile.Name, profileID).First(&nameCheck).Error; err == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Profile name already exists"})
- return
- }
+ var nameCheck models.TranscriptionProfile
+ if err := database.DB.Where("name = ? AND id != ? AND user_id = ?", updatedProfile.Name, profileID, existingProfile.UserID).First(&nameCheck).Error; err == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Profile name already exists"})
+ return
+ }
// Update the profile
updatedProfile.ID = profileID // Ensure ID doesn't change
@@ -2440,14 +2647,23 @@ func (h *Handler) DeleteProfile(c *gin.Context) {
profileID := c.Param("id")
var profile models.TranscriptionProfile
- if err := database.DB.Where("id = ?", profileID).First(&profile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
- return
- }
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", profileID, uid).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", profileID).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
if err := database.DB.Delete(&profile).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete profile"})
@@ -2479,14 +2695,23 @@ func (h *Handler) SetDefaultProfile(c *gin.Context) {
// Find the profile
var profile models.TranscriptionProfile
- if err := database.DB.Where("id = ?", profileID).First(&profile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
- return
- }
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", profileID, uid).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", profileID).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profile"})
+ return
+ }
// Set this profile as default (the BeforeSave hook will handle unsetting other defaults)
profile.IsDefault = true
@@ -2533,15 +2758,24 @@ func (h *Handler) SubmitQuickTranscription(c *gin.Context) {
if profileName := c.PostForm("profile_name"); profileName != "" {
// Load parameters from profile
var profile models.TranscriptionProfile
- if err := database.DB.Where("name = ?", profileName).First(&profile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Profile '%s' not found", profileName)})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"})
- return
- }
- params = profile.Parameters
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("name = ? AND user_id = ?", profileName, uid).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Profile '%s' not found", profileName)})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"})
+ return
+ }
+ } else if err := database.DB.Where("name = ?", profileName).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Profile '%s' not found", profileName)})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"})
+ return
+ }
+ params = profile.Parameters
} else if parametersJSON := c.PostForm("parameters"); parametersJSON != "" {
// Parse parameters from JSON string
if err := json.Unmarshal([]byte(parametersJSON), ¶ms); err != nil {
@@ -2606,14 +2840,23 @@ func (h *Handler) SubmitQuickTranscription(c *gin.Context) {
}
}
- // Submit quick transcription job
- job, err := h.quickTranscription.SubmitQuickJob(file, header.Filename, params)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to submit quick transcription: %v", err)})
- return
- }
+ // Submit quick transcription job (associate with user if available)
+ if uid, ok := getContextUserID(c); ok {
+ job, err := h.quickTranscription.SubmitQuickJobForUser(file, header.Filename, params, uid)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to submit quick transcription: %v", err)})
+ return
+ }
+ c.JSON(http.StatusOK, job)
+ return
+ }
+ job, err := h.quickTranscription.SubmitQuickJob(file, header.Filename, params)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to submit quick transcription: %v", err)})
+ return
+ }
- c.JSON(http.StatusOK, job)
+ c.JSON(http.StatusOK, job)
}
// @Summary Get quick transcription status
@@ -2667,8 +2910,11 @@ func (h *Handler) DownloadFromYouTube(c *gin.Context) {
return
}
- // Create upload directory
- uploadDir := h.config.UploadDir
+ // Create upload directory (user scoped if available)
+ uploadDir := h.config.UploadDir
+ if uid, ok := getContextUserID(c); ok {
+ uploadDir = filepath.Join(uploadDir, fmt.Sprintf("user_%d", uid))
+ }
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
return
@@ -2685,22 +2931,16 @@ func (h *Handler) DownloadFromYouTube(c *gin.Context) {
title = *req.Title
} else {
// Get title from yt-dlp
- titleStart := time.Now()
cmd := exec.Command(h.config.UVPath, "run", "--native-tls", "--project", h.config.WhisperXEnv, "python", "-m", "yt_dlp", "--get-title", req.URL)
titleBytes, err := cmd.Output()
if err != nil {
title = "YouTube Audio"
- logger.Warn("Failed to get YouTube title", "url", req.URL, "error", err.Error(), "duration", time.Since(titleStart))
} else {
title = strings.TrimSpace(string(titleBytes))
- logger.Info("YouTube title retrieved", "title", title, "duration", time.Since(titleStart))
}
}
// Download audio using yt-dlp in Python environment
- logger.Info("Starting YouTube download", "url", req.URL, "job_id", jobID)
- downloadStart := time.Now()
-
ytDlpCmd := exec.Command(h.config.UVPath, "run", "--native-tls", "--project", h.config.WhisperXEnv, "python", "-m", "yt_dlp",
"--extract-audio",
"--audio-format", "mp3",
@@ -2710,23 +2950,9 @@ func (h *Handler) DownloadFromYouTube(c *gin.Context) {
req.URL,
)
- // Execute download and capture stderr for better error messages
- var stderr bytes.Buffer
- ytDlpCmd.Stderr = &stderr
-
+ // Execute download
if err := ytDlpCmd.Run(); err != nil {
- stderrOutput := stderr.String()
- logger.Error("YouTube download failed",
- "url", req.URL,
- "job_id", jobID,
- "error", err.Error(),
- "stderr", stderrOutput,
- "duration", time.Since(downloadStart))
-
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": fmt.Sprintf("Failed to download YouTube audio: %v", err),
- "details": stderrOutput,
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to download YouTube audio: %v", err)})
return
}
@@ -2740,24 +2966,15 @@ func (h *Handler) DownloadFromYouTube(c *gin.Context) {
actualFilePath := matches[0]
- // Get file size for performance logging
- fileInfo, err := os.Stat(actualFilePath)
- if err == nil {
- fileSizeMB := float64(fileInfo.Size()) / 1024 / 1024
- logger.Info("YouTube download completed",
- "url", req.URL,
- "job_id", jobID,
- "file_path", actualFilePath,
- "file_size_mb", fmt.Sprintf("%.2f", fileSizeMB),
- "duration", time.Since(downloadStart))
- }
-
// Create transcription record
- job := models.TranscriptionJob{
- ID: jobID,
- AudioPath: actualFilePath,
- Status: models.StatusUploaded,
- }
+ job := models.TranscriptionJob{
+ ID: jobID,
+ AudioPath: actualFilePath,
+ Status: models.StatusUploaded,
+ }
+ if uid, ok := getContextUserID(c); ok {
+ job.UserID = uid
+ }
// Set title
if title != "" {
@@ -2798,40 +3015,40 @@ func (h *Handler) GetUserDefaultProfile(c *gin.Context) {
}
// If user has no default profile set, return the first available profile or no profile
- if user.DefaultProfileID == nil {
- var firstProfile models.TranscriptionProfile
- if err := database.DB.Order("created_at ASC").First(&firstProfile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "No profiles available"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profiles"})
- return
- }
- c.JSON(http.StatusOK, firstProfile)
- return
- }
+ if user.DefaultProfileID == nil {
+ var firstProfile models.TranscriptionProfile
+ if err := database.DB.Where("user_id = ?", user.ID).Order("created_at ASC").First(&firstProfile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "No profiles available"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profiles"})
+ return
+ }
+ c.JSON(http.StatusOK, firstProfile)
+ return
+ }
// Get the user's default profile
var profile models.TranscriptionProfile
- if err := database.DB.Where("id = ?", *user.DefaultProfileID).First(&profile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- // Default profile no longer exists, fall back to first available
- var firstProfile models.TranscriptionProfile
- if err := database.DB.Order("created_at ASC").First(&firstProfile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "No profiles available"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profiles"})
- return
- }
- c.JSON(http.StatusOK, firstProfile)
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get default profile"})
- return
- }
+ if err := database.DB.Where("id = ? AND user_id = ?", *user.DefaultProfileID, user.ID).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ // Default profile no longer exists, fall back to first available
+ var firstProfile models.TranscriptionProfile
+ if err := database.DB.Where("user_id = ?", user.ID).Order("created_at ASC").First(&firstProfile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "No profiles available"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get profiles"})
+ return
+ }
+ c.JSON(http.StatusOK, firstProfile)
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get default profile"})
+ return
+ }
c.JSON(http.StatusOK, profile)
}
@@ -2865,16 +3082,16 @@ func (h *Handler) SetUserDefaultProfile(c *gin.Context) {
return
}
- // Verify the profile exists
- var profile models.TranscriptionProfile
- if err := database.DB.Where("id = ?", req.ProfileID).First(&profile).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify profile"})
- return
- }
+ // Verify the profile exists and belongs to the user
+ var profile models.TranscriptionProfile
+ if err := database.DB.Where("id = ? AND user_id = ?", req.ProfileID, userID).First(&profile).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify profile"})
+ return
+ }
// Update user's default profile
if err := database.DB.Model(&models.User{}).Where("id = ?", userID).Update("default_profile_id", req.ProfileID).Error; err != nil {
@@ -2887,8 +3104,9 @@ func (h *Handler) SetUserDefaultProfile(c *gin.Context) {
// UserSettingsResponse represents the user's settings
type UserSettingsResponse struct {
- AutoTranscriptionEnabled bool `json:"auto_transcription_enabled"`
- DefaultProfileID *string `json:"default_profile_id,omitempty"`
+ AutoTranscriptionEnabled bool `json:"auto_transcription_enabled"`
+ DefaultProfileID *string `json:"default_profile_id,omitempty"`
+ IsAdmin bool `json:"is_admin"`
}
// UpdateUserSettingsRequest represents the request to update user settings
@@ -2921,6 +3139,7 @@ func (h *Handler) GetUserSettings(c *gin.Context) {
response := UserSettingsResponse{
AutoTranscriptionEnabled: user.AutoTranscriptionEnabled,
DefaultProfileID: user.DefaultProfileID,
+ IsAdmin: user.IsAdmin,
}
c.JSON(http.StatusOK, response)
@@ -3012,15 +3231,24 @@ func (h *Handler) GetSpeakerMappings(c *gin.Context) {
jobID := c.Param("id")
// Verify the transcription job exists and has diarization enabled
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
- return
- }
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
+ return
+ }
// Check if diarization was enabled or if this is a multi-track job (which also has speakers)
if !job.Diarization && !job.Parameters.Diarize && !job.IsMultiTrack {
@@ -3073,15 +3301,24 @@ func (h *Handler) UpdateSpeakerMappings(c *gin.Context) {
}
// Verify the transcription job exists and has diarization enabled
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
- return
- }
+ var job models.TranscriptionJob
+ if uid, ok := getContextUserID(c); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", jobID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
+ return
+ }
// Check if diarization was enabled or if this is a multi-track job (which also has speakers)
if !job.Diarization && !job.Parameters.Diarize && !job.IsMultiTrack {
@@ -3149,3 +3386,318 @@ func (h *Handler) UpdateSpeakerMappings(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
+
+// ===== Admin: User Management Handlers =====
+
+// AdminUserResponse represents a sanitized user for admin listings
+type AdminUserResponse struct {
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ IsAdmin bool `json:"is_admin"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// AdminCreateUserRequest represents payload to create a user
+type AdminCreateUserRequest struct {
+ Username string `json:"username" binding:"required,min=3,max=50"`
+ Password string `json:"password" binding:"required,min=6"`
+ ConfirmPassword string `json:"confirmPassword" binding:"required"`
+ IsAdmin *bool `json:"is_admin,omitempty"`
+}
+
+// AdminUpdateUserRequest represents payload to update a user
+type AdminUpdateUserRequest struct {
+ Username *string `json:"username,omitempty"`
+ IsAdmin *bool `json:"is_admin,omitempty"`
+}
+
+// AdminUpdatePasswordRequest represents payload to update a user's password
+type AdminUpdatePasswordRequest struct {
+ NewPassword string `json:"newPassword" binding:"required,min=6"`
+ ConfirmPassword string `json:"confirmPassword" binding:"required"`
+}
+
+// @Summary List users
+// @Tags admin
+// @Produce json
+// @Success 200 {array} AdminUserResponse
+// @Failure 401 {object} map[string]string
+// @Failure 403 {object} map[string]string
+// @Router /api/v1/admin/users/ [get]
+// @Security BearerAuth
+func (h *Handler) AdminListUsers(c *gin.Context) {
+ var users []models.User
+ if err := database.DB.Order("id ASC").Find(&users).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
+ return
+ }
+ resp := make([]AdminUserResponse, 0, len(users))
+ for _, u := range users {
+ resp = append(resp, AdminUserResponse{ID: u.ID, Username: u.Username, IsAdmin: u.IsAdmin, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt})
+ }
+ c.JSON(http.StatusOK, resp)
+}
+
+// @Summary Create user
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param request body AdminCreateUserRequest true "User details"
+// @Success 201 {object} AdminUserResponse
+// @Failure 400 {object} map[string]string
+// @Failure 409 {object} map[string]string
+// @Router /api/v1/admin/users/ [post]
+// @Security BearerAuth
+func (h *Handler) AdminCreateUser(c *gin.Context) {
+ var req AdminCreateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+ if req.Password != req.ConfirmPassword {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Passwords do not match"})
+ return
+ }
+ // Unique username check
+ {
+ var existing models.User
+ if err := database.DB.Where("username = ?", req.Username).First(&existing).Error; err == nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
+ return
+ }
+ }
+ // Hash password
+ hashed, err := auth.HashPassword(req.Password)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to secure password"})
+ return
+ }
+ isAdmin := false
+ if req.IsAdmin != nil {
+ isAdmin = *req.IsAdmin
+ }
+ user := models.User{Username: req.Username, Password: hashed, IsAdmin: isAdmin}
+ if err := database.DB.Create(&user).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
+ return
+ }
+ c.JSON(http.StatusCreated, AdminUserResponse{ID: user.ID, Username: user.Username, IsAdmin: user.IsAdmin, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt})
+}
+
+// @Summary Update user
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path string true "User ID"
+// @Param request body AdminUpdateUserRequest true "Update details"
+// @Success 200 {object} AdminUserResponse
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Failure 409 {object} map[string]string
+// @Router /api/v1/admin/users/{id} [put]
+// @Security BearerAuth
+func (h *Handler) AdminUpdateUser(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil || id <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
+ return
+ }
+ var req AdminUpdateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+ var user models.User
+ if err := database.DB.First(&user, uint(id)).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+ // Prevent demoting last admin
+ if req.IsAdmin != nil && user.IsAdmin && !*req.IsAdmin {
+ var adminCount int64
+ _ = database.DB.Model(&models.User{}).Where("is_admin = ?", true).Count(&adminCount).Error
+ if adminCount <= 1 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot remove admin from the last admin user"})
+ return
+ }
+ }
+ // Username uniqueness
+ if req.Username != nil && *req.Username != user.Username {
+ var existing models.User
+ if err := database.DB.Where("username = ?", *req.Username).First(&existing).Error; err == nil && existing.ID != user.ID {
+ c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
+ return
+ }
+ user.Username = *req.Username
+ }
+ if req.IsAdmin != nil {
+ user.IsAdmin = *req.IsAdmin
+ }
+ if err := database.DB.Save(&user).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
+ return
+ }
+ c.JSON(http.StatusOK, AdminUserResponse{ID: user.ID, Username: user.Username, IsAdmin: user.IsAdmin, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt})
+}
+
+// @Summary Update user password
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path string true "User ID"
+// @Param request body AdminUpdatePasswordRequest true "Password update"
+// @Success 200 {object} map[string]string
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Router /api/v1/admin/users/{id}/password [put]
+// @Security BearerAuth
+func (h *Handler) AdminUpdateUserPassword(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil || id <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
+ return
+ }
+ var req AdminUpdatePasswordRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+ if req.NewPassword != req.ConfirmPassword {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Passwords do not match"})
+ return
+ }
+ var user models.User
+ if err := database.DB.First(&user, uint(id)).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+ hashed, err := auth.HashPassword(req.NewPassword)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to secure password"})
+ return
+ }
+ user.Password = hashed
+ if err := database.DB.Save(&user).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
+}
+
+// @Summary Delete user
+// @Tags admin
+// @Produce json
+// @Param id path string true "User ID"
+// @Success 200 {object} map[string]string
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Router /api/v1/admin/users/{id} [delete]
+// @Security BearerAuth
+func (h *Handler) AdminDeleteUser(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil || id <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
+ return
+ }
+ // Prevent deleting last admin
+ var user models.User
+ if err := database.DB.First(&user, uint(id)).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+ if user.IsAdmin {
+ var adminCount int64
+ _ = database.DB.Model(&models.User{}).Where("is_admin = ?", true).Count(&adminCount).Error
+ if adminCount <= 1 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete the last admin user"})
+ return
+ }
+ }
+ // Prevent deleting yourself to avoid lockout
+ if v, ok := c.Get("user_id"); ok {
+ var uid uint
+ switch vv := v.(type) {
+ case uint:
+ uid = vv
+ case int:
+ if vv > 0 { uid = uint(vv) }
+ case int64:
+ if vv > 0 { uid = uint(vv) }
+ }
+ if uid == uint(id) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "You cannot delete your own account"})
+ return
+ }
+ }
+ if err := database.DB.Delete(&models.User{}, uint(id)).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
+}
+
+// @Summary Upload custom instance logo
+// @Tags admin
+// @Accept multipart/form-data
+// @Produce json
+// @Param logo formData file true "PNG logo file"
+// @Success 200 {object} map[string]string
+// @Failure 400 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Router /api/v1/admin/logo [post]
+// @Security BearerAuth
+func (h *Handler) AdminUploadLogo(c *gin.Context) {
+ file, header, err := c.Request.FormFile("logo")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Logo file is required"})
+ return
+ }
+ defer file.Close()
+
+ ext := strings.ToLower(filepath.Ext(header.Filename))
+ if ext != ".png" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Only PNG logo files are supported"})
+ return
+ }
+
+ logoPath := config.CustomLogoPath
+ if err := os.MkdirAll(filepath.Dir(logoPath), 0755); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create logo directory"})
+ return
+ }
+
+ dst, err := os.Create(logoPath)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save logo"})
+ return
+ }
+ defer dst.Close()
+
+ if _, err := io.Copy(dst, file); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save logo"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Logo updated"})
+}
+
+// @Summary Delete custom instance logo (revert to default)
+// @Tags admin
+// @Produce json
+// @Success 200 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Router /api/v1/admin/logo [delete]
+// @Security BearerAuth
+func (h *Handler) AdminDeleteLogo(c *gin.Context) {
+ logoPath := config.CustomLogoPath
+ if err := os.Remove(logoPath); err != nil && !os.IsNotExist(err) {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove custom logo"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Custom logo removed; default will be used"})
+}
diff --git a/internal/api/notes_handlers.go b/internal/api/notes_handlers.go
index 0fc14bcfb..588809b20 100644
--- a/internal/api/notes_handlers.go
+++ b/internal/api/notes_handlers.go
@@ -42,22 +42,31 @@ type NoteUpdateRequest struct {
// @Security BearerAuth
// @Router /api/v1/transcription/{id}/notes [get]
func (h *Handler) ListNotes(c *gin.Context) {
- transcriptionID := c.Param("id")
- if transcriptionID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID is required"})
- return
- }
+ transcriptionID := c.Param("id")
+ if transcriptionID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID is required"})
+ return
+ }
- // Ensure transcription exists
- var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", transcriptionID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
- return
- }
+ // Ensure transcription exists
+ var job models.TranscriptionJob
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", transcriptionID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", transcriptionID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
+ return
+ }
var notes []models.Note
if err := database.DB.Where("transcription_id = ?", transcriptionID).
@@ -111,16 +120,27 @@ func (h *Handler) CreateNote(c *gin.Context) {
// Ensure transcription exists
var job models.TranscriptionJob
- if err := database.DB.Where("id = ?", transcriptionID).First(&job).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- log.Printf("notes.CreateNote: transcription %s not found", transcriptionID)
- c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
- return
- }
- log.Printf("notes.CreateNote: failed to fetch transcription %s: %v", transcriptionID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
- return
- }
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", transcriptionID, uid).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ log.Printf("notes.CreateNote: transcription %s not found", transcriptionID)
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ log.Printf("notes.CreateNote: failed to fetch transcription %s: %v", transcriptionID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", transcriptionID).First(&job).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ log.Printf("notes.CreateNote: transcription %s not found", transcriptionID)
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ log.Printf("notes.CreateNote: failed to fetch transcription %s: %v", transcriptionID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
+ return
+ }
n := models.Note{
ID: uuid.New().String(),
@@ -158,17 +178,25 @@ func (h *Handler) CreateNote(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/notes/{note_id} [get]
func (h *Handler) GetNote(c *gin.Context) {
- noteID := c.Param("note_id")
- var n models.Note
- if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch note"})
- return
- }
- c.JSON(http.StatusOK, n)
+ noteID := c.Param("note_id")
+ var n models.Note
+ if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch note"})
+ return
+ }
+ // Ownership check via transcription
+ if uid, ok := c.Get("user_id"); ok {
+ var job models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", n.TranscriptionID, uid).First(&job).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
+ return
+ }
+ }
+ c.JSON(http.StatusOK, n)
}
// UpdateNote updates the content of an existing note
@@ -186,22 +214,29 @@ func (h *Handler) GetNote(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/notes/{note_id} [put]
func (h *Handler) UpdateNote(c *gin.Context) {
- noteID := c.Param("note_id")
- var req NoteUpdateRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
+ noteID := c.Param("note_id")
+ var req NoteUpdateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
- var n models.Note
- if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch note"})
- return
- }
+ var n models.Note
+ if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch note"})
+ return
+ }
+ if uid, ok := c.Get("user_id"); ok {
+ var job models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", n.TranscriptionID, uid).First(&job).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
+ return
+ }
+ }
n.Content = req.Content
n.UpdatedAt = time.Now()
@@ -225,11 +260,24 @@ func (h *Handler) UpdateNote(c *gin.Context) {
// @Security ApiKeyAuth
// @Router /api/v1/notes/{note_id} [delete]
func (h *Handler) DeleteNote(c *gin.Context) {
- noteID := c.Param("note_id")
- if err := database.DB.Delete(&models.Note{}, "id = ?", noteID).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete note"})
- return
- }
- // Tests expect 200 on deletion
- c.JSON(http.StatusOK, gin.H{"message": "Note deleted"})
+ noteID := c.Param("note_id")
+ // Ownership check: ensure note's transcription belongs to user
+ var n models.Note
+ if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
+ return
+ }
+ if uid, ok := c.Get("user_id"); ok {
+ var job models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", n.TranscriptionID, uid).First(&job).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
+ return
+ }
+ }
+ if err := database.DB.Delete(&models.Note{}, "id = ?", noteID).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete note"})
+ return
+ }
+ // Tests expect 200 on deletion
+ c.JSON(http.StatusOK, gin.H{"message": "Note deleted"})
}
diff --git a/internal/api/router.go b/internal/api/router.go
index 9815d3505..40f14fada 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -146,15 +146,34 @@ func SetupRoutes(handler *Handler, authService *auth.AuthService) *gin.Engine {
user.PUT("/settings", handler.UpdateUserSettings)
}
- // Admin routes (require authentication)
- admin := v1.Group("/admin")
- admin.Use(middleware.AuthMiddleware(authService))
- {
- queue := admin.Group("/queue")
- {
- queue.GET("/stats", handler.GetQueueStats)
- }
- }
+ // Admin routes (base group)
+ admin := v1.Group("/admin")
+ admin.Use(middleware.AuthMiddleware(authService))
+ {
+ queue := admin.Group("/queue")
+ {
+ queue.GET("/stats", handler.GetQueueStats)
+ }
+
+ // User management routes - JWT only and admin-only
+ adminUsers := admin.Group("/users")
+ adminUsers.Use(middleware.JWTOnlyMiddleware(authService), middleware.AdminOnlyMiddleware())
+ {
+ adminUsers.GET("/", handler.AdminListUsers)
+ adminUsers.POST("/", handler.AdminCreateUser)
+ adminUsers.PUT("/:id", handler.AdminUpdateUser)
+ adminUsers.PUT("/:id/password", handler.AdminUpdateUserPassword)
+ adminUsers.DELETE("/:id", handler.AdminDeleteUser)
+ }
+
+ // Instance branding/logo routes - JWT only and admin-only
+ adminBranding := admin.Group("")
+ adminBranding.Use(middleware.JWTOnlyMiddleware(authService), middleware.AdminOnlyMiddleware())
+ {
+ adminBranding.POST("/logo", handler.AdminUploadLogo)
+ adminBranding.DELETE("/logo", handler.AdminDeleteLogo)
+ }
+ }
// LLM configuration routes (require authentication)
llm := v1.Group("/llm")
diff --git a/internal/api/summarize_handlers.go b/internal/api/summarize_handlers.go
index 75c6bbf2f..08a061cc4 100644
--- a/internal/api/summarize_handlers.go
+++ b/internal/api/summarize_handlers.go
@@ -43,7 +43,16 @@ func (h *Handler) Summarize(c *gin.Context) {
return
}
- svc, provider, err := h.getLLMService()
+ // Verify transcription ownership
+ if uid, ok := c.Get("user_id"); ok {
+ var t models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", req.TranscriptionID, uid).First(&t).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
+ return
+ }
+ }
+
+ svc, provider, err := h.getLLMService(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -72,17 +81,17 @@ func (h *Handler) Summarize(c *gin.Context) {
gotFirstChunk := false
// helper to persist any accumulated content
- persistIfAny := func() {
- if req.TranscriptionID == "" || finalText == "" {
- return
- }
- sum := models.Summary{
- TranscriptionID: req.TranscriptionID,
- TemplateID: req.TemplateID,
- Model: req.Model,
- Content: finalText,
- }
- if err := database.DB.Create(&sum).Error; err != nil {
+ persistIfAny := func() {
+ if req.TranscriptionID == "" || finalText == "" {
+ return
+ }
+ sum := models.Summary{
+ TranscriptionID: req.TranscriptionID,
+ TemplateID: req.TemplateID,
+ Model: req.Model,
+ Content: finalText,
+ }
+ if err := database.DB.Create(&sum).Error; err != nil {
// Fallback: store on the transcription job record
_ = database.DB.Model(&models.TranscriptionJob{}).Where("id = ?", req.TranscriptionID).Update("summary", finalText).Error
} else {
@@ -178,12 +187,19 @@ func (h *Handler) Summarize(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/transcription/{id}/summary [get]
func (h *Handler) GetSummaryForTranscription(c *gin.Context) {
- tid := c.Param("id")
- if tid == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID required"})
- return
- }
- var s models.Summary
+ tid := c.Param("id")
+ if tid == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID required"})
+ return
+ }
+ if uid, ok := c.Get("user_id"); ok {
+ var t models.TranscriptionJob
+ if err := database.DB.Select("id").Where("id = ? AND user_id = ?", tid, uid).First(&t).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Summary not found"})
+ return
+ }
+ }
+ var s models.Summary
if err := database.DB.Where("transcription_id = ?", tid).Order("created_at DESC").First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Fallback: check if summary is cached on the job record
diff --git a/internal/api/summary_handlers.go b/internal/api/summary_handlers.go
index 3a36aed4d..5df8e74a8 100644
--- a/internal/api/summary_handlers.go
+++ b/internal/api/summary_handlers.go
@@ -37,12 +37,17 @@ type SummarySettingsResponse struct {
// @Security BearerAuth
// @Router /api/v1/summaries [get]
func (h *Handler) ListSummaryTemplates(c *gin.Context) {
- var items []models.SummaryTemplate
- if err := database.DB.Order("created_at DESC").Find(&items).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
- return
- }
- c.JSON(http.StatusOK, items)
+ var items []models.SummaryTemplate
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("user_id = ?", uid).Order("created_at DESC").Find(&items).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
+ return
+ }
+ } else if err := database.DB.Order("created_at DESC").Find(&items).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
+ return
+ }
+ c.JSON(http.StatusOK, items)
}
// CreateSummaryTemplate creates a new template
@@ -65,14 +70,17 @@ func (h *Handler) CreateSummaryTemplate(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- item := models.SummaryTemplate{
- Name: req.Name,
- Description: req.Description,
- Model: req.Model,
- Prompt: req.Prompt,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
+ item := models.SummaryTemplate{
+ Name: req.Name,
+ Description: req.Description,
+ Model: req.Model,
+ Prompt: req.Prompt,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ if uid, ok := c.Get("user_id"); ok {
+ item.UserID = uid.(uint)
+ }
if err := database.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
@@ -94,17 +102,26 @@ func (h *Handler) CreateSummaryTemplate(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/summaries/{id} [get]
func (h *Handler) GetSummaryTemplate(c *gin.Context) {
- id := c.Param("id")
- var item models.SummaryTemplate
- if err := database.DB.Where("id = ?", id).First(&item).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
- return
- }
- c.JSON(http.StatusOK, item)
+ id := c.Param("id")
+ var item models.SummaryTemplate
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", id, uid).First(&item).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", id).First(&item).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
+ return
+ }
+ c.JSON(http.StatusOK, item)
}
// UpdateSummaryTemplate updates an existing template
@@ -130,15 +147,24 @@ func (h *Handler) UpdateSummaryTemplate(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- var item models.SummaryTemplate
- if err := database.DB.Where("id = ?", id).First(&item).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
- return
- }
+ var item models.SummaryTemplate
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", id, uid).First(&item).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
+ return
+ }
+ } else if err := database.DB.Where("id = ?", id).First(&item).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
+ return
+ }
item.Name = req.Name
item.Description = req.Description
item.Model = req.Model
@@ -162,12 +188,20 @@ func (h *Handler) UpdateSummaryTemplate(c *gin.Context) {
// @Security ApiKeyAuth
// @Router /api/v1/summaries/{id} [delete]
func (h *Handler) DeleteSummaryTemplate(c *gin.Context) {
- id := c.Param("id")
- if err := database.DB.Delete(&models.SummaryTemplate{}, "id = ?", id).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
- return
- }
- c.Status(http.StatusNoContent)
+ id := c.Param("id")
+ if uid, ok := c.Get("user_id"); ok {
+ if err := database.DB.Where("id = ? AND user_id = ?", id, uid).Delete(&models.SummaryTemplate{}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ return
+ }
+ if err := database.DB.Delete(&models.SummaryTemplate{}, "id = ?", id).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
+ return
+ }
+ c.Status(http.StatusNoContent)
}
// GetSummarySettings returns the global summary settings (default model)
diff --git a/internal/config/config.go b/internal/config/config.go
index b51f443c3..8193bb1df 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -13,11 +13,15 @@ import (
"scriberr/pkg/logger"
)
+// CustomLogoPath is the relative path (from the working directory) where
+// an uploaded instance logo will be stored and served from.
+const CustomLogoPath = "data/custom-logo.png"
+
// Config holds all configuration values
type Config struct {
- // Server configuration
- Port string
- Host string
+ // Server configuration
+ Port string
+ Host string
// Database configuration
DatabasePath string
@@ -28,9 +32,12 @@ type Config struct {
// File storage
UploadDir string
- // Python/WhisperX configuration
- UVPath string
- WhisperXEnv string
+ // Python/WhisperX configuration
+ UVPath string
+ WhisperXEnv string
+
+ // Registration
+ AllowRegistration bool
}
// Load loads configuration from environment variables and .env file
@@ -40,15 +47,16 @@ func Load() *Config {
logger.Debug("No .env file found, using system environment variables")
}
- return &Config{
- Port: getEnv("PORT", "8080"),
- Host: getEnv("HOST", "localhost"),
- DatabasePath: getEnv("DATABASE_PATH", "data/scriberr.db"),
- JWTSecret: getJWTSecret(),
- UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
- UVPath: findUVPath(),
- WhisperXEnv: getEnv("WHISPERX_ENV", "data/whisperx-env"),
- }
+ return &Config{
+ Port: getEnv("PORT", "8080"),
+ Host: getEnv("HOST", "localhost"),
+ DatabasePath: getEnv("DATABASE_PATH", "data/scriberr.db"),
+ JWTSecret: getJWTSecret(),
+ UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
+ UVPath: findUVPath(),
+ WhisperXEnv: getEnv("WHISPERX_ENV", "data/whisperx-env"),
+ AllowRegistration: getEnvAsBool("ALLOW_REGISTRATION", true),
+ }
}
// getEnv gets an environment variable with a default value
diff --git a/internal/database/database.go b/internal/database/database.go
index dfb8087cb..49a594393 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -1,16 +1,19 @@
package database
import (
- "database/sql"
- "fmt"
- "os"
- "time"
-
- "scriberr/internal/models"
-
- "github.com/glebarez/sqlite"
- "gorm.io/gorm"
- "gorm.io/gorm/logger"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "scriberr/internal/models"
+
+ "github.com/glebarez/sqlite"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
)
// DB is the global database instance
@@ -18,32 +21,53 @@ var DB *gorm.DB
// Initialize initializes the database connection with optimized settings
func Initialize(dbPath string) error {
- var err error
-
- // Create database directory if it doesn't exist
- if err := os.MkdirAll("data", 0755); err != nil {
- return fmt.Errorf("failed to create data directory: %v", err)
- }
-
- // SQLite connection string with performance optimizations
- dsn := fmt.Sprintf("%s?"+
- "_pragma=foreign_keys(1)&"+ // Enable foreign keys
- "_pragma=journal_mode(WAL)&"+ // Use WAL mode for better concurrency
- "_pragma=synchronous(NORMAL)&"+ // Balance between safety and performance
- "_pragma=cache_size(-64000)&"+ // 64MB cache size
- "_pragma=temp_store(MEMORY)&"+ // Store temp tables in memory
- "_pragma=mmap_size(268435456)&"+ // 256MB mmap size
- "_timeout=30000", // 30 second timeout
- dbPath)
-
- // Open database connection with optimized config
- DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
- Logger: logger.Default.LogMode(logger.Warn), // Reduce logging overhead
- CreateBatchSize: 100, // Optimize batch inserts
- })
- if err != nil {
- return fmt.Errorf("failed to connect to database: %v", err)
- }
+ var err error
+
+ // Choose driver
+ driver := strings.ToLower(os.Getenv("DB_DRIVER"))
+ dbURL := os.Getenv("DATABASE_URL")
+
+ if driver == "postgres" || (dbURL != "" && driver == "") {
+ // Open Postgres
+ if dbURL == "" {
+ return fmt.Errorf("DATABASE_URL is required for postgres driver")
+ }
+ DB, err = gorm.Open(postgres.Open(dbURL), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Warn),
+ CreateBatchSize: 100,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to connect to postgres: %v", err)
+ }
+ } else {
+ // Ensure directory for sqlite file exists
+ dir := filepath.Dir(dbPath)
+ if dir != "." && dir != "" {
+ if mkErr := os.MkdirAll(dir, 0755); mkErr != nil {
+ return fmt.Errorf("failed to create data directory: %v", mkErr)
+ }
+ }
+
+ // SQLite connection string with performance optimizations
+ dsn := fmt.Sprintf("%s?"+
+ "_pragma=foreign_keys(1)&"+ // Enable foreign keys
+ "_pragma=journal_mode(WAL)&"+ // Use WAL mode for better concurrency
+ "_pragma=synchronous(NORMAL)&"+ // Balance between safety and performance
+ "_pragma=cache_size(-64000)&"+ // 64MB cache size
+ "_pragma=temp_store(MEMORY)&"+ // Store temp tables in memory
+ "_pragma=mmap_size(268435456)&"+ // 256MB mmap size
+ "_timeout=30000", // 30 second timeout
+ dbPath)
+
+ // Open SQLite database connection with optimized config
+ DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Warn), // Reduce logging overhead
+ CreateBatchSize: 100, // Optimize batch inserts
+ })
+ if err != nil {
+ return fmt.Errorf("failed to connect to sqlite: %v", err)
+ }
+ }
// Get underlying sql.DB for connection pool configuration
sqlDB, err := DB.DB()
@@ -51,39 +75,69 @@ func Initialize(dbPath string) error {
return fmt.Errorf("failed to get underlying sql.DB: %v", err)
}
- // Configure connection pool for optimal performance
- sqlDB.SetMaxOpenConns(10) // SQLite generally works well with lower connection counts
- sqlDB.SetMaxIdleConns(5) // Keep some connections idle
- sqlDB.SetConnMaxLifetime(30 * time.Minute) // Reset connections every 30 minutes
- sqlDB.SetConnMaxIdleTime(5 * time.Minute) // Close idle connections after 5 minutes
+ // Configure connection pool for optimal performance
+ if driver == "postgres" || (dbURL != "" && driver == "") {
+ // Higher concurrency for Postgres
+ sqlDB.SetMaxOpenConns(25)
+ sqlDB.SetMaxIdleConns(10)
+ } else {
+ // SQLite generally works well with lower connection counts
+ sqlDB.SetMaxOpenConns(10)
+ sqlDB.SetMaxIdleConns(5)
+ }
+ sqlDB.SetConnMaxLifetime(30 * time.Minute) // Reset connections every 30 minutes
+ sqlDB.SetConnMaxIdleTime(5 * time.Minute) // Close idle connections after 5 minutes
// Auto migrate the schema
- if err := DB.AutoMigrate(
- &models.TranscriptionJob{},
- &models.TranscriptionJobExecution{},
- &models.SpeakerMapping{},
- &models.MultiTrackFile{},
- &models.User{},
- &models.APIKey{},
- &models.TranscriptionProfile{},
- &models.LLMConfig{},
- &models.ChatSession{},
- &models.ChatMessage{},
- &models.SummaryTemplate{},
- &models.SummarySetting{},
- &models.Summary{},
- &models.Note{},
- &models.RefreshToken{},
- ); err != nil {
- return fmt.Errorf("failed to auto migrate: %v", err)
- }
+ if err := DB.AutoMigrate(
+ &models.TranscriptionJob{},
+ &models.TranscriptionJobExecution{},
+ &models.SpeakerMapping{},
+ &models.MultiTrackFile{},
+ &models.User{},
+ &models.APIKey{},
+ &models.TranscriptionProfile{},
+ &models.LLMConfig{},
+ &models.ChatSession{},
+ &models.ChatMessage{},
+ &models.SummaryTemplate{},
+ &models.SummarySetting{},
+ &models.Summary{},
+ &models.Note{},
+ &models.RefreshToken{},
+ ); err != nil {
+ return fmt.Errorf("failed to auto migrate: %v", err)
+ }
// Add unique constraint for speaker mappings (transcription_job_id + original_speaker)
if err := DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_speaker_mappings_unique ON speaker_mappings(transcription_job_id, original_speaker)").Error; err != nil {
return fmt.Errorf("failed to create unique constraint for speaker mappings: %v", err)
}
- return nil
+ // Backfill ownership: assign existing records without owner to the first user
+ var firstUser models.User
+ _ = DB.Order("id ASC").First(&firstUser).Error
+ if firstUser.ID != 0 {
+ // Tables with user ownership
+ _ = DB.Model(&models.TranscriptionJob{}).Where("user_id = 0").Update("user_id", firstUser.ID).Error
+ _ = DB.Model(&models.APIKey{}).Where("user_id = 0").Update("user_id", firstUser.ID).Error
+ _ = DB.Model(&models.TranscriptionProfile{}).Where("user_id = 0").Update("user_id", firstUser.ID).Error
+ _ = DB.Model(&models.ChatSession{}).Where("user_id = 0").Update("user_id", firstUser.ID).Error
+ _ = DB.Model(&models.SummaryTemplate{}).Where("user_id = 0").Update("user_id", firstUser.ID).Error
+ }
+
+ // Ensure at least one admin: make the first user admin if none exist
+ {
+ var adminCount int64
+ if err := DB.Model(&models.User{}).Where("is_admin = ?", true).Count(&adminCount).Error; err == nil {
+ if adminCount == 0 && firstUser.ID != 0 && !firstUser.IsAdmin {
+ firstUser.IsAdmin = true
+ _ = DB.Save(&firstUser).Error
+ }
+ }
+ }
+
+ return nil
}
// Close closes the database connection gracefully
diff --git a/internal/models/summary.go b/internal/models/summary.go
index 562512c28..08fb9bc2a 100644
--- a/internal/models/summary.go
+++ b/internal/models/summary.go
@@ -8,13 +8,16 @@ import (
// SummaryTemplate represents a saved summarization prompt/template
type SummaryTemplate struct {
- ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
- Name string `json:"name" gorm:"type:varchar(255);not null"`
- Description *string `json:"description,omitempty" gorm:"type:text"`
- Model string `json:"model" gorm:"type:varchar(255);not null;default:''"`
- Prompt string `json:"prompt" gorm:"type:text;not null"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
+ Name string `json:"name" gorm:"type:varchar(255);not null"`
+ Description *string `json:"description,omitempty" gorm:"type:text"`
+ Model string `json:"model" gorm:"type:varchar(255);not null;default:''"`
+ Prompt string `json:"prompt" gorm:"type:text;not null"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+
+ // Owner
+ UserID uint `json:"user_id" gorm:"index"`
}
func (st *SummaryTemplate) BeforeCreate(tx *gorm.DB) error {
diff --git a/internal/models/transcription.go b/internal/models/transcription.go
index 7dc0481f2..513a19b9b 100644
--- a/internal/models/transcription.go
+++ b/internal/models/transcription.go
@@ -9,6 +9,8 @@ import (
// TranscriptionJob represents a transcription job record
type TranscriptionJob struct {
+ // Owner (empty for legacy/quick temp jobs)
+ UserID uint `json:"user_id" gorm:"index"`
ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
Title *string `json:"title,omitempty" gorm:"type:text"`
Status JobStatus `json:"status" gorm:"type:varchar(20);not null;default:'pending'"`
@@ -133,27 +135,31 @@ func (tj *TranscriptionJob) BeforeCreate(tx *gorm.DB) error {
// User represents a user for authentication
type User struct {
- ID uint `json:"id" gorm:"primaryKey"`
- Username string `json:"username" gorm:"uniqueIndex;not null;type:varchar(50)"`
- Password string `json:"-" gorm:"not null;type:varchar(255)"`
- DefaultProfileID *string `json:"default_profile_id,omitempty" gorm:"type:varchar(36)"`
- AutoTranscriptionEnabled bool `json:"auto_transcription_enabled" gorm:"not null;default:false"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID uint `json:"id" gorm:"primaryKey"`
+ Username string `json:"username" gorm:"uniqueIndex;not null;type:varchar(50)"`
+ Password string `json:"-" gorm:"not null;type:varchar(255)"`
+ IsAdmin bool `json:"is_admin" gorm:"not null;default:false"`
+ DefaultProfileID *string `json:"default_profile_id,omitempty" gorm:"type:varchar(36)"`
+ AutoTranscriptionEnabled bool `json:"auto_transcription_enabled" gorm:"not null;default:false"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// APIKey represents an API key for external authentication
type APIKey struct {
- ID uint `json:"id" gorm:"primaryKey"`
- Key string `json:"key" gorm:"uniqueIndex;not null;type:varchar(255)"`
- Name string `json:"name" gorm:"not null;type:varchar(100)"`
- Description *string `json:"description,omitempty" gorm:"type:text"`
+ ID uint `json:"id" gorm:"primaryKey"`
+ Key string `json:"key" gorm:"uniqueIndex;not null;type:varchar(255)"`
+ Name string `json:"name" gorm:"not null;type:varchar(100)"`
+ Description *string `json:"description,omitempty" gorm:"type:text"`
// IsActive should persist explicit false values; avoid default tag to prevent
// GORM from overriding false with DB defaults during inserts.
- IsActive bool `json:"is_active" gorm:"type:boolean;not null"`
- LastUsed *time.Time `json:"last_used,omitempty"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ IsActive bool `json:"is_active" gorm:"type:boolean;not null"`
+ LastUsed *time.Time `json:"last_used,omitempty"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+
+ // Owner
+ UserID uint `json:"user_id" gorm:"index"`
}
// BeforeCreate sets the API key if not already set
@@ -166,13 +172,16 @@ func (ak *APIKey) BeforeCreate(tx *gorm.DB) error {
// TranscriptionProfile represents a saved transcription configuration profile
type TranscriptionProfile struct {
- ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
- Name string `json:"name" gorm:"type:varchar(255);not null"`
- Description *string `json:"description,omitempty" gorm:"type:text"`
- IsDefault bool `json:"is_default" gorm:"type:boolean;default:false"`
- Parameters WhisperXParams `json:"parameters" gorm:"embedded"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
+ Name string `json:"name" gorm:"type:varchar(255);not null"`
+ Description *string `json:"description,omitempty" gorm:"type:text"`
+ IsDefault bool `json:"is_default" gorm:"type:boolean;default:false"`
+ Parameters WhisperXParams `json:"parameters" gorm:"embedded"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+
+ // Owner
+ UserID uint `json:"user_id" gorm:"index"`
}
// BeforeCreate sets the ID if not already set
@@ -183,53 +192,59 @@ func (tp *TranscriptionProfile) BeforeCreate(tx *gorm.DB) error {
return nil
}
-// BeforeSave ensures only one profile can be default
+// BeforeSave ensures only one profile can be default (per user)
func (tp *TranscriptionProfile) BeforeSave(tx *gorm.DB) error {
- if tp.IsDefault {
- // Set all other profiles to not default
- if err := tx.Model(&TranscriptionProfile{}).Where("id != ?", tp.ID).Update("is_default", false).Error; err != nil {
- return err
- }
- }
- return nil
+ if tp.IsDefault {
+ // Set all other profiles to not default
+ if err := tx.Model(&TranscriptionProfile{}).Where("id != ? AND user_id = ?", tp.ID, tp.UserID).Update("is_default", false).Error; err != nil {
+ return err
+ }
+ }
+ return nil
}
// LLMConfig represents LLM configuration settings
type LLMConfig struct {
- ID uint `json:"id" gorm:"primaryKey"`
- Provider string `json:"provider" gorm:"not null;type:varchar(50)"` // "ollama" or "openai"
- BaseURL *string `json:"base_url,omitempty" gorm:"type:text"` // For Ollama
- APIKey *string `json:"api_key,omitempty" gorm:"type:text"` // For OpenAI (encrypted)
- IsActive bool `json:"is_active" gorm:"type:boolean;default:false"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID uint `json:"id" gorm:"primaryKey"`
+ Provider string `json:"provider" gorm:"not null;type:varchar(50)"` // "ollama" or "openai"
+ BaseURL *string `json:"base_url,omitempty" gorm:"type:text"` // For Ollama
+ APIKey *string `json:"api_key,omitempty" gorm:"type:text"` // For OpenAI (encrypted)
+ IsActive bool `json:"is_active" gorm:"type:boolean;default:false"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+
+ // Owner (0 = legacy/global)
+ UserID uint `json:"user_id" gorm:"index"`
}
-// BeforeSave ensures only one LLM config can be active
+// BeforeSave ensures only one LLM config can be active (per user)
func (lc *LLMConfig) BeforeSave(tx *gorm.DB) error {
- if lc.IsActive {
- // Set all other configs to not active
- if err := tx.Model(&LLMConfig{}).Where("id != ?", lc.ID).Update("is_active", false).Error; err != nil {
- return err
- }
- }
- return nil
+ if lc.IsActive {
+ // Set all other configs to not active
+ if err := tx.Model(&LLMConfig{}).Where("id != ? AND user_id = ?", lc.ID, lc.UserID).Update("is_active", false).Error; err != nil {
+ return err
+ }
+ }
+ return nil
}
// ChatSession represents a chat session with a transcript
type ChatSession struct {
- ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
- JobID string `json:"job_id" gorm:"type:varchar(36);not null"`
- TranscriptionID string `json:"transcription_id" gorm:"type:varchar(36);not null;index"`
- Title string `json:"title" gorm:"type:varchar(255);not null"`
- Model string `json:"model" gorm:"type:varchar(100);not null"`
- Provider string `json:"provider" gorm:"type:varchar(50);not null;default:'openai'"`
- SystemContext *string `json:"system_context,omitempty" gorm:"type:text"`
- MessageCount int `json:"message_count" gorm:"type:integer;default:0"`
- LastActivityAt *time.Time `json:"last_activity_at,omitempty" gorm:"type:datetime"`
- IsActive bool `json:"is_active" gorm:"type:boolean;default:true"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
+ JobID string `json:"job_id" gorm:"type:varchar(36);not null"`
+ TranscriptionID string `json:"transcription_id" gorm:"type:varchar(36);not null;index"`
+ Title string `json:"title" gorm:"type:varchar(255);not null"`
+ Model string `json:"model" gorm:"type:varchar(100);not null"`
+ Provider string `json:"provider" gorm:"type:varchar(50);not null;default:'openai'"`
+ SystemContext *string `json:"system_context,omitempty" gorm:"type:text"`
+ MessageCount int `json:"message_count" gorm:"type:integer;default:0"`
+ LastActivityAt *time.Time `json:"last_activity_at,omitempty" gorm:"type:datetime"`
+ IsActive bool `json:"is_active" gorm:"type:boolean;default:true"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+
+ // Owner
+ UserID uint `json:"user_id" gorm:"index"`
// Relationships
Transcription TranscriptionJob `json:"transcription,omitempty" gorm:"foreignKey:TranscriptionID"`
diff --git a/internal/transcription/adapters/canary_adapter.go b/internal/transcription/adapters/canary_adapter.go
index 17e2bfe81..bf21bab7a 100644
--- a/internal/transcription/adapters/canary_adapter.go
+++ b/internal/transcription/adapters/canary_adapter.go
@@ -11,6 +11,7 @@ import (
"time"
"scriberr/internal/transcription/interfaces"
+ "scriberr/internal/transcription/registry"
"scriberr/pkg/logger"
)
@@ -21,7 +22,9 @@ type CanaryAdapter struct {
}
// NewCanaryAdapter creates a new Canary adapter
-func NewCanaryAdapter(envPath string) *CanaryAdapter {
+func NewCanaryAdapter() *CanaryAdapter {
+ envPath := "whisperx-env/parakeet" // Shares environment with Parakeet
+
capabilities := interfaces.ModelCapabilities{
ModelID: "canary",
ModelFamily: "nvidia_canary",
@@ -546,9 +549,7 @@ func (c *CanaryAdapter) Transcribe(ctx context.Context, input interfaces.AudioIn
// Execute Canary
cmd := exec.CommandContext(ctx, "uv", args...)
- cmd.Env = append(os.Environ(),
- "PYTHONUNBUFFERED=1",
- "PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True")
+ cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
logger.Info("Executing Canary command", "args", strings.Join(args, " "))
@@ -698,4 +699,9 @@ func (c *CanaryAdapter) GetEstimatedProcessingTime(input interfaces.AudioInput)
// Canary typically processes at about 40-50% of audio duration
return time.Duration(float64(baseTime) * 2.0)
+}
+
+// init registers the Canary adapter
+func init() {
+ registry.RegisterTranscriptionAdapter("canary", NewCanaryAdapter())
}
\ No newline at end of file
diff --git a/internal/transcription/adapters/parakeet_adapter.go b/internal/transcription/adapters/parakeet_adapter.go
index 38c0c8c17..8903b315a 100644
--- a/internal/transcription/adapters/parakeet_adapter.go
+++ b/internal/transcription/adapters/parakeet_adapter.go
@@ -12,6 +12,7 @@ import (
"time"
"scriberr/internal/transcription/interfaces"
+ "scriberr/internal/transcription/registry"
"scriberr/pkg/logger"
)
@@ -22,7 +23,9 @@ type ParakeetAdapter struct {
}
// NewParakeetAdapter creates a new Parakeet adapter
-func NewParakeetAdapter(envPath string) *ParakeetAdapter {
+func NewParakeetAdapter() *ParakeetAdapter {
+ envPath := "whisperx-env/parakeet"
+
capabilities := interfaces.ModelCapabilities{
ModelID: "parakeet",
ModelFamily: "nvidia_parakeet",
@@ -34,11 +37,11 @@ func NewParakeetAdapter(envPath string) *ParakeetAdapter {
RequiresGPU: false, // Can run on CPU but GPU recommended
MemoryRequirement: 4096, // 4GB recommended
Features: map[string]bool{
- "timestamps": true,
- "word_level": true,
- "long_form": true,
- "attention_context": true,
- "high_quality": true,
+ "timestamps": true,
+ "word_level": true,
+ "long_form": true,
+ "attention_context": true,
+ "high_quality": true,
},
Metadata: map[string]string{
"engine": "nvidia_nemo",
@@ -106,7 +109,7 @@ func NewParakeetAdapter(envPath string) *ParakeetAdapter {
}
baseAdapter := NewBaseAdapter("parakeet", envPath, capabilities, schema)
-
+
adapter := &ParakeetAdapter{
BaseAdapter: baseAdapter,
envPath: envPath,
@@ -128,19 +131,15 @@ func (p *ParakeetAdapter) PrepareEnvironment(ctx context.Context) error {
if CheckEnvironmentReady(p.envPath, "import nemo.collections.asr") {
modelPath := filepath.Join(p.envPath, "parakeet-tdt-0.6b-v3.nemo")
scriptPath := filepath.Join(p.envPath, "transcribe.py")
- bufferedScriptPath := filepath.Join(p.envPath, "transcribe_buffered.py")
-
- // Check model, standard script, and buffered script all exist
+
+ // Check both model and script exist
if stat, err := os.Stat(modelPath); err == nil && stat.Size() > 1024*1024 {
- _, scriptErr := os.Stat(scriptPath)
- _, bufferedErr := os.Stat(bufferedScriptPath)
-
- if scriptErr == nil && bufferedErr == nil {
+ if _, err := os.Stat(scriptPath); err == nil {
logger.Info("Parakeet environment already ready")
p.initialized = true
return nil
} else {
- logger.Info("Parakeet model exists but scripts missing, recreating scripts")
+ logger.Info("Parakeet model exists but script missing, recreating script")
}
} else {
logger.Info("Parakeet model file missing or incomplete, redownloading")
@@ -159,15 +158,11 @@ func (p *ParakeetAdapter) PrepareEnvironment(ctx context.Context) error {
return fmt.Errorf("failed to download Parakeet model: %w", err)
}
- // Create transcription scripts (standard and buffered)
+ // Create transcription script
if err := p.createTranscriptionScript(); err != nil {
return fmt.Errorf("failed to create transcription script: %w", err)
}
- if err := p.createBufferedScript(); err != nil {
- return fmt.Errorf("failed to create buffered script: %w", err)
- }
-
p.initialized = true
logger.Info("Parakeet environment prepared successfully")
return nil
@@ -227,9 +222,9 @@ func (p *ParakeetAdapter) downloadParakeetModel() error {
}
logger.Info("Downloading Parakeet model", "path", modelPath)
-
+
modelURL := "https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3/resolve/main/parakeet-tdt-0.6b-v3.nemo?download=true"
-
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
@@ -484,84 +479,8 @@ func (p *ParakeetAdapter) Transcribe(ctx context.Context, input interfaces.Audio
}
}
- // Detect audio duration and choose processing path
- audioDuration := input.Duration
- if audioDuration == 0 {
- // Duration not provided, try to detect it
- durationSecs, err := p.detectAudioDuration(audioInput.FilePath)
- if err != nil {
- logger.Warn("Failed to detect audio duration, using standard transcription", "error", err)
- audioDuration = 0
- } else {
- audioDuration = time.Duration(durationSecs * float64(time.Second))
- }
- }
-
- // Get chunk threshold from environment (default: 300 seconds = 5 minutes)
- chunkThreshold := 300
- if thresholdStr := os.Getenv("PARAKEET_CHUNK_THRESHOLD_SECS"); thresholdStr != "" {
- if parsed, err := strconv.Atoi(thresholdStr); err == nil && parsed > 0 {
- chunkThreshold = parsed
- }
- }
-
- // Choose processing path based on audio duration
- chunkThresholdDuration := time.Duration(chunkThreshold) * time.Second
- var result *interfaces.TranscriptResult
- if audioDuration > chunkThresholdDuration {
- logger.Info("Using buffered inference for long audio",
- "duration_secs", audioDuration.Seconds(),
- "threshold_secs", chunkThreshold)
- result, err = p.transcribeBuffered(ctx, audioInput, params, tempDir)
- } else {
- logger.Info("Using standard transcription for short audio",
- "duration_secs", audioDuration.Seconds(),
- "threshold_secs", chunkThreshold)
- result, err = p.transcribeStandard(ctx, audioInput, params, tempDir)
- }
-
- if err != nil {
- return nil, err
- }
-
- result.ProcessingTime = time.Since(startTime)
- result.ModelUsed = "parakeet-tdt-0.6b-v3"
- result.Metadata = p.CreateDefaultMetadata(params)
-
- logger.Info("Parakeet transcription completed",
- "segments", len(result.Segments),
- "words", len(result.WordSegments),
- "processing_time", result.ProcessingTime)
-
- return result, nil
-}
-
-// detectAudioDuration uses ffprobe to detect audio duration
-func (p *ParakeetAdapter) detectAudioDuration(audioPath string) (float64, error) {
- cmd := exec.Command("ffprobe",
- "-v", "error",
- "-show_entries", "format=duration",
- "-of", "default=noprint_wrappers=1:nokey=1",
- audioPath)
-
- output, err := cmd.Output()
- if err != nil {
- return 0, fmt.Errorf("ffprobe failed: %w", err)
- }
-
- durationStr := strings.TrimSpace(string(output))
- duration, err := strconv.ParseFloat(durationStr, 64)
- if err != nil {
- return 0, fmt.Errorf("failed to parse duration: %w", err)
- }
-
- return duration, nil
-}
-
-// transcribeStandard uses the standard Parakeet transcription (original method)
-func (p *ParakeetAdapter) transcribeStandard(ctx context.Context, input interfaces.AudioInput, params map[string]interface{}, tempDir string) (*interfaces.TranscriptResult, error) {
// Build command arguments
- args, err := p.buildParakeetArgs(input, params, tempDir)
+ args, err := p.buildParakeetArgs(audioInput, params, tempDir)
if err != nil {
return nil, fmt.Errorf("failed to build command: %w", err)
}
@@ -571,7 +490,7 @@ func (p *ParakeetAdapter) transcribeStandard(ctx context.Context, input interfac
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
logger.Info("Executing Parakeet command", "args", strings.Join(args, " "))
-
+
output, err := cmd.CombinedOutput()
if ctx.Err() == context.Canceled {
return nil, fmt.Errorf("transcription was cancelled")
@@ -582,42 +501,19 @@ func (p *ParakeetAdapter) transcribeStandard(ctx context.Context, input interfac
}
// Parse result
- result, err := p.parseResult(tempDir, input, params)
+ result, err := p.parseResult(tempDir, audioInput, params)
if err != nil {
return nil, fmt.Errorf("failed to parse result: %w", err)
}
- return result, nil
-}
-
-// transcribeBuffered uses NeMo's buffered inference for long audio
-func (p *ParakeetAdapter) transcribeBuffered(ctx context.Context, input interfaces.AudioInput, params map[string]interface{}, tempDir string) (*interfaces.TranscriptResult, error) {
- // Build command arguments for buffered inference
- args, err := p.buildBufferedArgs(input, params, tempDir)
- if err != nil {
- return nil, fmt.Errorf("failed to build buffered command: %w", err)
- }
-
- // Execute buffered inference
- cmd := exec.CommandContext(ctx, "uv", args...)
- cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
-
- logger.Info("Executing Parakeet buffered inference", "args", strings.Join(args, " "))
-
- output, err := cmd.CombinedOutput()
- if ctx.Err() == context.Canceled {
- return nil, fmt.Errorf("transcription was cancelled")
- }
- if err != nil {
- logger.Error("Parakeet buffered execution failed", "output", string(output), "error", err)
- return nil, fmt.Errorf("Parakeet buffered execution failed: %w", err)
- }
+ result.ProcessingTime = time.Since(startTime)
+ result.ModelUsed = "parakeet-tdt-0.6b-v3"
+ result.Metadata = p.CreateDefaultMetadata(params)
- // Parse buffered result
- result, err := p.parseBufferedResult(tempDir, input, params)
- if err != nil {
- return nil, fmt.Errorf("failed to parse buffered result: %w", err)
- }
+ logger.Info("Parakeet transcription completed",
+ "segments", len(result.Segments),
+ "words", len(result.WordSegments),
+ "processing_time", result.ProcessingTime)
return result, nil
}
@@ -625,7 +521,7 @@ func (p *ParakeetAdapter) transcribeBuffered(ctx context.Context, input interfac
// buildParakeetArgs builds the command arguments for Parakeet
func (p *ParakeetAdapter) buildParakeetArgs(input interfaces.AudioInput, params map[string]interface{}, tempDir string) ([]string, error) {
outputFile := filepath.Join(tempDir, "result.json")
-
+
scriptPath := filepath.Join(p.envPath, "transcribe.py")
args := []string{
"run", "--native-tls", "--project", p.envPath, "python", scriptPath,
@@ -650,16 +546,16 @@ func (p *ParakeetAdapter) buildParakeetArgs(input interfaces.AudioInput, params
// parseResult parses the Parakeet output
func (p *ParakeetAdapter) parseResult(tempDir string, input interfaces.AudioInput, params map[string]interface{}) (*interfaces.TranscriptResult, error) {
resultFile := filepath.Join(tempDir, "result.json")
-
+
data, err := os.ReadFile(resultFile)
if err != nil {
return nil, fmt.Errorf("failed to read result file: %w", err)
}
var parakeetResult struct {
- Transcription string `json:"transcription"`
- Language string `json:"language"`
- WordTimestamps []struct {
+ Transcription string `json:"transcription"`
+ Language string `json:"language"`
+ WordTimestamps []struct {
Word string `json:"word"`
StartOffset int `json:"start_offset"`
EndOffset int `json:"end_offset"`
@@ -682,11 +578,11 @@ func (p *ParakeetAdapter) parseResult(tempDir string, input interfaces.AudioInpu
// Convert to standard format
result := &interfaces.TranscriptResult{
- Text: parakeetResult.Transcription,
- Language: parakeetResult.Language,
- Segments: make([]interfaces.TranscriptSegment, len(parakeetResult.SegmentTimestamps)),
+ Text: parakeetResult.Transcription,
+ Language: parakeetResult.Language,
+ Segments: make([]interfaces.TranscriptSegment, len(parakeetResult.SegmentTimestamps)),
WordSegments: make([]interfaces.TranscriptWord, len(parakeetResult.WordTimestamps)),
- Confidence: 0.0, // Default confidence
+ Confidence: 0.0, // Default confidence
}
// Convert segments
@@ -711,222 +607,16 @@ func (p *ParakeetAdapter) parseResult(tempDir string, input interfaces.AudioInpu
return result, nil
}
-// createBufferedScript creates the Python script for NeMo buffered inference
-func (p *ParakeetAdapter) createBufferedScript() error {
- scriptContent := `#!/usr/bin/env python3
-"""
-NVIDIA Parakeet buffered inference for long audio files.
-Splits audio into chunks to avoid GPU memory issues.
-"""
-
-import argparse
-import json
-import sys
-import os
-import librosa
-import soundfile as sf
-import numpy as np
-from pathlib import Path
-import nemo.collections.asr as nemo_asr
-
-
-def split_audio_file(audio_path, chunk_duration_secs=300):
- """Split audio file into chunks of specified duration."""
- audio, sr = librosa.load(audio_path, sr=None, mono=True)
- total_duration = len(audio) / sr
- chunk_samples = int(chunk_duration_secs * sr)
-
- chunks = []
- for start_sample in range(0, len(audio), chunk_samples):
- end_sample = min(start_sample + chunk_samples, len(audio))
- chunk_audio = audio[start_sample:end_sample]
- start_time = start_sample / sr
- chunks.append({
- 'audio': chunk_audio,
- 'start_time': start_time,
- 'duration': len(chunk_audio) / sr
- })
-
- return chunks, sr
-
-
-def transcribe_buffered(
- audio_path: str,
- output_file: str = None,
- chunk_duration_secs: float = 300, # 5 minutes default
-):
- """
- Transcribe long audio by splitting into chunks and merging results.
- """
- script_dir = os.path.dirname(os.path.abspath(__file__))
- model_path = os.path.join(script_dir, "parakeet-tdt-0.6b-v3.nemo")
-
- print(f"Loading NVIDIA Parakeet model from: {model_path}")
- if not os.path.exists(model_path):
- print(f"Error: Model not found at {model_path}")
- sys.exit(1)
-
- asr_model = nemo_asr.models.ASRModel.restore_from(model_path)
-
- # Disable CUDA graphs to fix Error 35 on RTX 2000e Ada GPU
- # Uses change_decoding_strategy() to properly reconfigure the TDT decoder
- from omegaconf import OmegaConf, open_dict
-
- print("Disabling CUDA graphs in TDT decoder...")
- dec_cfg = asr_model.cfg.decoding
-
- # Add use_cuda_graph_decoder parameter to greedy config
- with open_dict(dec_cfg.greedy):
- dec_cfg.greedy['use_cuda_graph_decoder'] = False
-
- # Apply the new decoding strategy (this rebuilds the decoder with our config)
- asr_model.change_decoding_strategy(dec_cfg)
- print("✓ CUDA graphs disabled successfully")
-
- print(f"Splitting audio into {chunk_duration_secs}s chunks...")
- chunks, sr = split_audio_file(audio_path, chunk_duration_secs)
- print(f"Created {len(chunks)} chunks")
-
- all_words = []
- all_segments = []
- full_text = []
-
- for i, chunk_info in enumerate(chunks):
- print(f"Transcribing chunk {i+1}/{len(chunks)} (duration: {chunk_info['duration']:.1f}s)...")
-
- # Save chunk to temporary file
- chunk_path = f"/tmp/chunk_{i}.wav"
- sf.write(chunk_path, chunk_info['audio'], sr)
-
- try:
- # Transcribe chunk
- output = asr_model.transcribe(
- [chunk_path],
- batch_size=1,
- timestamps=True,
- )
-
- result_data = output[0]
- chunk_text = result_data.text
- full_text.append(chunk_text)
-
- # Extract and adjust timestamps
- if hasattr(result_data, 'timestamp') and result_data.timestamp:
- chunk_words = result_data.timestamp.get("word", [])
- chunk_segments = result_data.timestamp.get("segment", [])
-
- # Adjust timestamps by chunk start time
- for word in chunk_words:
- word_copy = dict(word)
- word_copy['start'] += chunk_info['start_time']
- word_copy['end'] += chunk_info['start_time']
- all_words.append(word_copy)
-
- for segment in chunk_segments:
- seg_copy = dict(segment)
- seg_copy['start'] += chunk_info['start_time']
- seg_copy['end'] += chunk_info['start_time']
- all_segments.append(seg_copy)
-
- print(f"Chunk {i+1} complete: {len(chunk_text)} characters")
-
- finally:
- # Clean up temp file
- if os.path.exists(chunk_path):
- os.remove(chunk_path)
-
- final_text = " ".join(full_text)
- print(f"Transcription complete: {len(final_text)} characters total")
-
- output_data = {
- "transcription": final_text,
- "language": "en",
- "word_timestamps": all_words,
- "segment_timestamps": all_segments,
- "audio_file": audio_path,
- "model": "parakeet-tdt-0.6b-v3",
- "buffered": True,
- "chunk_duration_secs": chunk_duration_secs,
- "num_chunks": len(chunks),
- }
-
- if output_file:
- with open(output_file, 'w', encoding='utf-8') as f:
- json.dump(output_data, f, indent=2, ensure_ascii=False)
- print(f"Results saved to: {output_file}")
- else:
- print(json.dumps(output_data, indent=2, ensure_ascii=False))
-
-
-def main():
- parser = argparse.ArgumentParser(
- description="Transcribe long audio using NVIDIA Parakeet with chunking"
- )
- parser.add_argument("audio_file", help="Path to audio file")
- parser.add_argument("--output", "-o", help="Output file path", required=True)
- parser.add_argument(
- "--chunk-len", type=float, default=300,
- help="Chunk duration in seconds (default: 300 = 5 minutes)"
- )
-
- args = parser.parse_args()
-
- if not os.path.exists(args.audio_file):
- print(f"Error: Audio file not found: {args.audio_file}")
- sys.exit(1)
-
- transcribe_buffered(
- audio_path=args.audio_file,
- output_file=args.output,
- chunk_duration_secs=args.chunk_len,
- )
-
-
-if __name__ == "__main__":
- main()
-`
-
- scriptPath := filepath.Join(p.envPath, "transcribe_buffered.py")
- if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
- return fmt.Errorf("failed to write buffered script: %w", err)
- }
-
- logger.Info("Created buffered transcription script", "path", scriptPath)
- return nil
-}
-
-// buildBufferedArgs builds the command arguments for buffered inference
-func (p *ParakeetAdapter) buildBufferedArgs(input interfaces.AudioInput, params map[string]interface{}, tempDir string) ([]string, error) {
- outputFile := filepath.Join(tempDir, "result.json")
-
- // Get chunk threshold from environment (default: 300 seconds = 5 minutes)
- chunkDuration := "300"
- if thresholdStr := os.Getenv("PARAKEET_CHUNK_THRESHOLD_SECS"); thresholdStr != "" {
- chunkDuration = thresholdStr
- }
-
- scriptPath := filepath.Join(p.envPath, "transcribe_buffered.py")
- args := []string{
- "run", "--native-tls", "--project", p.envPath, "python", scriptPath,
- input.FilePath,
- "--output", outputFile,
- "--chunk-len", chunkDuration,
- }
-
- return args, nil
-}
-
-// parseBufferedResult parses the buffered inference output
-func (p *ParakeetAdapter) parseBufferedResult(tempDir string, input interfaces.AudioInput, params map[string]interface{}) (*interfaces.TranscriptResult, error) {
- // Buffered inference uses the same output format as standard transcription
- return p.parseResult(tempDir, input, params)
-}
-
// GetEstimatedProcessingTime provides Parakeet-specific time estimation
func (p *ParakeetAdapter) GetEstimatedProcessingTime(input interfaces.AudioInput) time.Duration {
// Parakeet is generally faster than WhisperX but slower than real-time
baseTime := p.BaseAdapter.GetEstimatedProcessingTime(input)
-
+
// Parakeet typically processes at about 30% of audio duration
return time.Duration(float64(baseTime) * 1.5)
}
+
+// init registers the Parakeet adapter
+func init() {
+ registry.RegisterTranscriptionAdapter("parakeet", NewParakeetAdapter())
+}
\ No newline at end of file
diff --git a/internal/transcription/adapters/pyannote_adapter.go b/internal/transcription/adapters/pyannote_adapter.go
index dfc05fdb6..fd308171a 100644
--- a/internal/transcription/adapters/pyannote_adapter.go
+++ b/internal/transcription/adapters/pyannote_adapter.go
@@ -12,6 +12,7 @@ import (
"time"
"scriberr/internal/transcription/interfaces"
+ "scriberr/internal/transcription/registry"
"scriberr/pkg/logger"
)
@@ -22,7 +23,9 @@ type PyAnnoteAdapter struct {
}
// NewPyAnnoteAdapter creates a new PyAnnote diarization adapter
-func NewPyAnnoteAdapter(envPath string) *PyAnnoteAdapter {
+func NewPyAnnoteAdapter() *PyAnnoteAdapter {
+ envPath := "whisperx-env/parakeet" // Shares environment with NVIDIA models
+
capabilities := interfaces.ModelCapabilities{
ModelID: "pyannote",
ModelFamily: "pyannote",
@@ -116,8 +119,8 @@ func NewPyAnnoteAdapter(envPath string) *PyAnnoteAdapter {
Type: "string",
Required: false,
Default: "cpu",
- Options: []string{"cpu", "cuda", "mps"},
- Description: "Device to use for computation (cpu, cuda for NVIDIA GPUs, mps for Apple Silicon)",
+ Options: []string{"cpu", "cuda"},
+ Description: "Device to use for computation",
Group: "advanced",
},
@@ -368,16 +371,6 @@ def diarize_audio(
print("CUDA not available, falling back to CPU")
except ImportError:
print("PyTorch not available for CUDA, using CPU")
- elif device == "mps":
- try:
- import torch
- if torch.backends.mps.is_available():
- pipeline = pipeline.to(torch.device("mps"))
- print("Using MPS (Apple Silicon) for diarization")
- else:
- print("MPS not available, falling back to CPU")
- except (ImportError, AttributeError):
- print("PyTorch MPS not available, using CPU")
print("Pipeline loaded successfully")
except Exception as e:
@@ -507,7 +500,7 @@ def main():
)
parser.add_argument(
"--device",
- choices=["cpu", "cuda", "mps"],
+ choices=["cpu", "cuda"],
default="cpu",
help="Device to use for computation"
)
@@ -799,7 +792,12 @@ func (p *PyAnnoteAdapter) parseRTTMResult(tempDir string, input interfaces.Audio
func (p *PyAnnoteAdapter) GetEstimatedProcessingTime(input interfaces.AudioInput) time.Duration {
// PyAnnote is typically faster than real-time for diarization
baseTime := p.BaseAdapter.GetEstimatedProcessingTime(input)
-
+
// PyAnnote typically processes at about 10-15% of audio duration
return time.Duration(float64(baseTime) * 0.5)
+}
+
+// init registers the PyAnnote adapter
+func init() {
+ registry.RegisterDiarizationAdapter("pyannote", NewPyAnnoteAdapter())
}
\ No newline at end of file
diff --git a/internal/transcription/adapters/sortformer_adapter.go b/internal/transcription/adapters/sortformer_adapter.go
index d2e0a7f57..1b0fab707 100644
--- a/internal/transcription/adapters/sortformer_adapter.go
+++ b/internal/transcription/adapters/sortformer_adapter.go
@@ -12,6 +12,7 @@ import (
"time"
"scriberr/internal/transcription/interfaces"
+ "scriberr/internal/transcription/registry"
"scriberr/pkg/logger"
)
@@ -22,7 +23,9 @@ type SortformerAdapter struct {
}
// NewSortformerAdapter creates a new NVIDIA Sortformer diarization adapter
-func NewSortformerAdapter(envPath string) *SortformerAdapter {
+func NewSortformerAdapter() *SortformerAdapter {
+ envPath := "whisperx-env/parakeet" // Shares environment with NVIDIA models
+
capabilities := interfaces.ModelCapabilities{
ModelID: "sortformer",
ModelFamily: "nvidia_sortformer",
@@ -91,8 +94,8 @@ func NewSortformerAdapter(envPath string) *SortformerAdapter {
Type: "string",
Required: false,
Default: "auto",
- Options: []string{"cpu", "cuda", "mps", "auto"},
- Description: "Device to use for computation (cpu, cuda for NVIDIA GPUs, mps for Apple Silicon, auto for automatic detection)",
+ Options: []string{"cpu", "cuda", "auto"},
+ Description: "Device to use for computation",
Group: "advanced",
},
@@ -322,12 +325,7 @@ def diarize_audio(
Perform speaker diarization using NVIDIA's Sortformer model.
"""
if device is None or device == "auto":
- if torch.cuda.is_available():
- device = "cuda"
- elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
- device = "mps"
- else:
- device = "cpu"
+ device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
print(f"Loading NVIDIA Sortformer diarization model...")
@@ -573,7 +571,7 @@ Note: This script requires diar_streaming_sortformer_4spk-v2.nemo to be in the s
parser.add_argument("audio_file", help="Path to input audio file (WAV, FLAC, etc.)")
parser.add_argument("output_file", help="Path to output file (.json for JSON format, .rttm for RTTM format)")
parser.add_argument("--batch-size", type=int, default=1, help="Batch size for processing (default: 1)")
- parser.add_argument("--device", choices=["cuda", "mps", "cpu", "auto"], default="auto", help="Device to use for inference (default: auto-detect)")
+ parser.add_argument("--device", choices=["cuda", "cpu", "auto"], default="auto", help="Device to use for inference (default: auto-detect)")
parser.add_argument("--max-speakers", type=int, default=4, help="Maximum number of speakers (default: 4, optimized for this model)")
parser.add_argument("--output-format", choices=["json", "rttm"], help="Output format (auto-detected from file extension if not specified)")
parser.add_argument("--streaming", action="store_true", help="Enable streaming mode")
@@ -873,4 +871,9 @@ func (s *SortformerAdapter) GetEstimatedProcessingTime(input interfaces.AudioInp
// Sortformer typically processes at about 5-10% of audio duration
return time.Duration(float64(baseTime) * 0.3)
+}
+
+// init registers the Sortformer adapter
+func init() {
+ registry.RegisterDiarizationAdapter("sortformer", NewSortformerAdapter())
}
\ No newline at end of file
diff --git a/internal/transcription/adapters/whisperx_adapter.go b/internal/transcription/adapters/whisperx_adapter.go
index 63b0716bb..fb6bc4de3 100644
--- a/internal/transcription/adapters/whisperx_adapter.go
+++ b/internal/transcription/adapters/whisperx_adapter.go
@@ -12,6 +12,7 @@ import (
"time"
"scriberr/internal/transcription/interfaces"
+ "scriberr/internal/transcription/registry"
"scriberr/pkg/logger"
)
@@ -22,7 +23,9 @@ type WhisperXAdapter struct {
}
// NewWhisperXAdapter creates a new WhisperX adapter
-func NewWhisperXAdapter(envPath string) *WhisperXAdapter {
+func NewWhisperXAdapter() *WhisperXAdapter {
+ envPath := "whisperx-env"
+
capabilities := interfaces.ModelCapabilities{
ModelID: "whisperx",
ModelFamily: "whisper",
@@ -76,7 +79,7 @@ func NewWhisperXAdapter(envPath string) *WhisperXAdapter {
Type: "string",
Required: false,
Default: "cpu",
- Options: []string{"cpu", "cuda", "mps"},
+ Options: []string{"cpu", "cuda"},
Description: "Device to use for computation",
Group: "basic",
},
@@ -346,7 +349,7 @@ func (w *WhisperXAdapter) updateWhisperXDependencies(whisperxPath string) error
content = strings.ReplaceAll(content,
`"transformers>=4.48.0",`,
`"transformers>=4.48.0",
- "yt-dlp[default]",`)
+ "yt-dlp",`)
}
if err := os.WriteFile(pyprojectPath, []byte(content), 0644); err != nil {
@@ -590,9 +593,14 @@ func (w *WhisperXAdapter) parseResult(outputDir string, input interfaces.AudioIn
func (w *WhisperXAdapter) GetEstimatedProcessingTime(input interfaces.AudioInput) time.Duration {
// WhisperX processing time varies by model size
baseTime := w.BaseAdapter.GetEstimatedProcessingTime(input)
-
+
// Adjust based on model size (if we can determine it)
// This would need model size information from parameters
// For now, use base estimation
return baseTime
+}
+
+// init registers the WhisperX adapter
+func init() {
+ registry.RegisterTranscriptionAdapter("whisperx", NewWhisperXAdapter())
}
\ No newline at end of file
diff --git a/internal/transcription/quick_transcription.go b/internal/transcription/quick_transcription.go
index d09d41258..06e05334d 100644
--- a/internal/transcription/quick_transcription.go
+++ b/internal/transcription/quick_transcription.go
@@ -105,6 +105,17 @@ func (qs *QuickTranscriptionService) SubmitQuickJob(audioData io.Reader, filenam
return job, nil
}
+// SubmitQuickJobForUser creates and processes a temporary transcription job and associates DB temp record with a user
+func (qs *QuickTranscriptionService) SubmitQuickJobForUser(audioData io.Reader, filename string, params models.WhisperXParams, userID uint) (*QuickTranscriptionJob, error) {
+ job, err := qs.SubmitQuickJob(audioData, filename, params)
+ if err != nil {
+ return nil, err
+ }
+ // Best-effort: set user on the temp DB job if present
+ _ = database.DB.Model(&models.TranscriptionJob{}).Where("id = ?", job.ID).Update("user_id", userID).Error
+ return job, nil
+}
+
// GetQuickJob retrieves a quick transcription job by ID
func (qs *QuickTranscriptionService) GetQuickJob(jobID string) (*QuickTranscriptionJob, error) {
qs.jobsMutex.RLock()
diff --git a/internal/transcription/registry/registry.go b/internal/transcription/registry/registry.go
index 817241b82..6fd95f77f 100644
--- a/internal/transcription/registry/registry.go
+++ b/internal/transcription/registry/registry.go
@@ -571,47 +571,4 @@ func (r *ModelRegistry) GetParameterSchema(modelID string) ([]interfaces.Paramet
}
return nil, fmt.Errorf("model not found: %s", modelID)
-}
-
-// Test helper functions
-
-// ClearRegistry clears all registered adapters (for testing only)
-func ClearRegistry() {
- registry := GetRegistry()
- registry.mu.Lock()
- defer registry.mu.Unlock()
-
- registry.transcriptionAdapters = make(map[string]interfaces.TranscriptionAdapter)
- registry.diarizationAdapters = make(map[string]interfaces.DiarizationAdapter)
- registry.compositeAdapters = make(map[string]interfaces.CompositeAdapter)
- registry.capabilities = make(map[string]interfaces.ModelCapabilities)
- registry.initialized = false
-}
-
-// GetTranscriptionAdapters returns all registered transcription adapters (for testing)
-func GetTranscriptionAdapters() map[string]interfaces.TranscriptionAdapter {
- registry := GetRegistry()
- registry.mu.RLock()
- defer registry.mu.RUnlock()
-
- // Return a copy to avoid concurrent access issues
- result := make(map[string]interfaces.TranscriptionAdapter)
- for id, adapter := range registry.transcriptionAdapters {
- result[id] = adapter
- }
- return result
-}
-
-// GetDiarizationAdapters returns all registered diarization adapters (for testing)
-func GetDiarizationAdapters() map[string]interfaces.DiarizationAdapter {
- registry := GetRegistry()
- registry.mu.RLock()
- defer registry.mu.RUnlock()
-
- // Return a copy to avoid concurrent access issues
- result := make(map[string]interfaces.DiarizationAdapter)
- for id, adapter := range registry.diarizationAdapters {
- result[id] = adapter
- }
- return result
}
\ No newline at end of file
diff --git a/internal/web/static.go b/internal/web/static.go
index b804df2d2..35cb51440 100644
--- a/internal/web/static.go
+++ b/internal/web/static.go
@@ -4,9 +4,11 @@ import (
"embed"
"io/fs"
"net/http"
+ "os"
"strings"
"scriberr/internal/auth"
+ "scriberr/internal/config"
"github.com/gin-gonic/gin"
)
@@ -72,6 +74,22 @@ func SetupStaticRoutes(router *gin.Engine, authService *auth.AuthService) {
// Serve scriberr-logo.png
router.GET("/scriberr-logo.png", func(c *gin.Context) {
+ // Prevent aggressive browser caching so logo changes are visible immediately
+ c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
+
+ // First try to load a custom uploaded logo from the data directory
+ if fileContent, err := os.ReadFile(config.CustomLogoPath); err == nil {
+ c.Data(http.StatusOK, "image/png", fileContent)
+ return
+ }
+
+ // Then try to load a custom logo from the local filesystem (Transferordner/Logo-Diakovere.png)
+ if fileContent, err := os.ReadFile("Transferordner/Logo-Diakovere.png"); err == nil {
+ c.Data(http.StatusOK, "image/png", fileContent)
+ return
+ }
+
+ // Fallback to embedded default logo
fileContent, err := staticFiles.ReadFile("dist/scriberr-logo.png")
if err != nil {
c.Status(http.StatusNotFound)
diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go
index 892cd49f1..5a8ebc757 100644
--- a/pkg/middleware/auth.go
+++ b/pkg/middleware/auth.go
@@ -1,30 +1,33 @@
package middleware
import (
- "net/http"
- "strings"
- "time"
+ "net/http"
+ "strings"
+ "time"
- "scriberr/internal/auth"
- "scriberr/internal/database"
- "scriberr/internal/models"
+ "scriberr/internal/auth"
+ "scriberr/internal/database"
+ "scriberr/internal/models"
- "github.com/gin-gonic/gin"
+ "github.com/gin-gonic/gin"
)
// AuthMiddleware handles both API key and JWT authentication
func AuthMiddleware(authService *auth.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
- // Check for API key first
- apiKey := c.GetHeader("X-API-Key")
- if apiKey != "" {
- if validateAPIKey(apiKey) {
- c.Set("auth_type", "api_key")
- c.Set("api_key", apiKey)
- c.Next()
- return
- }
- }
+ // Check for API key first
+ apiKey := c.GetHeader("X-API-Key")
+ if apiKey != "" {
+ if key, ok := validateAPIKey(apiKey); ok {
+ c.Set("auth_type", "api_key")
+ c.Set("api_key", apiKey)
+ if key.UserID != 0 {
+ c.Set("user_id", key.UserID)
+ }
+ c.Next()
+ return
+ }
+ }
// Check for JWT token
authHeader := c.GetHeader("Authorization")
@@ -58,41 +61,45 @@ func AuthMiddleware(authService *auth.AuthService) gin.HandlerFunc {
}
// validateAPIKey validates an API key against the database and updates last used timestamp
-func validateAPIKey(key string) bool {
- var apiKey models.APIKey
- result := database.DB.Where("key = ? AND is_active = ?", key, true).First(&apiKey)
- if result.Error != nil {
- return false
- }
-
- // Update last used timestamp
- now := time.Now()
- apiKey.LastUsed = &now
- database.DB.Save(&apiKey)
-
- return true
+func validateAPIKey(key string) (*models.APIKey, bool) {
+ var apiKey models.APIKey
+ result := database.DB.Where("key = ? AND is_active = ?", key, true).First(&apiKey)
+ if result.Error != nil {
+ return nil, false
+ }
+
+ // Update last used timestamp
+ now := time.Now()
+ apiKey.LastUsed = &now
+ database.DB.Save(&apiKey)
+
+ return &apiKey, true
}
// APIKeyOnlyMiddleware only allows API key authentication
func APIKeyOnlyMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
- apiKey := c.GetHeader("X-API-Key")
- if apiKey == "" {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
- c.Abort()
- return
- }
-
- if !validateAPIKey(apiKey) {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
- c.Abort()
- return
- }
-
- c.Set("auth_type", "api_key")
- c.Set("api_key", apiKey)
- c.Next()
- }
+ apiKey := c.GetHeader("X-API-Key")
+ if apiKey == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
+ c.Abort()
+ return
+ }
+
+ key, ok := validateAPIKey(apiKey)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
+ c.Abort()
+ return
+ }
+
+ c.Set("auth_type", "api_key")
+ c.Set("api_key", apiKey)
+ if key != nil && key.UserID != 0 {
+ c.Set("user_id", key.UserID)
+ }
+ c.Next()
+}
}
// JWTOnlyMiddleware only allows JWT authentication
@@ -126,3 +133,53 @@ func JWTOnlyMiddleware(authService *auth.AuthService) gin.HandlerFunc {
c.Next()
}
}
+
+// AdminOnlyMiddleware ensures the authenticated JWT user has admin privileges
+func AdminOnlyMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Require a user_id set by JWTOnlyMiddleware
+ v, ok := c.Get("user_id")
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
+ c.Abort()
+ return
+ }
+
+ var uid uint
+ switch id := v.(type) {
+ case uint:
+ uid = id
+ case int:
+ if id < 0 {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
+ c.Abort()
+ return
+ }
+ uid = uint(id)
+ case int64:
+ if id < 0 {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
+ c.Abort()
+ return
+ }
+ uid = uint(id)
+ default:
+ c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
+ c.Abort()
+ return
+ }
+
+ var user models.User
+ if err := database.DB.Select("id, is_admin").First(&user, uid).Error; err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
+ c.Abort()
+ return
+ }
+ if !user.IsAdmin {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/run_tests.sh b/run_tests.sh
index 9ffa5d1d4..b96dc7894 100755
--- a/run_tests.sh
+++ b/run_tests.sh
@@ -9,37 +9,14 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
-# Check and build frontend if needed
-echo -e "\n${YELLOW}🔍 Checking frontend build...${NC}"
-if [ ! -d "web/frontend/dist" ]; then
- echo -e "${YELLOW}Frontend not built. Building now...${NC}"
- cd web/frontend
- npm install
- npm run build
- cd ../..
- echo -e "${GREEN}✅ Frontend build complete${NC}"
-else
- echo -e "${GREEN}✅ Frontend already built${NC}"
-fi
-
# Function to run tests and capture results
run_test() {
local test_name=$1
local test_files=$2
-
+
echo -e "\n${YELLOW}🔄 Running $test_name...${NC}"
-
- # Clean up any test databases before running
- find . -maxdepth 1 -name "*_test.db" -delete 2>/dev/null || true
-
- # Run the test and capture the exit code
- go test $test_files -v
- local test_result=$?
-
- # Clean up test databases after running
- find . -maxdepth 1 -name "*_test.db" -delete 2>/dev/null || true
-
- if [ $test_result -eq 0 ]; then
+
+ if go test $test_files -v; then
echo -e "${GREEN}✅ $test_name PASSED${NC}"
return 0
else
@@ -104,8 +81,8 @@ else
fi
((total++))
-# Adapter Registration Tests (tests our model storage fix)
-if run_test "Adapter Registration Tests" "./tests/test_helpers.go ./tests/adapter_registration_test.go"; then
+# Transcription Tests (may have issues)
+if run_test "Transcription Service Tests" "./tests/test_helpers.go ./tests/transcription_service_test.go"; then
((passed++))
else
((failed++))
diff --git a/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png b/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png
new file mode 100644
index 000000000..4c925fec0
Binary files /dev/null and b/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png differ
diff --git a/tests/api_handlers_test.go b/tests/api_handlers_test.go
index b24f728e1..4a67ed8f1 100644
--- a/tests/api_handlers_test.go
+++ b/tests/api_handlers_test.go
@@ -28,7 +28,7 @@ type APIHandlerTestSuite struct {
router *gin.Engine
handler *api.Handler
taskQueue *queue.TaskQueue
- unifiedProcessor *transcription.UnifiedJobProcessor
+ whisperXService *transcription.WhisperXService
quickTranscription *transcription.QuickTranscriptionService
}
@@ -36,13 +36,13 @@ func (suite *APIHandlerTestSuite) SetupSuite() {
suite.helper = NewTestHelper(suite.T(), "api_handlers_test.db")
// Initialize services
- suite.unifiedProcessor = transcription.NewUnifiedJobProcessor()
+ suite.whisperXService = transcription.NewWhisperXService(suite.helper.Config)
var err error
- suite.quickTranscription, err = transcription.NewQuickTranscriptionService(suite.helper.Config, suite.unifiedProcessor)
+ suite.quickTranscription, err = transcription.NewQuickTranscriptionService(suite.helper.Config, suite.whisperXService)
assert.NoError(suite.T(), err)
- suite.taskQueue = queue.NewTaskQueue(1, suite.unifiedProcessor)
- suite.handler = api.NewHandler(suite.helper.Config, suite.helper.AuthService, suite.taskQueue, suite.unifiedProcessor, suite.quickTranscription)
+ suite.taskQueue = queue.NewTaskQueue(1, suite.whisperXService)
+ suite.handler = api.NewHandler(suite.helper.Config, suite.helper.AuthService, suite.taskQueue, suite.whisperXService, suite.quickTranscription)
// Set up router
suite.router = api.SetupRoutes(suite.handler, suite.helper.AuthService)
@@ -310,14 +310,11 @@ func (suite *APIHandlerTestSuite) TestGetSupportedModels() {
assert.Contains(suite.T(), response, "models")
assert.Contains(suite.T(), response, "languages")
- // Models is now a map (model_id -> capabilities), languages is still an array
- // In test environment, these may be empty since no adapters are registered
- models := response["models"].(map[string]interface{})
+ models := response["models"].([]interface{})
languages := response["languages"].([]interface{})
- // Just verify they have the correct types (may be empty in test environment)
- assert.NotNil(suite.T(), models)
- assert.NotNil(suite.T(), languages)
+ assert.Greater(suite.T(), len(models), 0)
+ assert.Greater(suite.T(), len(languages), 0)
}
// Test profile management
@@ -427,7 +424,7 @@ func (suite *APIHandlerTestSuite) TestGetQueueStats() {
assert.NoError(suite.T(), err)
assert.Contains(suite.T(), response, "queue_size")
- assert.Contains(suite.T(), response, "current_workers")
+ assert.Contains(suite.T(), response, "workers")
assert.Contains(suite.T(), response, "pending_jobs")
assert.Contains(suite.T(), response, "processing_jobs")
assert.Contains(suite.T(), response, "completed_jobs")
diff --git a/tests/queue_test.go b/tests/queue_test.go
index 200110e77..a602b5137 100644
--- a/tests/queue_test.go
+++ b/tests/queue_test.go
@@ -70,9 +70,9 @@ func (suite *QueueTestSuite) TestNewTaskQueue() {
// Test queue stats before starting
stats := tq.GetQueueStats()
- assert.Equal(suite.T(), 2, stats["current_workers"])
+ assert.Equal(suite.T(), 2, stats["workers"])
assert.Equal(suite.T(), 0, stats["queue_size"])
- assert.Equal(suite.T(), 200, stats["queue_capacity"])
+ assert.Equal(suite.T(), 100, stats["queue_capacity"])
}
// Test enqueuing jobs
@@ -111,7 +111,7 @@ func (suite *QueueTestSuite) TestJobProcessing() {
time.Sleep(100 * time.Millisecond)
// Verify the job was processed
- mockProcessor.AssertCalled(suite.T(), "ProcessJobWithProcess", mock.Anything, job.ID, mock.Anything)
+ mockProcessor.AssertCalled(suite.T(), "ProcessJob", mock.Anything, job.ID)
// Check job status in database
updatedJob, err := tq.GetJobStatus(job.ID)
@@ -141,7 +141,7 @@ func (suite *QueueTestSuite) TestJobProcessingFailure() {
time.Sleep(100 * time.Millisecond)
// Verify the job was processed
- mockProcessor.AssertCalled(suite.T(), "ProcessJobWithProcess", mock.Anything, job.ID, mock.Anything)
+ mockProcessor.AssertCalled(suite.T(), "ProcessJob", mock.Anything, job.ID)
// Check job status in database
updatedJob, err := tq.GetJobStatus(job.ID)
@@ -222,9 +222,9 @@ func (suite *QueueTestSuite) TestGetQueueStats() {
stats := tq.GetQueueStats()
- assert.Equal(suite.T(), 3, stats["current_workers"])
+ assert.Equal(suite.T(), 3, stats["workers"])
assert.Equal(suite.T(), 0, stats["queue_size"]) // No jobs in queue buffer
- assert.Equal(suite.T(), 200, stats["queue_capacity"])
+ assert.Equal(suite.T(), 100, stats["queue_capacity"])
// Note: The actual counts depend on what's in the database
assert.Contains(suite.T(), stats, "pending_jobs")
@@ -263,7 +263,7 @@ func (suite *QueueTestSuite) TestMultipleWorkers() {
// Verify all jobs were processed
for _, job := range jobs {
- mockProcessor.AssertCalled(suite.T(), "ProcessJobWithProcess", mock.Anything, job.ID, mock.Anything)
+ mockProcessor.AssertCalled(suite.T(), "ProcessJob", mock.Anything, job.ID)
}
}
@@ -300,17 +300,16 @@ func (suite *QueueTestSuite) TestQueueOverflow() {
tq := queue.NewTaskQueue(1, mockProcessor)
- // Fill up the queue (capacity is 200)
- for i := 0; i < 200; i++ {
+ // Fill up the queue (capacity is 100)
+ for i := 0; i < 100; i++ {
err := tq.EnqueueJob(fmt.Sprintf("job-%d", i))
assert.NoError(suite.T(), err)
}
- // The 201st job should fail
+ // The 101st job should fail
err := tq.EnqueueJob("overflow-job")
- if assert.Error(suite.T(), err, "Expected error for queue overflow") {
- assert.Contains(suite.T(), err.Error(), "queue is full")
- }
+ assert.Error(suite.T(), err)
+ assert.Contains(suite.T(), err.Error(), "queue is full")
}
// Test job status retrieval
diff --git a/tests/security_test.go b/tests/security_test.go
index d4563fc82..f768fec96 100644
--- a/tests/security_test.go
+++ b/tests/security_test.go
@@ -30,7 +30,7 @@ type SecurityTestSuite struct {
config *config.Config
authService *auth.AuthService
taskQueue *queue.TaskQueue
- unifiedProcessor *transcription.UnifiedJobProcessor
+ whisperXService *transcription.WhisperXService
quickTranscriptionService *transcription.QuickTranscriptionService
handler *api.Handler
}
@@ -57,14 +57,14 @@ func (suite *SecurityTestSuite) SetupSuite() {
// Initialize services
suite.authService = auth.NewAuthService(suite.config.JWTSecret)
- suite.unifiedProcessor = transcription.NewUnifiedJobProcessor()
+ suite.whisperXService = transcription.NewWhisperXService(suite.config)
var err error
- suite.quickTranscriptionService, err = transcription.NewQuickTranscriptionService(suite.config, suite.unifiedProcessor)
+ suite.quickTranscriptionService, err = transcription.NewQuickTranscriptionService(suite.config, suite.whisperXService)
if err != nil {
suite.T().Fatal("Failed to initialize quick transcription service:", err)
}
- suite.taskQueue = queue.NewTaskQueue(1, suite.unifiedProcessor)
- suite.handler = api.NewHandler(suite.config, suite.authService, suite.taskQueue, suite.unifiedProcessor, suite.quickTranscriptionService)
+ suite.taskQueue = queue.NewTaskQueue(1, suite.whisperXService)
+ suite.handler = api.NewHandler(suite.config, suite.authService, suite.taskQueue, suite.whisperXService, suite.quickTranscriptionService)
// Set up router
suite.router = api.SetupRoutes(suite.handler, suite.authService)
diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx
index 31a65371e..389b3bd9d 100644
--- a/web/frontend/src/App.tsx
+++ b/web/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { useRouter } from './contexts/RouterContext'
const Homepage = lazy(() => import('./components/Homepage').then(module => ({ default: module.Homepage })))
const AudioDetailView = lazy(() => import('./components/AudioDetailView').then(module => ({ default: module.AudioDetailView })))
const Settings = lazy(() => import('./pages/Settings').then(module => ({ default: module.Settings })))
+const Admin = lazy(() => import('./pages/Admin').then(module => ({ default: module.Admin })))
const ChatPage = lazy(() => import('./pages/ChatPage').then(module => ({ default: module.ChatPage })))
// Loading component
@@ -25,6 +26,8 @@ function App() {
) : currentRoute.path === 'chat' ? (
+ ) : currentRoute.path === 'admin' ? (
+
) : (
)}
diff --git a/web/frontend/src/components/Header.tsx b/web/frontend/src/components/Header.tsx
index 7c4b797cf..372297c1d 100644
--- a/web/frontend/src/components/Header.tsx
+++ b/web/frontend/src/components/Header.tsx
@@ -6,7 +6,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Upload, Mic, Settings, LogOut, Home, Plus, Grip, Zap, Youtube, Video, Users } from "lucide-react";
+import { Upload, Mic, Settings, LogOut, Home, Plus, Grip, Zap, Youtube, Video, Users, Shield } from "lucide-react";
import { ScriberrLogo } from "./ScriberrLogo";
import { ThemeSwitcher } from "./ThemeSwitcher";
import { AudioRecorder } from "./AudioRecorder";
@@ -14,7 +14,6 @@ import { QuickTranscriptionDialog } from "./QuickTranscriptionDialog";
import { YouTubeDownloadDialog } from "./YouTubeDownloadDialog";
import { useRouter } from "../contexts/RouterContext";
import { useAuth } from "../contexts/AuthContext";
-import { isVideoFile, isAudioFile } from "../utils/fileProcessor";
interface FileWithType {
file: File;
@@ -28,8 +27,8 @@ interface HeaderProps {
}
export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }: HeaderProps) {
- const { navigate } = useRouter();
- const { logout } = useAuth();
+ const { navigate } = useRouter();
+ const { logout, isAdmin } = useAuth();
const fileInputRef = useRef(null);
const videoFileInputRef = useRef(null);
const [isRecorderOpen, setIsRecorderOpen] = useState(false);
@@ -64,9 +63,9 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
navigate({ path: "settings" });
};
- const handleLogout = () => {
- logout();
- };
+ const handleLogout = () => {
+ logout();
+ };
const handleHomeClick = () => {
navigate({ path: "home" });
@@ -75,26 +74,12 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
const handleFileChange = (event: React.ChangeEvent) => {
const files = event.target.files;
if (files && files.length > 0) {
- const fileArray = Array.from(files);
-
- // Check for video files that were incorrectly uploaded via audio upload
- const videoFiles = fileArray.filter(file => isVideoFile(file));
- if (videoFiles.length > 0) {
- alert(`Video files detected. Please use "Upload Videos" instead of "Upload Files" to upload ${videoFiles.map(f => f.name).join(', ')}`);
- event.target.value = "";
- return;
- }
-
// Filter to only audio files
- const audioFiles = fileArray.filter(file => isAudioFile(file));
+ const audioFiles = Array.from(files).filter(file => file.type.startsWith("audio/"));
if (audioFiles.length > 0) {
onFileSelect(audioFiles.length === 1 ? audioFiles[0] : audioFiles);
// Reset the input so the same files can be selected again
event.target.value = "";
- } else {
- // No valid audio files found
- alert("No valid audio files found. Please select audio files (.mp3, .wav, .flac, .m4a, .aac, .ogg)");
- event.target.value = "";
}
}
};
@@ -236,10 +221,16 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
Home
-
-
- Settings
-
+
+
+ Settings
+
+ {isAdmin && (
+ navigate({ path: 'admin' })} className="cursor-pointer">
+
+ Admin
+
+ )}
Logout
diff --git a/web/frontend/src/components/TranscriptionConfigDialog.tsx b/web/frontend/src/components/TranscriptionConfigDialog.tsx
index 6ba2851cb..7f4ea3299 100644
--- a/web/frontend/src/components/TranscriptionConfigDialog.tsx
+++ b/web/frontend/src/components/TranscriptionConfigDialog.tsx
@@ -112,7 +112,7 @@ const PARAM_DESCRIPTIONS = {
model: "Size of the Whisper model to use. Larger models are more accurate but slower and require more memory.",
language: "Source language of the audio. Leave as auto-detect for automatic language detection.",
task: "Whether to transcribe the audio or translate it to English.",
- device: "Processing device: CPU (slower, universal), GPU (faster, requires CUDA), MPS (Apple Silicon GPU), or AUTO (automatic selection).",
+ device: "Processing device: CPU (slower, universal), GPU (faster, requires CUDA), or Auto (automatic selection).",
compute_type: "Precision type: Float16 (faster, less memory), Float32 (more accurate), Int8 (fastest, least accurate).",
batch_size: "Number of audio segments processed simultaneously. Higher values are faster but use more memory.",
diarize: "Enable speaker diarization to identify and separate different speakers in the audio.",
@@ -1057,7 +1057,7 @@ export const TranscriptionConfigDialog = memo(function TranscriptionConfigDialog
CPU
GPU (CUDA)
- GPU (MPS)
+ Auto
diff --git a/web/frontend/src/contexts/AuthContext.tsx b/web/frontend/src/contexts/AuthContext.tsx
index efe655b6c..4272f3808 100644
--- a/web/frontend/src/contexts/AuthContext.tsx
+++ b/web/frontend/src/contexts/AuthContext.tsx
@@ -2,13 +2,14 @@ import { createContext, useContext, useState, useEffect, useCallback, useMemo, u
import type { ReactNode } from "react";
interface AuthContextType {
- token: string | null;
- isAuthenticated: boolean;
- requiresRegistration: boolean;
- isInitialized: boolean;
- login: (token: string) => void;
- logout: () => void;
- getAuthHeaders: () => { Authorization?: string };
+ token: string | null;
+ isAuthenticated: boolean;
+ requiresRegistration: boolean;
+ isInitialized: boolean;
+ isAdmin: boolean;
+ login: (token: string) => void;
+ logout: () => void;
+ getAuthHeaders: () => { Authorization?: string };
}
const AuthContext = createContext(undefined);
@@ -18,9 +19,10 @@ interface AuthProviderProps {
}
export function AuthProvider({ children }: AuthProviderProps) {
- const [token, setToken] = useState(null);
- const [isInitialized, setIsInitialized] = useState(false);
- const [requiresRegistration, setRequiresRegistration] = useState(false);
+ const [token, setToken] = useState(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [requiresRegistration, setRequiresRegistration] = useState(false);
+ const [isAdmin, setIsAdmin] = useState(false);
// Use refs to avoid re-creating intervals on every render
const tokenCheckIntervalRef = useRef(null);
@@ -42,6 +44,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
// Logout function
const logout = useCallback(() => {
setToken(null);
+ setIsAdmin(false);
localStorage.removeItem("scriberr_auth_token");
// Call logout endpoint to invalidate token server-side (optional)
fetch("/api/v1/auth/logout", {
@@ -76,18 +79,26 @@ export function AuthProvider({ children }: AuthProviderProps) {
// Only check for existing token if registration is not required
if (!regEnabled) {
- const savedToken = localStorage.getItem("scriberr_auth_token");
- if (savedToken) {
- if (isTokenExpired(savedToken)) {
- // Token expired, remove it
- localStorage.removeItem("scriberr_auth_token");
- } else {
- setToken(savedToken);
- }
- }
- }
- }
- } catch (error) {
+ const savedToken = localStorage.getItem("scriberr_auth_token");
+ if (savedToken) {
+ if (isTokenExpired(savedToken)) {
+ // Token expired, remove it
+ localStorage.removeItem("scriberr_auth_token");
+ } else {
+ setToken(savedToken);
+ // Try to fetch user settings to determine admin
+ try {
+ const us = await fetch('/api/v1/user/settings', { headers: { Authorization: `Bearer ${savedToken}` } });
+ if (us.ok) {
+ const data = await us.json();
+ if (typeof data.is_admin === 'boolean') setIsAdmin(!!data.is_admin);
+ }
+ } catch {}
+ }
+ }
+ }
+ }
+ } catch (error) {
console.error("Failed to check registration status:", error);
// If we can't check status, assume no registration needed and check token
const savedToken = localStorage.getItem("scriberr_auth_token");
@@ -106,11 +117,18 @@ export function AuthProvider({ children }: AuthProviderProps) {
initializeAuth();
}, [isTokenExpired]);
- const login = useCallback((newToken: string) => {
- setToken(newToken);
- localStorage.setItem("scriberr_auth_token", newToken);
- setRequiresRegistration(false); // Clear registration requirement after successful login/registration
- }, []);
+ const login = useCallback((newToken: string) => {
+ setToken(newToken);
+ localStorage.setItem("scriberr_auth_token", newToken);
+ setRequiresRegistration(false); // Clear registration requirement after successful login/registration
+ // Fetch user settings to set admin
+ fetch('/api/v1/user/settings', { headers: { Authorization: `Bearer ${newToken}` }}).then(async (res) => {
+ if (res.ok) {
+ const data = await res.json();
+ setIsAdmin(!!data.is_admin);
+ }
+ }).catch(() => {});
+ }, []);
// Helper: attempt to refresh JWT via cookie refresh token
const tryRefresh = useCallback(async (): Promise => {
@@ -173,8 +191,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
clearInterval(tokenCheckIntervalRef.current);
}
- // Setup token expiry checking if we have a token
- if (token) {
+ // Setup token expiry checking if we have a token
+ if (token) {
const checkTokenExpiry = async () => {
if (!token) return;
@@ -208,15 +226,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
}, [token]);
// Memoize context value to prevent unnecessary re-renders
- const value = useMemo(() => ({
- token,
- isAuthenticated: !!token && isInitialized,
- requiresRegistration,
- isInitialized,
- login,
- logout,
- getAuthHeaders,
- }), [token, isInitialized, requiresRegistration, login, logout, getAuthHeaders]);
+ const value = useMemo(() => ({
+ token,
+ isAuthenticated: !!token && isInitialized,
+ requiresRegistration,
+ isInitialized,
+ isAdmin,
+ login,
+ logout,
+ getAuthHeaders,
+ }), [token, isInitialized, requiresRegistration, isAdmin, login, logout, getAuthHeaders]);
return (
diff --git a/web/frontend/src/contexts/RouterContext.tsx b/web/frontend/src/contexts/RouterContext.tsx
index 21993fbce..ddb65bb06 100644
--- a/web/frontend/src/contexts/RouterContext.tsx
+++ b/web/frontend/src/contexts/RouterContext.tsx
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState } from 'react'
export type Route = {
- path: 'home' | 'audio-detail' | 'settings' | 'chat'
+ path: 'home' | 'audio-detail' | 'settings' | 'chat' | 'admin'
params?: Record
}
@@ -35,6 +35,8 @@ export function RouterProvider({ children }: { children: React.ReactNode }) {
return { path: 'audio-detail', params: { id: audioId } }
} else if (path === '/settings') {
return { path: 'settings' }
+ } else if (path === '/admin') {
+ return { path: 'admin' }
}
return { path: 'home' }
@@ -53,6 +55,8 @@ export function RouterProvider({ children }: { children: React.ReactNode }) {
url = `/audio/${route.params.audioId}/chat`
} else if (route.path === 'settings') {
url = '/settings'
+ } else if (route.path === 'admin') {
+ url = '/admin'
}
window.history.pushState({ route }, '', url)
diff --git a/web/frontend/src/pages/Admin.tsx b/web/frontend/src/pages/Admin.tsx
new file mode 100644
index 000000000..e12718e9d
--- /dev/null
+++ b/web/frontend/src/pages/Admin.tsx
@@ -0,0 +1,472 @@
+import { useEffect, useMemo, useState, useRef } from 'react'
+import { useAuth } from '@/contexts/AuthContext'
+import { Button } from '@/components/ui/button'
+import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+
+type AdminUser = {
+ id: number
+ username: string
+ is_admin: boolean
+ created_at: string
+ updated_at: string
+}
+
+export function Admin() {
+ const { getAuthHeaders, token } = useAuth()
+
+ const [users, setUsers] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Create user form state
+ const [newUsername, setNewUsername] = useState('')
+ const [newPassword, setNewPassword] = useState('')
+ const [newConfirm, setNewConfirm] = useState('')
+ const [newIsAdmin, setNewIsAdmin] = useState(false)
+ const [creating, setCreating] = useState(false)
+
+ // Simple password reset state per user id
+ const [pwdTarget, setPwdTarget] = useState(null)
+ const [pwd1, setPwd1] = useState('')
+ const [pwd2, setPwd2] = useState('')
+
+ // Logo/branding state
+ const [logoUploading, setLogoUploading] = useState(false)
+ const [logoVersion, setLogoVersion] = useState(0)
+ const [logoError, setLogoError] = useState(null)
+ const logoInputRef = useRef(null)
+
+ const canCreate = useMemo(
+ () =>
+ newUsername.trim().length >= 3 &&
+ newPassword.length >= 6 &&
+ newPassword === newConfirm,
+ [newUsername, newPassword, newConfirm]
+ )
+
+ const fetchUsers = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/v1/admin/users/', {
+ headers: { ...getAuthHeaders() }
+ })
+ if (!res.ok) {
+ throw new Error('Failed to load users')
+ }
+ const data = await res.json()
+ setUsers(data as AdminUser[])
+ } catch (e: any) {
+ setError(e?.message || 'Failed to load users')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ if (token) {
+ fetchUsers()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [token])
+
+ const createUser = async () => {
+ if (!canCreate) return
+ setCreating(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/v1/admin/users/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getAuthHeaders()
+ },
+ body: JSON.stringify({
+ username: newUsername.trim(),
+ password: newPassword,
+ confirmPassword: newConfirm,
+ is_admin: newIsAdmin
+ })
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ throw new Error(data?.error || 'Failed to create user')
+ }
+ setNewUsername('')
+ setNewPassword('')
+ setNewConfirm('')
+ setNewIsAdmin(false)
+ await fetchUsers()
+ } catch (e: any) {
+ setError(e?.message || 'Failed to create user')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const toggleAdmin = async (u: AdminUser) => {
+ try {
+ const res = await fetch(`/api/v1/admin/users/${u.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getAuthHeaders()
+ },
+ body: JSON.stringify({ is_admin: !u.is_admin })
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error((data as any)?.error || 'Failed to update user')
+ }
+ await fetchUsers()
+ } catch (e: any) {
+ setError(e?.message || 'Failed to update user')
+ }
+ }
+
+ const deleteUser = async (u: AdminUser) => {
+ if (!confirm(`Delete user ${u.username}?`)) return
+ try {
+ const res = await fetch(`/api/v1/admin/users/${u.id}`, {
+ method: 'DELETE',
+ headers: { ...getAuthHeaders() }
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error((data as any)?.error || 'Failed to delete user')
+ }
+ await fetchUsers()
+ } catch (e: any) {
+ setError(e?.message || 'Failed to delete user')
+ }
+ }
+
+ const resetPassword = async (u: AdminUser) => {
+ if (!pwd1 || pwd1.length < 6 || pwd1 !== pwd2) {
+ setError('Passwords must match and be at least 6 chars')
+ return
+ }
+ try {
+ const res = await fetch(`/api/v1/admin/users/${u.id}/password`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getAuthHeaders()
+ },
+ body: JSON.stringify({
+ newPassword: pwd1,
+ confirmPassword: pwd2
+ })
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error((data as any)?.error || 'Failed to update password')
+ }
+ setPwdTarget(null)
+ setPwd1('')
+ setPwd2('')
+ } catch (e: any) {
+ setError(e?.message || 'Failed to update password')
+ }
+ }
+
+ const handleLogoChange = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ setLogoError(null)
+
+ if (!file.type.startsWith('image/')) {
+ setLogoError('Please select an image file (PNG).')
+ event.target.value = ''
+ return
+ }
+
+ const formData = new FormData()
+ formData.append('logo', file)
+
+ setLogoUploading(true)
+ try {
+ const res = await fetch('/api/v1/admin/logo', {
+ method: 'POST',
+ headers: {
+ ...getAuthHeaders()
+ // Do not set Content-Type, browser will set multipart boundary
+ },
+ body: formData
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error((data as any)?.error || 'Failed to upload logo')
+ }
+ setLogoVersion(v => v + 1)
+ } catch (e: any) {
+ setLogoError(e?.message || 'Failed to upload logo')
+ } finally {
+ setLogoUploading(false)
+ event.target.value = ''
+ }
+ }
+
+ const handleLogoReset = async () => {
+ setLogoError(null)
+ setLogoUploading(true)
+ try {
+ const res = await fetch('/api/v1/admin/logo', {
+ method: 'DELETE',
+ headers: {
+ ...getAuthHeaders()
+ }
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error((data as any)?.error || 'Failed to reset logo')
+ }
+ setLogoVersion(v => v + 1)
+ } catch (e: any) {
+ setLogoError(e?.message || 'Failed to reset logo')
+ } finally {
+ setLogoUploading(false)
+ }
+ }
+
+ return (
+
+
+
+
+ User Management
+
+ Add, promote, or remove users for this Scriberr instance
+
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+
+
+
+ Users
+
+
+ {loading ? (
+ Loading users…
+ ) : (
+
+ )}
+
+
+
+
+
+ Branding
+
+ Upload a custom logo for this Scriberr instance.
+
+
+
+ {logoError && (
+ {logoError}
+ )}
+
+
+
+ Current logo
+
+
+
+
+
+
+
+ Change logo (PNG)
+
+
+
+ logoInputRef.current?.click()}
+ disabled={logoUploading}
+ className="border-blue-200 text-blue-700 hover:bg-blue-50 hover:text-blue-800"
+ >
+ Change logo
+
+
+ Reset to default
+
+
+
+ Changes are applied immediately after upload.
+
+
+
+
+
+
+
+ )
+}
+
+export default Admin
diff --git a/web/landing/dist/assets/index-CTEra42u.js b/web/landing/dist/assets/index-CTEra42u.js
index ac0e1a091..bbce048e1 100644
--- a/web/landing/dist/assets/index-CTEra42u.js
+++ b/web/landing/dist/assets/index-CTEra42u.js
@@ -1 +1 @@
-import{j as e,r as i,G as n,c as o,R as l}from"./styles-DGJLjUaE.js";import{W as c}from"./Window-BJy5jSY4.js";function a({className:s=""}){return e.jsx("img",{src:"/scriberr-logo.png",alt:"Scriberr",className:`w-auto select-none ${s}`})}function d(){const[s,r]=i.useState(!1);return e.jsxs("header",{className:"sticky top-0 z-40 bg-white/80 backdrop-blur shadow-soft",children:[e.jsxs("div",{className:"container-narrow py-4 flex items-center justify-between",children:[e.jsx("a",{href:"#",className:"flex items-center gap-3",children:e.jsx(a,{className:"h-8 sm:h-10"})}),e.jsxs("nav",{className:"hidden md:flex items-center gap-6 text-sm text-gray-600",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",children:"API"})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("button",{className:"md:hidden inline-flex items-center justify-center rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-gray-700 hover:bg-gray-50","aria-label":"Menu",onClick:()=>r(t=>!t),children:e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:"size-5",children:e.jsx("path",{d:"M4 7h16M4 12h16M4 17h16"})})}),e.jsx(n,{})]})]}),s&&e.jsx("div",{className:"md:hidden border-t border-gray-200 bg-white",children:e.jsxs("div",{className:"container-narrow py-3 flex flex-col gap-2 text-sm text-gray-700",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"API"})]})})]})}function m(){return e.jsx("section",{className:"relative",children:e.jsxs("div",{className:"container-narrow section text-center",children:[e.jsx("span",{className:"eyebrow mb-3 inline-block",children:"Self-hosted offline audio transcription"}),e.jsx("h1",{className:"headline flex justify-center mb-4",children:e.jsx(a,{className:"h-16 sm:h-20 md:h-24 lg:h-28 xl:h-32"})}),e.jsx("p",{className:"subcopy mt-3 mx-auto max-w-2xl",children:"Transcribe audio locally into text - Summarize and Chat with your audio. No GPU Required."}),e.jsxs("div",{className:"mt-8 flex items-center justify-center gap-3",children:[e.jsx("a",{href:"/docs/installation.html",className:"button-primary",children:"Get Started"}),e.jsx("a",{href:"#features",className:"button-ghost",children:"Learn more"})]}),e.jsx("div",{className:"mt-6 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",onClick:s=>{s.preventDefault(),window.open("https://ko-fi.com/H2H41KQZA3","_blank","noopener,noreferrer")},children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})}),e.jsx("div",{className:"mt-12 max-w-5xl mx-auto",children:e.jsxs("div",{className:"rounded-3xl shadow-soft overflow-hidden bg-white hover-lift",children:[e.jsxs("div",{className:"flex items-center gap-2 px-3 py-2 bg-gray-100",children:[e.jsx("span",{className:"size-3 rounded-full bg-red-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-yellow-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-green-400/80"})]}),e.jsx("img",{src:"/screenshots/scriberr-homepage.png",alt:"Scriberr homepage",className:"w-full object-cover"})]})}),e.jsxs("div",{className:"mt-6 flex items-center justify-center gap-4 text-xs text-gray-500",children:[e.jsx("span",{children:"Privacy preserving"}),e.jsx("span",{children:"Mobile ready"}),e.jsx("span",{children:"Fast and responsive"})]})]})})}const h=[{title:"Precise transcription",desc:"Tweak advanced transcription parameters to get the best quality output"},{title:"Built-in recorder",desc:"Capture audio directly in-app and transcribe instantly."},{title:"Summarize & chat",desc:"Extract key points or chat over transcripts using LLMs."},{title:"Lightweight notes",desc:"Highlight, annotate, and tag important moments as you listen/read."},{title:"Speaker diarization",desc:"Identify and label distinct speakers in your audio."},{title:"Profiles & presets",desc:"Save configurations for different audio scenarios."}];function x({name:s}){const r="size-4";switch(s){case"Precise transcription":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M12 3v10a3 3 0 1 1-6 0V8"}),e.jsx("path",{d:"M19 10v3a7 7 0 0 1-14 0"})]});case"Built-in recorder":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("rect",{x:"9",y:"9",width:"6",height:"6",rx:"3"}),e.jsx("circle",{cx:"12",cy:"12",r:"9"})]});case"Summarize & chat":return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M21 15a4 4 0 0 1-4 4H7l-4 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"})});case"Lightweight notes":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M9 7h6M9 12h6M9 17h6"}),e.jsx("rect",{x:"5",y:"3",width:"14",height:"18",rx:"2"})]});case"Speaker diarization":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("circle",{cx:"7",cy:"8",r:"3"}),e.jsx("path",{d:"M2 19a5 5 0 0 1 10 0"}),e.jsx("circle",{cx:"17",cy:"10",r:"2.5"}),e.jsx("path",{d:"M13 19c.5-2.5 2.5-4 5-4"})]});case"Profiles & presets":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M6 3v12"}),e.jsx("path",{d:"M12 3v18"}),e.jsx("path",{d:"M18 3v8"})]});default:return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M12 6v12m6-6H6"})})}}function p(){return e.jsxs("section",{id:"features",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Capabilities"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"Key Features"}),e.jsx("p",{className:"subcopy mt-2",children:"Curated set of features to manage and work with transcripts."})]}),e.jsx("div",{className:"grid sm:grid-cols-2 lg:grid-cols-3 gap-6",children:h.map(s=>e.jsxs("article",{className:"rounded-2xl p-6 bg-gray-50 shadow-soft hover-lift",children:[e.jsxs("div",{className:"mb-3 flex items-center gap-3",children:[e.jsx("span",{className:"inline-flex items-center justify-center size-9 rounded-xl bg-gray-100 text-gray-600 shadow-subtle",children:e.jsx(x,{name:s.title})}),e.jsx("h3",{className:"font-medium text-base md:text-lg text-gray-900",children:s.title})]}),e.jsx("p",{className:"subcopy",children:s.desc})]},s.title))})]})}const u=[{title:"Transcript view",desc:"Minimal reading experience with timestamps with playback follow along that highlights currently playing word.",img:"scriberr-transcript page.png",bullets:["Jump from audio timestamp to corresponding word","Jump from text to corresponding audio segment","View transcript as paragraph or as timestamped/speaker segments"]},{title:"Record right in Scriberr",desc:"Capture audio directly in-app and transcribe",img:"scriberr-inbuilt audio recorder for directly recording and transcribing audio within the app.png",bullets:[]},{title:"Summaries at a glance",desc:"Turn long recordings into brief, actionable summaries you can scan in seconds.",img:"scriberr-summarize transcripts.png",bullets:["Write your own custom prompts for summarization","Supports both Ollama/OpenAI (needs API Key) LLM providers","Save multiple summarization presets to reuse quickly"]},{title:"Annotate transcripts",desc:"Highlight important moments, jot down concise notes, and keep insights attached to the exact timestamp.",img:"scriberr-annotate transcript and take notes.png",bullets:["Highlight text to add a note","Timestamped notes allow jumping to exact segment"]},{title:"Advanced controls",desc:"Fine-tune model settings, language, and diarization for optimal results",img:"scriberr-fine tune advanced transcription parameters as you see fit to improve transcription quality.png",bullets:["Language hints and temperature","Diarization and VAD options","Profiles for repeatable setups"]},{title:"Bring your own providers",desc:"Use OpenAI or local models via Ollama for summaries and chat — your keys, your choice.",img:"scriberr-Ollama openAI llm providers for chat and summarization.png",bullets:["Works with OpenAI or Ollama"]},{title:"Export transcripts",desc:"Download your transcripts in multiple formats",img:"scriberr-download-transcript-in-different-formats.png",bullets:["Export to TXT, Markdown, JSON","Keep timestamps and speaker info"]},{title:"Chat with your transcript",desc:"Ask questions about your recording, extract insights, and clarify details without scrubbing through audio.",img:"scriberr-chat-with-your-recording-transcript.png",bullets:["Works with OpenAI or Ollama"]},{title:"API keys and REST API",desc:"Manage API keys and use the full REST API to build automations or integrate Scriberr into your own applications.",img:"scriberr-api-key-management.png",bullets:["Secure API key management","Endpoints for transcription, chat, notes and more"]},{title:"Transcribe YouTube videos",desc:"Paste a YouTube link to transcribe the audio directly — no downloads required.",img:"scriberr-youtube-video.png",bullets:["Grab insights from talks, podcasts and lectures","Works with summarization and notes"]}];function g(){return e.jsxs("section",{id:"details",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Details"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"A closer look"})]}),e.jsx("div",{className:"space-y-16 md:space-y-24",children:u.map((s,r)=>e.jsxs("div",{className:"grid md:grid-cols-2 gap-8 md:gap-10 items-center",children:[e.jsx("div",{className:r%2===0?"order-1 md:order-1":"order-2 md:order-2",children:e.jsxs("div",{children:[e.jsx("h3",{className:"text-xl md:text-2xl font-semibold text-gray-900",children:s.title}),e.jsx("p",{className:"subcopy mt-2",children:s.desc}),s.bullets&&e.jsx("ul",{className:"mt-4 space-y-2 text-sm text-gray-600 list-disc list-inside",children:s.bullets.map(t=>e.jsx("li",{children:t},t))})]})}),e.jsx("div",{className:r%2===0?"order-2 md:order-2":"order-1 md:order-1",children:e.jsx(c,{src:`/screenshots/${s.img}`,alt:s.title})})]},s.title))})]})}function f(){return e.jsx("footer",{className:"mt-8 bg-gray-50",children:e.jsxs("div",{className:"container-narrow py-10 text-center text-sm text-gray-700",children:[e.jsxs("p",{children:["If you like Scriberr, consider giving the project a star on"," ",e.jsx("a",{href:"https://github.com/rishikanthc/scriberr",target:"_blank",rel:"noreferrer",className:"text-blue-600 hover:text-blue-700 underline-offset-2 hover:underline",children:"GitHub"}),"."]}),e.jsx("div",{className:"mt-4 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})})]})})}function j(){return e.jsxs("div",{className:"min-h-screen flex flex-col",children:[e.jsx(d,{}),e.jsxs("main",{className:"flex-1",children:[e.jsx(m,{}),e.jsx(p,{}),e.jsx(g,{})]}),e.jsx(f,{})]})}const b=o(document.getElementById("root"));b.render(e.jsx(l.StrictMode,{children:e.jsx(j,{})}));
+import{j as e,r as i,G as n,c as o,R as l}from"./styles-DGJLjUaE.js";import{W as c}from"./Window-BJy5jSY4.js";function a({className:s=""}){return e.jsx("img",{src:"/scriberr-logo.png",alt:"Scriberr",className:`w-auto select-none ${s}`})}function d(){const[s,r]=i.useState(!1);return e.jsxs("header",{className:"sticky top-0 z-40 bg-white/80 backdrop-blur shadow-soft",children:[e.jsxs("div",{className:"container-narrow py-4 flex items-center justify-between",children:[e.jsx("a",{href:"#",className:"flex items-center gap-3",children:e.jsx(a,{className:"h-8 sm:h-10"})}),e.jsxs("nav",{className:"hidden md:flex items-center gap-6 text-sm text-gray-600",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",children:"API"})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("button",{className:"md:hidden inline-flex items-center justify-center rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-gray-700 hover:bg-gray-50","aria-label":"Menu",onClick:()=>r(t=>!t),children:e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:"size-5",children:e.jsx("path",{d:"M4 7h16M4 12h16M4 17h16"})})}),e.jsx(n,{})]})]}),s&&e.jsx("div",{className:"md:hidden border-t border-gray-200 bg-white",children:e.jsxs("div",{className:"container-narrow py-3 flex flex-col gap-2 text-sm text-gray-700",children:[e.jsx("a",{href:"/docs/intro.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Docs"}),e.jsx("a",{href:"/changelog.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"Changelog"}),e.jsx("a",{href:"/api.html",className:"hover:text-gray-900",onClick:()=>r(!1),children:"API"})]})})]})}function m(){return e.jsx("section",{className:"relative",children:e.jsxs("div",{className:"container-narrow section text-center",children:[e.jsx("span",{className:"eyebrow mb-3 inline-block",children:"Self-hosted offline audio transcription"}),e.jsx("h1",{className:"headline flex justify-center mb-4",children:e.jsx(a,{className:"h-16 sm:h-20 md:h-24 lg:h-28 xl:h-32"})}),e.jsx("p",{className:"subcopy mt-3 mx-auto max-w-2xl",children:"Transcribe audio locally into text - Summarize and Chat with your audio. No GPU Required."}),e.jsxs("div",{className:"mt-8 flex items-center justify-center gap-3",children:[e.jsx("a",{href:"/docs/installation.html",className:"button-primary",children:"Get Started"}),e.jsx("a",{href:"#features",className:"button-ghost",children:"Learn more"})]}),e.jsx("div",{className:"mt-6 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",onClick:s=>{s.preventDefault(),window.open("https://ko-fi.com/H2H41KQZA3","_blank","noopener,noreferrer")},children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})}),e.jsx("div",{className:"mt-12 max-w-5xl mx-auto",children:e.jsxs("div",{className:"rounded-3xl shadow-soft overflow-hidden bg-white hover-lift",children:[e.jsxs("div",{className:"flex items-center gap-2 px-3 py-2 bg-gray-100",children:[e.jsx("span",{className:"size-3 rounded-full bg-red-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-yellow-400/80"}),e.jsx("span",{className:"size-3 rounded-full bg-green-400/80"})]}),e.jsx("img",{src:"/screenshots/scriberr-homepage.png",alt:"Scriberr homepage",className:"w-full object-cover"})]})}),e.jsxs("div",{className:"mt-6 flex items-center justify-center gap-4 text-xs text-gray-500",children:[e.jsx("span",{children:"Privacy preserving"}),e.jsx("span",{children:"Mobile ready"}),e.jsx("span",{children:"Fast and responsive"})]})]})})}const h=[{title:"Precise transcription",desc:"Tweak advanced transcription parameters to get the best quality output"},{title:"Built-in recorder",desc:"Capture audio directly in-app and transcribe instantly."},{title:"Summarize & chat",desc:"Extract key points or chat over transcripts using LLMs."},{title:"Lightweight notes",desc:"Highlight, annotate, and tag important moments as you listen/read."},{title:"Speaker diarization",desc:"Identify and label distinct speakers in your audio."},{title:"Profiles & presets",desc:"Save configurations for different audio scenarios."}];function x({name:s}){const r="size-4";switch(s){case"Precise transcription":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M12 3v10a3 3 0 1 1-6 0V8"}),e.jsx("path",{d:"M19 10v3a7 7 0 0 1-14 0"})]});case"Built-in recorder":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("rect",{x:"9",y:"9",width:"6",height:"6",rx:"3"}),e.jsx("circle",{cx:"12",cy:"12",r:"9"})]});case"Summarize & chat":return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M21 15a4 4 0 0 1-4 4H7l-4 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"})});case"Lightweight notes":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M9 7h6M9 12h6M9 17h6"}),e.jsx("rect",{x:"5",y:"3",width:"14",height:"18",rx:"2"})]});case"Speaker diarization":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("circle",{cx:"7",cy:"8",r:"3"}),e.jsx("path",{d:"M2 19a5 5 0 0 1 10 0"}),e.jsx("circle",{cx:"17",cy:"10",r:"2.5"}),e.jsx("path",{d:"M13 19c.5-2.5 2.5-4 5-4"})]});case"Profiles & presets":return e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:[e.jsx("path",{d:"M6 3v12"}),e.jsx("path",{d:"M12 3v18"}),e.jsx("path",{d:"M18 3v8"})]});default:return e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",className:r,children:e.jsx("path",{d:"M12 6v12m6-6H6"})})}}function p(){return e.jsxs("section",{id:"features",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Capabilities"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"Key Features"}),e.jsx("p",{className:"subcopy mt-2",children:"Curated set of features to manage and work with transcripts."})]}),e.jsx("div",{className:"grid sm:grid-cols-2 lg:grid-cols-3 gap-6",children:h.map(s=>e.jsxs("article",{className:"rounded-2xl p-6 bg-gray-50 shadow-soft hover-lift",children:[e.jsxs("div",{className:"mb-3 flex items-center gap-3",children:[e.jsx("span",{className:"inline-flex items-center justify-center size-9 rounded-xl bg-gray-100 text-gray-600 shadow-subtle",children:e.jsx(x,{name:s.title})}),e.jsx("h3",{className:"font-medium text-base md:text-lg text-gray-900",children:s.title})]}),e.jsx("p",{className:"subcopy",children:s.desc})]},s.title))})]})}const u=[{title:"Transcript view",desc:"Minimal reading experience with timestamps with playback follow along that highlights currently playing word.",img:"scriberr-transcript page.png",bullets:["Jump from audio timestamp to corresponding word","Jump from text to corresponding audio segment","View transcript as paragraph or as timestamped/speaker segments"]},{title:"Record right in Scriberr",desc:"Capture audio directly in-app and transcribe",img:"scriberr-inbuilt audio recorder for directly recording and transcribing audio within the app.png",bullets:[]},{title:"Summaries at a glance",desc:"Turn long recordings into brief, actionable summaries you can scan in seconds.",img:"scriberr-summarize transcripts.png",bullets:["Write your own custom prompts for summarization","Supports both Ollama/OpenAI (needs API Key) LLM providers","Save multiple summarization presets to reuse quickly"]},{title:"Annotate transcripts",desc:"Highlight important moments, jot down concise notes, and keep insights attached to the exact timestamp.",img:"scriberr-annotate transcript and take notes.png",bullets:["Highlight text to add a note","Timestamped notes allow jumping to exact segment"]},{title:"Advanced controls",desc:"Fine-tune model settings, language, and diarization for optimal results",img:"scriberr-fine tune advanced transcription parameters as you see fit to improve transcription quality.png",bullets:["Language hints and temperature","Diarization and VAD options","Profiles for repeatable setups"]},{title:"Bring your own providers",desc:"Use OpenAI or local models via Ollama for summaries and chat — your keys, your choice.",img:"scriberr-Ollama:openAI llm providers for chat and summarization.png",bullets:["Works with OpenAI or Ollama"]},{title:"Export transcripts",desc:"Download your transcripts in multiple formats",img:"scriberr-download-transcript-in-different-formats.png",bullets:["Export to TXT, Markdown, JSON","Keep timestamps and speaker info"]},{title:"Chat with your transcript",desc:"Ask questions about your recording, extract insights, and clarify details without scrubbing through audio.",img:"scriberr-chat-with-your-recording-transcript.png",bullets:["Works with OpenAI or Ollama"]},{title:"API keys and REST API",desc:"Manage API keys and use the full REST API to build automations or integrate Scriberr into your own applications.",img:"scriberr-api-key-management.png",bullets:["Secure API key management","Endpoints for transcription, chat, notes and more"]},{title:"Transcribe YouTube videos",desc:"Paste a YouTube link to transcribe the audio directly — no downloads required.",img:"scriberr-youtube-video.png",bullets:["Grab insights from talks, podcasts and lectures","Works with summarization and notes"]}];function g(){return e.jsxs("section",{id:"details",className:"container-narrow section",children:[e.jsxs("div",{className:"text-center mb-12",children:[e.jsx("span",{className:"eyebrow",children:"Details"}),e.jsx("h2",{className:"text-2xl md:text-3xl font-semibold mt-2",children:"A closer look"})]}),e.jsx("div",{className:"space-y-16 md:space-y-24",children:u.map((s,r)=>e.jsxs("div",{className:"grid md:grid-cols-2 gap-8 md:gap-10 items-center",children:[e.jsx("div",{className:r%2===0?"order-1 md:order-1":"order-2 md:order-2",children:e.jsxs("div",{children:[e.jsx("h3",{className:"text-xl md:text-2xl font-semibold text-gray-900",children:s.title}),e.jsx("p",{className:"subcopy mt-2",children:s.desc}),s.bullets&&e.jsx("ul",{className:"mt-4 space-y-2 text-sm text-gray-600 list-disc list-inside",children:s.bullets.map(t=>e.jsx("li",{children:t},t))})]})}),e.jsx("div",{className:r%2===0?"order-2 md:order-2":"order-1 md:order-1",children:e.jsx(c,{src:`/screenshots/${s.img}`,alt:s.title})})]},s.title))})]})}function f(){return e.jsx("footer",{className:"mt-8 bg-gray-50",children:e.jsxs("div",{className:"container-narrow py-10 text-center text-sm text-gray-700",children:[e.jsxs("p",{children:["If you like Scriberr, consider giving the project a star on"," ",e.jsx("a",{href:"https://github.com/rishikanthc/scriberr",target:"_blank",rel:"noreferrer",className:"text-blue-600 hover:text-blue-700 underline-offset-2 hover:underline",children:"GitHub"}),"."]}),e.jsx("div",{className:"mt-4 flex justify-center",children:e.jsx("a",{href:"https://ko-fi.com/H2H41KQZA3",target:"_blank",rel:"noopener noreferrer",children:e.jsx("img",{height:"36",style:{border:"0px",height:"36px"},src:"https://storage.ko-fi.com/cdn/kofi6.png?v=6",alt:"Buy Me a Coffee at ko-fi.com"})})})]})})}function j(){return e.jsxs("div",{className:"min-h-screen flex flex-col",children:[e.jsx(d,{}),e.jsxs("main",{className:"flex-1",children:[e.jsx(m,{}),e.jsx(p,{}),e.jsx(g,{})]}),e.jsx(f,{})]})}const b=o(document.getElementById("root"));b.render(e.jsx(l.StrictMode,{children:e.jsx(j,{})}));
diff --git a/web/landing/dist/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png b/web/landing/dist/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png
new file mode 100644
index 000000000..4c925fec0
Binary files /dev/null and b/web/landing/dist/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png differ
diff --git a/web/landing/public/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png b/web/landing/public/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png
new file mode 100644
index 000000000..4c925fec0
Binary files /dev/null and b/web/landing/public/screenshots/scriberr-Ollama_openAI llm providers for chat and summarization.png differ
diff --git a/web/landing/src/components/Alternating.tsx b/web/landing/src/components/Alternating.tsx
index 8d2b34878..b0870781d 100644
--- a/web/landing/src/components/Alternating.tsx
+++ b/web/landing/src/components/Alternating.tsx
@@ -56,7 +56,7 @@ const items: Item[] = [
{
title: "Bring your own providers",
desc: "Use OpenAI or local models via Ollama for summaries and chat — your keys, your choice.",
- img: "scriberr-Ollama openAI llm providers for chat and summarization.png",
+ img: "scriberr-Ollama:openAI llm providers for chat and summarization.png",
bullets: ["Works with OpenAI or Ollama"],
},
{