summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers.go49
-rw-r--r--internal/repository/media.go6
-rw-r--r--internal/repository/repository.go1
-rw-r--r--internal/scanner/scanner.go42
-rw-r--r--internal/scanner/scanner_test.go18
-rw-r--r--internal/service/admin.go2
-rw-r--r--internal/service/admin_test.go10
7 files changed, 98 insertions, 30 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 97e5557..ad2e608 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -7,9 +7,11 @@ import (
"fmt"
"io"
"log/slog"
+ "mime"
"net/http"
"net/url"
"os"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -348,6 +350,14 @@ func parseMediaListQuery(q url.Values) repository.MediaFilter {
filter.SetID = &id
}
}
+ if v := q.Get("set_ids"); v != "" {
+ parts := strings.Split(v, ",")
+ for _, p := range parts {
+ if id, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil {
+ filter.SetIDs = append(filter.SetIDs, id)
+ }
+ }
+ }
if v := q.Get("type"); v != "" {
t := model.MediaType(v)
filter.Type = &t
@@ -552,7 +562,10 @@ func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *se
disp := fmt.Sprintf("attachment; filename=%q", res.FileName)
w.Header().Set("Content-Disposition", disp)
}
-
+ // Set Content-Type so browsers know how to decode the file without
+ // needing to sniff, which avoids buffering delays during streaming.
+ w.Header().Set("Content-Type", mimeTypeForFilename(res.FileName))
+ w.Header().Set("Accept-Ranges", "bytes")
http.ServeContent(w, r, res.FileName, stat.ModTime(), f)
}
@@ -983,3 +996,37 @@ func (s *Server) handleRevokePermission(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+
+// mimeTypeForFilename returns an HTTP Content-Type based on the file extension.
+func mimeTypeForFilename(name string) string {
+ ext := strings.ToLower(filepath.Ext(name))
+ t := mime.TypeByExtension(ext)
+ if t != "" {
+ return t
+ }
+ switch ext {
+ case ".mp4", ".m4v":
+ return "video/mp4"
+ case ".mkv":
+ return "video/x-matroska"
+ case ".avi":
+ return "video/x-msvideo"
+ case ".mov":
+ return "video/quicktime"
+ case ".webm":
+ return "video/webm"
+ case ".mp3":
+ return "audio/mpeg"
+ case ".flac":
+ return "audio/flac"
+ case ".wav":
+ return "audio/wav"
+ case ".aac", ".m4a":
+ return "audio/mp4"
+ case ".ogg", ".opus":
+ return "audio/ogg"
+ case ".m4b":
+ return "audio/x-m4b"
+ }
+ return "application/octet-stream"
+}
diff --git a/internal/repository/media.go b/internal/repository/media.go
index 1657c7a..1fdb365 100644
--- a/internal/repository/media.go
+++ b/internal/repository/media.go
@@ -164,6 +164,12 @@ func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Med
conds = append(conds, `media.set_id = ?`)
args = append(args, *filter.SetID)
}
+ if len(filter.SetIDs) > 0 {
+ conds = append(conds, "media.set_id IN ("+placeholders(len(filter.SetIDs))+")")
+ for _, id := range filter.SetIDs {
+ args = append(args, id)
+ }
+ }
if len(filter.AllowedSetIDs) > 0 {
conds = append(conds, "media.set_id IN ("+placeholders(len(filter.AllowedSetIDs))+")")
for _, id := range filter.AllowedSetIDs {
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
index bc44bf5..53635b4 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -93,6 +93,7 @@ type SetPermissionRepo interface {
// MediaFilter defines query parameters for listing media.
type MediaFilter struct {
SetID *int64
+ SetIDs []int64 // multi-set selection
AllowedSetIDs []int64
Type *model.MediaType
Search string
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index 3e3f19b..569f181 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -17,7 +17,7 @@ import (
// Scanner defines the filesystem scanning contract.
type Scanner interface {
- Scan(ctx context.Context, root string) error
+ Scan(ctx context.Context, root string, progress *model.ScanProgress) error
}
// FSScanner recursively scans media root for sets and media files.
@@ -43,26 +43,43 @@ func NewFSScanner(store repository.ScannerStore, prober probe.Prober, thumbGen t
}
// Scan walks immediate subdirectories of root, treating each as a set.
-func (s *FSScanner) Scan(ctx context.Context, root string) error {
+func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanProgress) error {
entries, err := s.fs.ReadDir(root)
if err != nil {
return fmt.Errorf("read media root %q: %w", root, err)
}
+ // Count total sets for progress.
+ var setCount int
+ for _, entry := range entries {
+ if entry.IsDir() {
+ setCount++
+ }
+ }
+ if progress != nil {
+ progress.Start(setCount)
+ }
+
for _, entry := range entries {
if !entry.IsDir() {
continue
}
setPath := filepath.Join(root, entry.Name())
- if err := s.scanSet(ctx, root, setPath); err != nil {
+ if err := s.scanSet(ctx, root, setPath, progress); err != nil {
return err
}
+ if progress != nil {
+ progress.IncrementSet()
+ }
}
return nil
}
-func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
+func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error {
setName := filepath.Base(setPath)
+ if progress != nil {
+ progress.SetCurrentSet(setName)
+ }
relRoot, err := filepath.Rel(root, setPath)
if err != nil {
relRoot = setName
@@ -106,8 +123,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
existing[m.RelPath] = m
}
- // First pass: gather images per directory so we can pair them with audio files.
- // Key: parent dir path (absolute); Value: relative path to the first image found there.
+ // First pass: gather images per directory.
coverImages := make(map[string]string)
_ = s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !isImageFile(path) {
@@ -121,7 +137,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
return nil
})
- // Second pass: walk set directory recursively for NEW media files.
+ // Second pass: walk for NEW media files.
walkErr := s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("walk %q: %w", path, err)
@@ -147,7 +163,6 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
meta, err := s.prober.Probe(ctx, path)
if err != nil {
- // Skip unprobeable/corrupt files instead of aborting the whole scan.
fmt.Printf("[scanner] skipping unprobeable file %q: %v\n", path, err)
return nil
}
@@ -163,12 +178,10 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg"
thumbnailPath = filepath.Join(thumbDir, thumbName)
if err := s.thumbGen.Generate(ctx, path, thumbnailPath, meta.Duration); err != nil {
- // Skip thumbnail generation errors (e.g., corrupt or audio-only video files).
fmt.Printf("[scanner] skipping thumbnail for %q: %v\n", path, err)
thumbnailPath = ""
}
} else if mediaType == model.MediaTypeAudio {
- // Use a sibling or ancestor image as the cover/thumbnail if one exists.
thumbnailPath = findCoverImage(path, coverImages, setPath)
}
@@ -190,6 +203,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
if _, err := s.store.CreateMedia(ctx, media); err != nil {
return fmt.Errorf("create media %q: %w", path, err)
}
+ if progress != nil {
+ progress.IncrementFile()
+ }
return nil
})
if walkErr != nil {
@@ -212,8 +228,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
return nil
}
-// findCoverImage walks up from a file's directory toward the set root,
-// returning the first cover image found.
+// findCoverImage walks up from a file's directory toward the set root.
func findCoverImage(filePath string, coverImages map[string]string, setPath string) string {
for dir := filepath.Dir(filePath); len(dir) >= len(setPath); dir = filepath.Dir(dir) {
if coverRel, ok := coverImages[dir]; ok {
@@ -228,7 +243,7 @@ func findCoverImage(filePath string, coverImages map[string]string, setPath stri
var mediaExtensions = map[string]struct{}{
".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".webm": {},
- ".mp3": {}, ".flac": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".m4a": {}, ".opus": {},
+ ".mp3": {}, ".flac": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".m4a": {}, ".opus": {}, ".m4b": {},
}
// imageExtensions lists file extensions recognized as cover/artwork images.
@@ -248,7 +263,6 @@ func isImageFile(path string) bool {
func isMediaFile(path string) bool {
base := filepath.Base(path)
- // Skip macOS resource fork files (._*)
if strings.HasPrefix(base, "._") {
return false
}
diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go
index 1a105ec..8cc0d27 100644
--- a/internal/scanner/scanner_test.go
+++ b/internal/scanner/scanner_test.go
@@ -117,7 +117,7 @@ func TestFSScanner_Scan(t *testing.T) {
store := repository.NewMockStore()
store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil }
s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs)
- if err := s.Scan(ctx, "/media"); err != nil {
+ if err := s.Scan(ctx, "/media", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
@@ -180,7 +180,7 @@ func TestFSScanner_Scan(t *testing.T) {
}
s := newTestScanner(store, prober, gen, clk, mfs)
- if err := s.Scan(ctx, "/media"); err != nil {
+ if err := s.Scan(ctx, "/media", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -242,7 +242,7 @@ func TestFSScanner_Scan(t *testing.T) {
}
s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs)
- if err := s.Scan(ctx, "/media"); err != nil {
+ if err := s.Scan(ctx, "/media", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if created != 0 {
@@ -282,7 +282,7 @@ func TestFSScanner_Scan(t *testing.T) {
}
s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs)
- if err := s.Scan(ctx, "/media"); err != nil {
+ if err := s.Scan(ctx, "/media", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if created.RelPath != filepath.Join("season1", "ep1.mp4") {
@@ -293,7 +293,7 @@ func TestFSScanner_Scan(t *testing.T) {
t.Run("read dir error", func(t *testing.T) {
mfs := &mockFS{entries: map[string][]os.DirEntry{}}
s := newTestScanner(repository.NewMockStore(), &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs)
- err := s.Scan(ctx, "/media")
+ err := s.Scan(ctx, "/media", nil)
if err == nil {
t.Fatal("expected error for missing root")
}
@@ -315,7 +315,7 @@ func TestFSScanner_Scan(t *testing.T) {
store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { return nil, nil }
s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs)
- err := s.Scan(ctx, "/media")
+ err := s.Scan(ctx, "/media", nil)
if err == nil {
t.Fatal("expected error for stat failure")
}
@@ -346,7 +346,7 @@ func TestFSScanner_Scan(t *testing.T) {
s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs)
// Unprobeable files are skipped with a log instead of failing the whole scan.
- err := s.Scan(ctx, "/media")
+ err := s.Scan(ctx, "/media", nil)
if err != nil {
t.Fatalf("unexpected error for probe failure; expected skip, got: %v", err)
}
@@ -386,7 +386,7 @@ func TestFSScanner_Scan(t *testing.T) {
s := newTestScanner(store, prober, gen, clk, mfs)
// Thumbnail generation errors are skipped so the scan continues.
- err := s.Scan(ctx, "/media")
+ err := s.Scan(ctx, "/media", nil)
if err != nil {
t.Fatalf("unexpected error for thumbnail failure; expected skip, got: %v", err)
}
@@ -406,7 +406,7 @@ func TestFSScanner_Scan(t *testing.T) {
store.MediaRepo.ListMediaFunc = func(_ context.Context, _ repository.MediaFilter) ([]model.Media, error) { return nil, nil }
s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs)
- err := s.Scan(ctx, "/media")
+ err := s.Scan(ctx, "/media", nil)
if err == nil {
t.Fatal("expected error for walk failure")
}
diff --git a/internal/service/admin.go b/internal/service/admin.go
index 673bf37..ebae686 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -45,7 +45,7 @@ func (s *adminService) TriggerRescan(ctx context.Context) error {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
- if err := s.scanner.Scan(ctx, s.mediaRoot); err != nil {
+ if err := s.scanner.Scan(ctx, s.mediaRoot, nil); err != nil {
fmt.Printf("[rescan] scan failed: %v\n", err)
} else {
fmt.Printf("[rescan] scan completed\n")
diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go
index ac73ce2..d4be649 100644
--- a/internal/service/admin_test.go
+++ b/internal/service/admin_test.go
@@ -10,12 +10,12 @@ import (
)
type fakeScanner struct {
- scanFunc func(ctx context.Context, root string) error
+ scanFunc func(ctx context.Context, root string, progress *model.ScanProgress) error
}
-func (f *fakeScanner) Scan(ctx context.Context, root string) error {
+func (f *fakeScanner) Scan(ctx context.Context, root string, progress *model.ScanProgress) error {
if f.scanFunc != nil {
- return f.scanFunc(ctx, root)
+ return f.scanFunc(ctx, root, progress)
}
return nil
}
@@ -59,7 +59,7 @@ func TestAdminService_TriggerRescan(t *testing.T) {
var scannedRoot string
done := make(chan struct{})
sc := &fakeScanner{
- scanFunc: func(_ context.Context, root string) error {
+ scanFunc: func(_ context.Context, root string, _ *model.ScanProgress) error {
scannedRoot = root
close(done)
return nil
@@ -80,7 +80,7 @@ func TestAdminService_TriggerRescan_Error(t *testing.T) {
ctx := context.Background()
done := make(chan struct{})
sc := &fakeScanner{
- scanFunc: func(_ context.Context, _ string) error {
+ scanFunc: func(_ context.Context, _ string, _ *model.ScanProgress) error {
close(done)
return errors.New("scan failed")
},