diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/handlers.go | 4 | ||||
| -rw-r--r-- | internal/api/handlers_admin.go | 7 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 27 | ||||
| -rw-r--r-- | internal/api/server.go | 2 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 17 | ||||
| -rw-r--r-- | internal/service/admin.go | 10 | ||||
| -rw-r--r-- | internal/service/media_browse.go | 50 | ||||
| -rw-r--r-- | internal/service/media_test.go | 62 | ||||
| -rw-r--r-- | internal/service/mock.go | 23 | ||||
| -rw-r--r-- | internal/service/service.go | 23 |
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. |
