Skip to content

Commit 3d3931b

Browse files
committed
prepares for package release
1 parent dc41280 commit 3d3931b

File tree

8 files changed

+397
-1
lines changed

8 files changed

+397
-1
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ AGENTS.md
3838
.env.example
3939
scriberr-data
4040
whisperx-env
41-
internal
41+
42+
internal/web

internal/dropzone/dropzone.go

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
package dropzone
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
12+
"scriberr/internal/config"
13+
"scriberr/internal/database"
14+
"scriberr/internal/models"
15+
16+
"github.com/fsnotify/fsnotify"
17+
"github.com/google/uuid"
18+
)
19+
20+
// TaskQueue interface for enqueueing transcription jobs
21+
type TaskQueue interface {
22+
EnqueueJob(jobID string) error
23+
}
24+
25+
// Service manages the dropzone file monitoring
26+
type Service struct {
27+
config *config.Config
28+
watcher *fsnotify.Watcher
29+
dropzonePath string
30+
taskQueue TaskQueue
31+
}
32+
33+
// NewService creates a new dropzone service
34+
func NewService(cfg *config.Config, taskQueue TaskQueue) *Service {
35+
return &Service{
36+
config: cfg,
37+
taskQueue: taskQueue,
38+
dropzonePath: filepath.Join("data", "dropzone"),
39+
}
40+
}
41+
42+
// Start initializes the dropzone directory and starts file monitoring
43+
func (s *Service) Start() error {
44+
log.Printf("Starting dropzone service...")
45+
46+
// Create dropzone directory if it doesn't exist
47+
if err := os.MkdirAll(s.dropzonePath, 0755); err != nil {
48+
return fmt.Errorf("failed to create dropzone directory: %v", err)
49+
}
50+
51+
log.Printf("Dropzone directory created/verified at: %s", s.dropzonePath)
52+
53+
// Initialize file watcher
54+
watcher, err := fsnotify.NewWatcher()
55+
if err != nil {
56+
return fmt.Errorf("failed to create file watcher: %v", err)
57+
}
58+
s.watcher = watcher
59+
60+
// Add dropzone directory and all subdirectories to watcher recursively
61+
if err := s.addDirectoryRecursively(s.dropzonePath); err != nil {
62+
s.watcher.Close()
63+
return fmt.Errorf("failed to add directories to watcher: %v", err)
64+
}
65+
66+
// Process existing files recursively on startup
67+
if err := s.processExistingFiles(); err != nil {
68+
log.Printf("Warning: failed to process some existing files: %v", err)
69+
}
70+
71+
// Start monitoring in a goroutine
72+
go s.watchFiles()
73+
74+
log.Printf("Dropzone service started, monitoring recursively: %s", s.dropzonePath)
75+
return nil
76+
}
77+
78+
// Stop stops the dropzone service
79+
func (s *Service) Stop() error {
80+
if s.watcher != nil {
81+
log.Printf("Stopping dropzone service...")
82+
return s.watcher.Close()
83+
}
84+
return nil
85+
}
86+
87+
// addDirectoryRecursively adds a directory and all its subdirectories to the watcher
88+
func (s *Service) addDirectoryRecursively(root string) error {
89+
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
90+
if err != nil {
91+
log.Printf("Warning: error accessing path %s: %v", path, err)
92+
return nil // Continue walking despite errors
93+
}
94+
95+
// Only add directories to the watcher
96+
if info.IsDir() {
97+
if err := s.watcher.Add(path); err != nil {
98+
log.Printf("Warning: failed to watch directory %s: %v", path, err)
99+
return nil // Continue despite individual directory failures
100+
}
101+
log.Printf("Added directory to watcher: %s", path)
102+
}
103+
104+
return nil
105+
})
106+
}
107+
108+
// processExistingFiles processes all existing audio files in the dropzone on startup
109+
func (s *Service) processExistingFiles() error {
110+
return filepath.Walk(s.dropzonePath, func(path string, info os.FileInfo, err error) error {
111+
if err != nil {
112+
log.Printf("Warning: error accessing path %s: %v", path, err)
113+
return nil // Continue walking despite errors
114+
}
115+
116+
// Only process files, not directories
117+
if !info.IsDir() {
118+
filename := filepath.Base(path)
119+
if s.isAudioFile(filename) {
120+
log.Printf("Processing existing audio file: %s", path)
121+
s.processFile(path)
122+
}
123+
}
124+
125+
return nil
126+
})
127+
}
128+
129+
// watchFiles monitors the dropzone directory for new files
130+
func (s *Service) watchFiles() {
131+
for {
132+
select {
133+
case event, ok := <-s.watcher.Events:
134+
if !ok {
135+
return
136+
}
137+
138+
// Handle creation events for both files and directories
139+
if event.Op&fsnotify.Create == fsnotify.Create {
140+
// Check if the created item is a directory
141+
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
142+
log.Printf("Detected new directory in dropzone: %s", event.Name)
143+
// Add the new directory to the watcher recursively
144+
if err := s.addDirectoryRecursively(event.Name); err != nil {
145+
log.Printf("Failed to watch new directory %s: %v", event.Name, err)
146+
}
147+
} else {
148+
log.Printf("Detected new file in dropzone: %s", event.Name)
149+
s.processFile(event.Name)
150+
}
151+
}
152+
153+
case err, ok := <-s.watcher.Errors:
154+
if !ok {
155+
return
156+
}
157+
log.Printf("Dropzone watcher error: %v", err)
158+
}
159+
}
160+
}
161+
162+
// isAudioFile checks if the file is a valid audio file based on extension
163+
func (s *Service) isAudioFile(filename string) bool {
164+
ext := strings.ToLower(filepath.Ext(filename))
165+
audioExtensions := []string{
166+
".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg",
167+
".wma", ".mp4", ".avi", ".mov", ".mkv", ".webm",
168+
}
169+
170+
for _, validExt := range audioExtensions {
171+
if ext == validExt {
172+
return true
173+
}
174+
}
175+
return false
176+
}
177+
178+
// processFile handles a newly detected file in the dropzone
179+
func (s *Service) processFile(filePath string) {
180+
// Small delay to ensure file is fully written
181+
time.Sleep(500 * time.Millisecond)
182+
183+
filename := filepath.Base(filePath)
184+
185+
// Check if it's an audio file
186+
if !s.isAudioFile(filename) {
187+
log.Printf("Skipping non-audio file: %s", filename)
188+
return
189+
}
190+
191+
// Check if file exists and is accessible
192+
fileInfo, err := os.Stat(filePath)
193+
if err != nil {
194+
log.Printf("Error accessing file %s: %v", filePath, err)
195+
return
196+
}
197+
198+
// Skip if it's a directory
199+
if fileInfo.IsDir() {
200+
return
201+
}
202+
203+
log.Printf("Processing audio file: %s", filename)
204+
205+
// Upload the file using the same logic as the API handler
206+
if err := s.uploadFile(filePath, filename); err != nil {
207+
log.Printf("Failed to upload file %s: %v", filename, err)
208+
return
209+
}
210+
211+
// Delete the original file from dropzone after successful upload
212+
if err := os.Remove(filePath); err != nil {
213+
log.Printf("Warning: Failed to delete file from dropzone %s: %v", filePath, err)
214+
} else {
215+
log.Printf("Successfully processed and removed file: %s", filename)
216+
}
217+
}
218+
219+
// uploadFile uploads the file using the existing pipeline logic
220+
func (s *Service) uploadFile(sourcePath, originalFilename string) error {
221+
// Create upload directory
222+
uploadDir := s.config.UploadDir
223+
if err := os.MkdirAll(uploadDir, 0755); err != nil {
224+
return fmt.Errorf("failed to create upload directory: %v", err)
225+
}
226+
227+
// Generate unique filename
228+
jobID := uuid.New().String()
229+
ext := filepath.Ext(originalFilename)
230+
filename := fmt.Sprintf("%s%s", jobID, ext)
231+
destPath := filepath.Join(uploadDir, filename)
232+
233+
// Copy file from dropzone to upload directory
234+
if err := s.copyFile(sourcePath, destPath); err != nil {
235+
return fmt.Errorf("failed to copy file: %v", err)
236+
}
237+
238+
// Create job record with "uploaded" status
239+
job := models.TranscriptionJob{
240+
ID: jobID,
241+
AudioPath: destPath,
242+
Status: models.StatusUploaded,
243+
Title: &originalFilename, // Use original filename as title
244+
}
245+
246+
// Save to database
247+
if err := database.DB.Create(&job).Error; err != nil {
248+
os.Remove(destPath) // Clean up file on database error
249+
return fmt.Errorf("failed to create job record: %v", err)
250+
}
251+
252+
// Check if auto-transcription is enabled
253+
if s.isAutoTranscriptionEnabled() {
254+
log.Printf("Auto-transcription enabled, enqueueing job %s", jobID)
255+
256+
// Update job status to pending before enqueueing
257+
if err := database.DB.Model(&job).Update("status", models.StatusPending).Error; err != nil {
258+
log.Printf("Warning: Failed to update job status to pending: %v", err)
259+
}
260+
261+
// Enqueue the job for transcription
262+
if err := s.taskQueue.EnqueueJob(jobID); err != nil {
263+
log.Printf("Failed to enqueue job %s for transcription: %v", jobID, err)
264+
} else {
265+
log.Printf("Job %s enqueued for auto-transcription", jobID)
266+
}
267+
}
268+
269+
log.Printf("Successfully uploaded file %s as job %s", originalFilename, jobID)
270+
return nil
271+
}
272+
273+
// isAutoTranscriptionEnabled checks if auto-transcription is enabled for any user
274+
func (s *Service) isAutoTranscriptionEnabled() bool {
275+
var count int64
276+
277+
// Check if there are any users with auto-transcription enabled
278+
err := database.DB.Model(&models.User{}).
279+
Where("auto_transcription_enabled = ?", true).
280+
Count(&count).Error
281+
282+
if err != nil {
283+
log.Printf("Error checking auto-transcription settings: %v", err)
284+
return false
285+
}
286+
287+
return count > 0
288+
}
289+
290+
// copyFile copies a file from source to destination
291+
func (s *Service) copyFile(src, dst string) error {
292+
sourceFile, err := os.Open(src)
293+
if err != nil {
294+
return err
295+
}
296+
defer sourceFile.Close()
297+
298+
destFile, err := os.Create(dst)
299+
if err != nil {
300+
return err
301+
}
302+
defer destFile.Close()
303+
304+
_, err = io.Copy(destFile, sourceFile)
305+
if err != nil {
306+
return err
307+
}
308+
309+
return destFile.Sync()
310+
}

