summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers.go4
-rw-r--r--internal/api/handlers_admin.go7
-rw-r--r--internal/api/handlers_test.go27
-rw-r--r--internal/api/server.go2
-rw-r--r--internal/scanner/scanner.go17
-rw-r--r--internal/service/admin.go10
-rw-r--r--internal/service/media_browse.go50
-rw-r--r--internal/service/media_test.go62
-rw-r--r--internal/service/mock.go23
-rw-r--r--internal/service/service.go23
10 files changed, 183 insertions, 42 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 41c4197..0436247 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -103,6 +103,10 @@ func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) {
s.serveFile(w, r, "bootstrap.html")
}
+func (s *Server) serveDetach(w http.ResponseWriter, r *http.Request) {
+ s.serveFile(w, r, "detach.html")
+}
+
// ------------------------------------------------------------------
// File serving helpers
// ------------------------------------------------------------------
diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go
index 246a466..e9935d4 100644
--- a/internal/api/handlers_admin.go
+++ b/internal/api/handlers_admin.go
@@ -33,6 +33,13 @@ func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+func (s *Server) handleScanProgress(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.adminSvc) {
+ return
+ }
+ writeJSON(w, http.StatusOK, s.adminSvc.ScanProgress(r.Context()))
+}
+
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if !requireService(w, s.adminSvc) {
return
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
index 2201897..0003b88 100644
--- a/internal/api/handlers_test.go
+++ b/internal/api/handlers_test.go
@@ -679,8 +679,8 @@ func TestServer_MediaList(t *testing.T) {
},
{
name: "with query params",
- query: "?set_id=1&type=video&search=foo&tags=bar,baz&favorites=true&min_duration=10&max_duration=100&sort=name&limit=5&offset=10",
- filter: repository.MediaFilter{SetID: intPtr(1), Type: (*model.MediaType)(func() *string { s := "video"; return &s }()), Search: "foo", Tags: []string{"bar", "baz"}, Favorites: true, MinDuration: floatPtr(10), MaxDuration: floatPtr(100), Sort: "name", Limit: 5, Offset: 10},
+ query: "?set_id=1&type=video&search=foo&tags=bar,baz&favorites=true&min_duration=10&max_duration=100&sort=name&limit=5&offset=10",
+ filter: repository.MediaFilter{SetID: intPtr(1), Type: (*model.MediaType)(func() *string { s := "video"; return &s }()), Search: "foo", Tags: []string{"bar", "baz"}, Favorites: true, MinDuration: floatPtr(10), MaxDuration: floatPtr(100), Sort: "name", Limit: 5, Offset: 10},
listResult: []model.Media{},
wantCode: http.StatusOK,
},
@@ -716,14 +716,14 @@ func TestServer_MediaList(t *testing.T) {
func TestServer_MediaDetail(t *testing.T) {
tests := []struct {
- name string
- id string
- result *service.MediaDetail
- err error
- wantCode int
- wantMedia bool
- wantResumeFrom float64
- wantProgressNil bool
+ name string
+ id string
+ result *service.MediaDetail
+ err error
+ wantCode int
+ wantMedia bool
+ wantResumeFrom float64
+ wantProgressNil bool
}{
{
name: "ok with progress",
@@ -762,8 +762,8 @@ func TestServer_MediaDetail(t *testing.T) {
return
}
var resp struct {
- Media *model.Media `json:"media"`
- Progress *model.PlaybackProgress `json:"progress"`
+ Media *model.Media `json:"media"`
+ Progress *model.PlaybackProgress `json:"progress"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal detail: %v", err)
@@ -1083,7 +1083,7 @@ func TestServer_Shares(t *testing.T) {
},
GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
return &service.GetSharedMediaResult{
- Media: &service.SharedMediaView{ID: 1, FileName: "x.mp4", Type: model.MediaTypeVideo, Duration: 120},
+ Media: &service.SharedMediaView{ID: 1, FileName: "x.mp4", Type: model.MediaTypeVideo, Duration: 120},
StreamURL: "/s/abc/stream",
ThumbURL: "/s/abc/thumbnail",
}, nil
@@ -1190,6 +1190,7 @@ func TestServer_AdminRoutes(t *testing.T) {
}{
{"list trash", "GET", "/api/admin/trash", "", http.StatusOK},
{"rescan", "POST", "/api/admin/rescan", "", http.StatusOK},
+ {"scan progress", "GET", "/api/admin/scan-progress", "", http.StatusOK},
{"list users", "GET", "/api/admin/users", "", http.StatusOK},
{"create user", "POST", "/api/admin/users", `{"username":"bob","password":"pass","is_admin":false}`, http.StatusOK},
{"delete user", "DELETE", "/api/admin/users/2", "", http.StatusOK},
diff --git a/internal/api/server.go b/internal/api/server.go
index 4d18d76..11518df 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -107,6 +107,7 @@ func (s *Server) routes() {
s.mux.Handle("/bootstrap.html", http.HandlerFunc(s.serveBootstrap))
s.mux.Handle("/", s.mw.RequireSession(http.HandlerFunc(s.serveIndex)))
s.mux.Handle("GET /index.html", s.mw.RequireSession(http.HandlerFunc(s.serveIndex)))
+ s.mux.Handle("GET /detach.html", s.mw.RequireSession(http.HandlerFunc(s.serveDetach)))
// Session-required routes
s.mux.Handle("POST /api/logout", s.mw.RequireSession(http.HandlerFunc(s.handleLogout)))
@@ -148,6 +149,7 @@ func (s *Server) routes() {
// Admin routes
s.mux.Handle("GET /api/admin/trash", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListTrash))))
s.mux.Handle("POST /api/admin/rescan", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleRescan))))
+ s.mux.Handle("GET /api/admin/scan-progress", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleScanProgress))))
s.mux.Handle("GET /api/admin/users", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListUsers))))
s.mux.Handle("POST /api/admin/users", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleCreateUser))))
s.mux.Handle("DELETE /api/admin/users/{id}", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleDeleteUser))))
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index 15d0d53..4372f91 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -59,6 +59,7 @@ func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanP
if progress != nil {
progress.Start(setCount)
}
+ fmt.Printf("[scanner] scan started root=%q sets=%d\n", root, setCount)
for _, entry := range entries {
if !entry.IsDir() {
@@ -72,11 +73,13 @@ func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanP
progress.IncrementSet()
}
}
+ fmt.Printf("[scanner] scan finished root=%q sets=%d\n", root, setCount)
return nil
}
func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error {
setName := filepath.Base(setPath)
+ fmt.Printf("[scanner] set started name=%q path=%q\n", setName, setPath)
if progress != nil {
progress.SetCurrentSet(setName)
}
@@ -122,6 +125,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
for _, m := range mediaList {
existing[m.RelPath] = m
}
+ newFiles := 0
// First pass: gather images per directory.
coverImages := make(map[string]string)
@@ -153,7 +157,12 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
return fmt.Errorf("rel path for %q: %w", path, err)
}
relPath = filepath.ToSlash(relPath)
- if _, alreadyExists := existing[relPath]; alreadyExists {
+ _, alreadyExists := existing[relPath]
+ fmt.Printf("[scanner] file set=%q path=%q existing=%t\n", setName, relPath, alreadyExists)
+ if progress != nil {
+ progress.IncrementFile()
+ }
+ if alreadyExists {
return nil
}
@@ -204,8 +213,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
if _, err := s.store.CreateMedia(ctx, media); err != nil {
return fmt.Errorf("create media %q: %w", path, err)
}
- if progress != nil {
- progress.IncrementFile()
+ newFiles++
+ if newFiles == 1 || newFiles%25 == 0 {
+ fmt.Printf("[scanner] set progress name=%q new_media=%d latest=%q\n", setName, newFiles, relPath)
}
return nil
})
@@ -226,6 +236,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
}
}
+ fmt.Printf("[scanner] set completed name=%q existing_media=%d new_media=%d\n", setName, len(existing), newFiles)
return nil
}
diff --git a/internal/service/admin.go b/internal/service/admin.go
index ebae686..9b32dd3 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -19,6 +19,7 @@ type adminService struct {
hasher auth.Hasher
scanner scanner.Scanner
mediaRoot string
+ progress *model.ScanProgress
}
// NewAdminService creates a concrete AdminService.
@@ -29,6 +30,7 @@ func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher
hasher: hasher,
scanner: sc,
mediaRoot: mediaRoot,
+ progress: &model.ScanProgress{},
}
}
@@ -45,15 +47,21 @@ 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, nil); err != nil {
+ if err := s.scanner.Scan(ctx, s.mediaRoot, s.progress); err != nil {
+ s.progress.Done(err)
fmt.Printf("[rescan] scan failed: %v\n", err)
} else {
+ s.progress.Done(nil)
fmt.Printf("[rescan] scan completed\n")
}
}()
return nil
}
+func (s *adminService) ScanProgress(ctx context.Context) model.ScanProgress {
+ return s.progress.Copy()
+}
+
func (s *adminService) ListUsers(ctx context.Context) ([]model.User, error) {
return s.store.ListUsers(ctx)
}
diff --git a/internal/service/media_browse.go b/internal/service/media_browse.go
index 79c9da1..0e3a7de 100644
--- a/internal/service/media_browse.go
+++ b/internal/service/media_browse.go
@@ -270,13 +270,29 @@ func (s *mediaService) GetSetCover(ctx context.Context, setID int64, folder stri
coverPath := filepath.Join(filepath.Clean(baseDir), ".cover.jpg")
info, err := os.Stat(coverPath)
+ if err == nil {
+ return &FileResult{
+ Path: coverPath,
+ FileName: filepath.Base(coverPath),
+ FileSize: info.Size(),
+ }, nil
+ }
+
+ media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID})
if err != nil {
+ return nil, fmt.Errorf("list media: %w", err)
+ }
+ candidate := randomFolderThumbnail(media, prefix)
+ if candidate == "" {
return nil, fmt.Errorf("stat cover: %w", err)
}
-
+ info, err = os.Stat(candidate)
+ if err != nil {
+ return nil, fmt.Errorf("stat thumbnail cover: %w", err)
+ }
return &FileResult{
- Path: coverPath,
- FileName: filepath.Base(coverPath),
+ Path: candidate,
+ FileName: filepath.Base(candidate),
FileSize: info.Size(),
}, nil
}
@@ -399,7 +415,8 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren
subPath := filepath.Join(parent, name)
coverPath := filepath.Join(filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath, filepath.FromSlash(subPath))), ".cover.jpg")
_, err := os.Stat(coverPath)
- folders = append(folders, BrowseFolder{Name: name, HasCover: err == nil})
+ hasCover := err == nil || randomFolderThumbnail(media, filepath.ToSlash(subPath)) != ""
+ folders = append(folders, BrowseFolder{Name: name, HasCover: hasCover})
}
}
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
@@ -410,3 +427,28 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren
Media: items,
}, nil
}
+
+func randomFolderThumbnail(media []model.Media, folder string) string {
+ prefix := filepath.ToSlash(strings.Trim(folder, "/"))
+ if prefix != "" {
+ prefix += "/"
+ }
+ var candidates []string
+ for _, m := range media {
+ if m.DeletedAt != nil || m.ThumbnailPath == "" {
+ continue
+ }
+ rel := filepath.ToSlash(m.RelPath)
+ if prefix != "" && !strings.HasPrefix(rel, prefix) {
+ continue
+ }
+ if prefix == "" && strings.Contains(rel, "/") {
+ continue
+ }
+ candidates = append(candidates, m.ThumbnailPath)
+ }
+ if len(candidates) == 0 {
+ return ""
+ }
+ return candidates[mrand.Intn(len(candidates))]
+}
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index 27f93dc..4163066 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -999,7 +999,6 @@ func TestMediaService_StreamSharedMedia(t *testing.T) {
}
}
-
func TestMediaService_UploadMedia_ProbeAndThumbnail(t *testing.T) {
ctx := context.Background()
makeStore := func() *repository.MockStore {
@@ -1253,7 +1252,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
return nil, nil
},
SoftDeleteMediaFunc: func(ctx context.Context, id int64) error { return nil },
- RestoreMediaFunc: func(ctx context.Context, id int64) error { return nil },
+ RestoreMediaFunc: func(ctx context.Context, id int64) error { return nil },
},
UserRepo: repository.MockUserRepo{
GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
@@ -1905,6 +1904,65 @@ func TestMediaService_RegenerateSetCover(t *testing.T) {
})
}
+func TestMediaService_FolderThumbnailFallback(t *testing.T) {
+ ctx := context.Background()
+ tmpDir := t.TempDir()
+ thumbPath := filepath.Join(tmpDir, "thumb.jpg")
+ if err := os.WriteFile(thumbPath, []byte("thumb"), 0o644); err != nil {
+ t.Fatalf("write thumb: %v", err)
+ }
+
+ set := &model.Set{ID: 1, RootPath: "library"}
+ media := []model.Media{
+ {ID: 1, SetID: 1, RelPath: "Rock/a.mp4", ThumbnailPath: thumbPath, Type: model.MediaTypeVideo},
+ {ID: 2, SetID: 1, RelPath: "Rock/b.mp4", Type: model.MediaTypeVideo},
+ }
+ store := &repository.MockStore{
+ SetRepo: repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ if id == set.ID {
+ return set, nil
+ }
+ return nil, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
+ MediaRepo: repository.MockMediaRepo{
+ ListMediaFunc: func(ctx context.Context, filter repository.MediaFilter) ([]model.Media, error) {
+ return media, nil
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), tmpDir, nil, nil)
+
+ t.Run("browse marks folder as having thumbnail", func(t *testing.T) {
+ res, err := svc.BrowseSet(ctx, 1, 1, "")
+ if err != nil {
+ t.Fatalf("browse set: %v", err)
+ }
+ if len(res.Folders) != 1 {
+ t.Fatalf("expected one folder, got %d", len(res.Folders))
+ }
+ if res.Folders[0].Name != "Rock" || !res.Folders[0].HasCover {
+ t.Fatalf("unexpected folder: %+v", res.Folders[0])
+ }
+ })
+
+ t.Run("cover endpoint falls back to media thumbnail", func(t *testing.T) {
+ fr, err := svc.GetSetCover(ctx, 1, "Rock", 1)
+ if err != nil {
+ t.Fatalf("get set cover: %v", err)
+ }
+ if fr.Path != thumbPath {
+ t.Fatalf("expected fallback thumb %q, got %q", thumbPath, fr.Path)
+ }
+ })
+}
+
func TestMediaService_GetThumbnail(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
diff --git a/internal/service/mock.go b/internal/service/mock.go
index 2743ac8..33be6b3 100644
--- a/internal/service/mock.go
+++ b/internal/service/mock.go
@@ -11,14 +11,14 @@ import (
)
var (
- _ MediaBrowseService = (*MockMediaService)(nil)
- _ MediaWriteService = (*MockMediaService)(nil)
- _ MediaShareService = (*MockMediaService)(nil)
- _ MediaTagService = (*MockMediaService)(nil)
- _ MediaFavoriteService = (*MockMediaService)(nil)
- _ MediaNoteService = (*MockMediaService)(nil)
- _ MediaService = (*MockMediaService)(nil)
- _ AuthService = (*MockAuthService)(nil)
+ _ MediaBrowseService = (*MockMediaService)(nil)
+ _ MediaWriteService = (*MockMediaService)(nil)
+ _ MediaShareService = (*MockMediaService)(nil)
+ _ MediaTagService = (*MockMediaService)(nil)
+ _ MediaFavoriteService = (*MockMediaService)(nil)
+ _ MediaNoteService = (*MockMediaService)(nil)
+ _ MediaService = (*MockMediaService)(nil)
+ _ AuthService = (*MockAuthService)(nil)
)
// MockMediaService is a fake MediaService for testing.
@@ -219,6 +219,7 @@ func (m *MockMediaService) DeleteNote(ctx context.Context, mediaID, userID int64
type MockAdminService struct {
ListTrashFunc func(ctx context.Context) ([]model.Media, error)
TriggerRescanFunc func(ctx context.Context) error
+ ScanProgressFunc func(ctx context.Context) model.ScanProgress
ListUsersFunc func(ctx context.Context) ([]model.User, error)
CreateUserFunc func(ctx context.Context, username, password string, isAdmin bool) (*model.User, error)
DeleteUserFunc func(ctx context.Context, id int64) error
@@ -239,6 +240,12 @@ func (m *MockAdminService) TriggerRescan(ctx context.Context) error {
}
return nil
}
+func (m *MockAdminService) ScanProgress(ctx context.Context) model.ScanProgress {
+ if m.ScanProgressFunc != nil {
+ return m.ScanProgressFunc(ctx)
+ }
+ return model.ScanProgress{}
+}
func (m *MockAdminService) ListUsers(ctx context.Context) ([]model.User, error) {
if m.ListUsersFunc != nil {
return m.ListUsersFunc(ctx)
diff --git a/internal/service/service.go b/internal/service/service.go
index b0be1f4..e31f6db 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -33,15 +33,15 @@ type MediaWriteService interface {
// BrowseFolder is a named folder within a set's directory tree.
type BrowseFolder struct {
- Name string `json:"name"`
- HasCover bool `json:"has_cover"`
+ Name string `json:"name"`
+ HasCover bool `json:"has_cover"`
}
// BrowseResult is the content of one directory inside a set.
type BrowseResult struct {
- CurrentPath string `json:"current_path"`
+ CurrentPath string `json:"current_path"`
Folders []BrowseFolder `json:"folders"`
- Media []model.Media `json:"media"`
+ Media []model.Media `json:"media"`
}
// SharedMediaView exposes only the metadata fields needed for a public share page.
@@ -121,6 +121,7 @@ type MediaService interface {
type AdminService interface {
ListTrash(ctx context.Context) ([]model.Media, error)
TriggerRescan(ctx context.Context) error
+ ScanProgress(ctx context.Context) model.ScanProgress
ListUsers(ctx context.Context) ([]model.User, error)
CreateUser(ctx context.Context, username, password string, isAdmin bool) (*model.User, error)
DeleteUser(ctx context.Context, id int64) error
@@ -148,8 +149,8 @@ type ProgressService interface {
// PermissionsMatrix is the shape returned by ListPermissions.
type PermissionsMatrix struct {
- Sets []model.Set `json:"sets"`
- Users []model.User `json:"users"`
+ Sets []model.Set `json:"sets"`
+ Users []model.User `json:"users"`
Permissions []model.SetPermission `json:"permissions"`
}
@@ -162,11 +163,11 @@ type FileResult struct {
// MediaDetail combines media with related data.
type MediaDetail struct {
- Media *model.Media `json:"media"`
- Tags []model.Tag `json:"tags"`
- Favorite bool `json:"favorite"`
- Note *model.Note `json:"note,omitempty"`
- Progress *model.PlaybackProgress `json:"progress,omitempty"`
+ Media *model.Media `json:"media"`
+ Tags []model.Tag `json:"tags"`
+ Favorite bool `json:"favorite"`
+ Note *model.Note `json:"note,omitempty"`
+ Progress *model.PlaybackProgress `json:"progress,omitempty"`
}
// ResumeFrom returns the saved playback position in seconds, or 0 if none.