internal/queue/proc_kill_darwin.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build darwin
2+
// +build darwin
3+
4+
package queue
5+
6+
import (
7+
"os"
8+
"syscall"
9+
)
10+
11+
// killProcessTree sends SIGKILL to the entire process group on macOS.
12+
func killProcessTree(p *os.Process) error {
13+
return syscall.Kill(-p.Pid, syscall.SIGKILL)
14+
}
15+

internal/queue/proc_kill_linux.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build linux
2+
// +build linux
3+
4+
package queue
5+
6+
import (
7+
"os"
8+
"syscall"
9+
)
10+
11+
// killProcessTree sends SIGKILL to the entire process group on Linux.
12+
func killProcessTree(p *os.Process) error {
13+
return syscall.Kill(-p.Pid, syscall.SIGKILL)
14+
}
15+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build windows
2+
// +build windows
3+
4+
package queue
5+
6+
import "os"
7+
8+
// killProcessTree attempts to kill the process. Windows lacks a simple
9+
// process group SIGKILL equivalent; callers may need a more robust tree kill.
10+
func killProcessTree(p *os.Process) error {
11+
return p.Kill()
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build darwin
2+
// +build darwin
3+
4+
package transcription
5+
6+
import (
7+
"os/exec"
8+
"syscall"
9+
)
10+
11+
// configureCmdSysProcAttr sets process group on macOS so we can kill children.
12+
func configureCmdSysProcAttr(cmd *exec.Cmd) {
13+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
14+
}
15+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build linux
2+
// +build linux
3+
4+
package transcription
5+
6+
import (
7+
"os/exec"
8+
"syscall"
9+
)
10+
11+
// configureCmdSysProcAttr sets process group on Linux so we can kill children.
12+
func configureCmdSysProcAttr(cmd *exec.Cmd) {
13+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
14+
}
15+

0 commit comments

Comments
 (0)