diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-02 11:45:27 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-02 11:45:27 +0300 |
| commit | 23d017ff90abfe7aeea7fb43619d7469e3ec0c2a (patch) | |
| tree | 95380288fa9d7ee362e43f5f91dcfd3370a79469 | |
| parent | 5275a0a4cec129bc1ea66beab934bf472f7a7fd2 (diff) | |
more on this
| -rw-r--r-- | internal/api/handlers_media.go | 53 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 2 | ||||
| -rw-r--r-- | internal/api/server.go | 4 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 1 | ||||
| -rw-r--r-- | internal/service/media_browse.go | 154 | ||||
| -rw-r--r-- | internal/service/media_test.go | 37 | ||||
| -rw-r--r-- | internal/service/mock.go | 23 | ||||
| -rw-r--r-- | internal/service/mock_test.go | 6 | ||||
| -rw-r--r-- | internal/service/service.go | 17 | ||||
| -rw-r--r-- | web/css/layout.css | 25 | ||||
| -rw-r--r-- | web/css/player.css | 3 | ||||
| -rw-r--r-- | web/index.html | 6 | ||||
| -rw-r--r-- | web/js/api.js | 13 | ||||
| -rw-r--r-- | web/js/app.js | 190 | ||||
| -rw-r--r-- | web/js/keyboard.js | 1 | ||||
| -rw-r--r-- | web/js/selection.js | 2 | ||||
| -rw-r--r-- | web/js/state.js | 1 |
17 files changed, 465 insertions, 73 deletions
diff --git a/internal/api/handlers_media.go b/internal/api/handlers_media.go index 1469342..3863ab0 100644 --- a/internal/api/handlers_media.go +++ b/internal/api/handlers_media.go @@ -30,7 +30,7 @@ func (s *Server) handleListSets(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, sets) } -func (s *Server) handleSetCover(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) { if !requireService(w, s.mediaSvc) { return } @@ -39,7 +39,34 @@ func (s *Server) handleSetCover(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid set id"}) return } - if err := s.mediaSvc.RegenerateSetCover(r.Context(), setID, userIDFromContext(r)); err != nil { + folder := r.URL.Query().Get("folder") + fr, err := s.mediaSvc.GetSetCover(r.Context(), setID, folder, userIDFromContext(r)) + if err != nil { + if errors.Is(err, service.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + if errors.Is(err, service.ErrForbidden) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + http.ServeFile(w, r, fr.Path) +} + +func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.mediaSvc) { + return + } + setID := pathID(r, "id") + if setID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid set id"}) + return + } + folder := r.URL.Query().Get("folder") + if err := s.mediaSvc.RegenerateSetCover(r.Context(), setID, folder, userIDFromContext(r)); err != nil { if errors.Is(err, service.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return @@ -54,6 +81,28 @@ func (s *Server) handleSetCover(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +func (s *Server) handleBrowseSet(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.mediaSvc) { + return + } + setID := pathID(r, "id") + if setID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid set id"}) + return + } + parent := r.URL.Query().Get("parent") + result, err := s.mediaSvc.BrowseSet(r.Context(), setID, userIDFromContext(r), parent) + if err != nil { + if errors.Is(err, service.ErrForbidden) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) +} + func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if !requireService(w, s.mediaSvc) { return diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index 23e94c7..e5d8529 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -339,7 +339,7 @@ func TestServer_SetCover(t *testing.T) { var ms service.MediaService if !tt.svcNil { ms = &service.MockMediaService{ - RegenerateSetCoverFunc: func(ctx context.Context, setID, userID int64) error { + RegenerateSetCoverFunc: func(ctx context.Context, setID int64, folder string, userID int64) error { return tt.svcErr }, } diff --git a/internal/api/server.go b/internal/api/server.go index 6c15c78..1855148 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -112,7 +112,9 @@ func (s *Server) routes() { // Sets s.mux.Handle("GET /api/sets", s.mw.RequireSession(http.HandlerFunc(s.handleListSets))) - s.mux.Handle("POST /api/sets/{id}/cover", s.mw.RequireSession(http.HandlerFunc(s.handleSetCover))) + s.mux.Handle("GET /api/sets/{id}/browse", s.mw.RequireSession(http.HandlerFunc(s.handleBrowseSet))) + s.mux.Handle("GET /api/sets/{id}/cover", s.mw.RequireSession(http.HandlerFunc(s.handleGetSetCover))) + s.mux.Handle("POST /api/sets/{id}/cover", s.mw.RequireSession(http.HandlerFunc(s.handlePostSetCover))) s.mux.Handle("POST /api/sets/{id}/upload", s.mw.RequireSession(http.HandlerFunc(s.handleUpload))) // Media diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 569f181..15d0d53 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -152,6 +152,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress if err != nil { return fmt.Errorf("rel path for %q: %w", path, err) } + relPath = filepath.ToSlash(relPath) if _, alreadyExists := existing[relPath]; alreadyExists { return nil } diff --git a/internal/service/media_browse.go b/internal/service/media_browse.go index 2a60516..79c9da1 100644 --- a/internal/service/media_browse.go +++ b/internal/service/media_browse.go @@ -7,6 +7,7 @@ import ( mrand "math/rand" "os" "path/filepath" + "sort" "strings" "codeberg.org/snonux/player/internal/model" @@ -182,7 +183,7 @@ func (s *mediaService) RegenerateThumbnail(ctx context.Context, mediaID, userID return nil } -func (s *mediaService) RegenerateSetCover(ctx context.Context, setID, userID int64) error { +func (s *mediaService) RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error { if err := s.verifySetModifyAccess(ctx, setID, userID); err != nil { return err } @@ -200,9 +201,25 @@ func (s *mediaService) RegenerateSetCover(ctx context.Context, setID, userID int return fmt.Errorf("list media: %w", err) } + prefix := filepath.ToSlash(strings.Trim(folder, "/")) var candidates []model.Media for _, m := range media { - if m.Type == model.MediaTypeVideo && m.DeletedAt == nil { + if m.DeletedAt != nil { + continue + } + rel := filepath.ToSlash(m.RelPath) + if prefix != "" { + if !strings.HasPrefix(rel, prefix+"/") { + continue + } + suffix := strings.TrimPrefix(rel, prefix+"/") + if strings.Contains(suffix, "/") { + continue + } + } else if strings.Contains(rel, "/") { + continue + } + if m.Type == model.MediaTypeVideo { candidates = append(candidates, m) } } @@ -215,7 +232,11 @@ func (s *mediaService) RegenerateSetCover(ctx context.Context, setID, userID int candidate = candidates[mrand.Intn(len(candidates))] } - coverPath := filepath.Join(filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath)), ".cover.jpg") + baseDir := filepath.Join(s.mediaRoot, filepath.FromSlash(set.RootPath)) + if prefix != "" { + baseDir = filepath.Join(baseDir, filepath.FromSlash(prefix)) + } + coverPath := filepath.Join(filepath.Clean(baseDir), ".cover.jpg") meta, err := s.prober.Probe(ctx, candidate.AbsPath) if err != nil { return fmt.Errorf("probe cover candidate: %w", err) @@ -225,13 +246,41 @@ func (s *mediaService) RegenerateSetCover(ctx context.Context, setID, userID int return fmt.Errorf("generate cover: %w", err) } - set.CoverThumbnailPath = coverPath - if err := s.store.UpdateSet(ctx, set); err != nil { - return fmt.Errorf("update set: %w", err) - } return nil } +func (s *mediaService) GetSetCover(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error) { + if err := s.checkSetPermission(ctx, setID, userID, ""); err != nil { + return nil, err + } + + set, err := s.store.GetSetByID(ctx, setID) + if err != nil { + return nil, fmt.Errorf("get set: %w", err) + } + if set == nil { + return nil, ErrNotFound + } + + prefix := filepath.ToSlash(strings.Trim(folder, "/")) + baseDir := filepath.Join(s.mediaRoot, filepath.FromSlash(set.RootPath)) + if prefix != "" { + baseDir = filepath.Join(baseDir, filepath.FromSlash(prefix)) + } + coverPath := filepath.Join(filepath.Clean(baseDir), ".cover.jpg") + + info, err := os.Stat(coverPath) + if err != nil { + return nil, fmt.Errorf("stat cover: %w", err) + } + + return &FileResult{ + Path: coverPath, + FileName: filepath.Base(coverPath), + FileSize: info.Size(), + }, nil +} + func (s *mediaService) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { if _, err := s.verifyAccess(ctx, mediaID, userID); err != nil { return false, err @@ -270,3 +319,94 @@ func (s *mediaService) RemoveTag(ctx context.Context, mediaID, userID int64, tag } return s.store.RemoveTag(ctx, mediaID, tag.ID) } + +// BrowseSet returns the immediate subfolders and media files inside +// a specific folder (parent) of a set. +// If a subfolder contains exactly one file and no further subfolders, +// that file is "flattened" and shown at the current level instead of +// presenting the folder. +func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) { + if err := s.checkSetPermission(ctx, setID, userID, ""); err != nil { + return nil, err + } + + parent = filepath.ToSlash(strings.Trim(parent, "/")) + media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) + if err != nil { + return nil, fmt.Errorf("list media: %w", err) + } + + set, err := s.store.GetSetByID(ctx, setID) + if err != nil { + return nil, fmt.Errorf("get set: %w", err) + } + if set == nil { + return nil, ErrNotFound + } + + type folderContent struct { + files []model.Media + subfolders map[string]struct{} + } + folderMap := make(map[string]*folderContent) + var items []model.Media + + for _, m := range media { + if m.DeletedAt != nil { + continue + } + rel := filepath.ToSlash(m.RelPath) + prefix := "" + if parent != "" { + prefix = parent + "/" + } + if !strings.HasPrefix(rel, prefix) { + continue + } + suffix := strings.TrimPrefix(rel, prefix) + if suffix == "" { + continue + } + parts := strings.SplitN(suffix, "/", 2) + name := parts[0] + if len(parts) == 1 { + // File at the current level. + items = append(items, m) + continue + } + // Inside a subfolder — count what is in there. + fc, ok := folderMap[name] + if !ok { + fc = &folderContent{subfolders: make(map[string]struct{})} + folderMap[name] = fc + } + rest := parts[1] + subparts := strings.SplitN(rest, "/", 2) + if len(subparts) == 1 { + fc.files = append(fc.files, m) + } else { + fc.subfolders[subparts[0]] = struct{}{} + } + } + + var folders []BrowseFolder + for name, fc := range folderMap { + total := len(fc.files) + len(fc.subfolders) + if total == 1 && len(fc.files) == 1 { + // Flatten: show the lone file at the current level. + items = append(items, fc.files[0]) + } else { + 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}) + } + } + sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) + + return &BrowseResult{ + CurrentPath: parent, + Folders: folders, + Media: items, + }, nil +} diff --git a/internal/service/media_test.go b/internal/service/media_test.go index cbf3de1..27f93dc 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -1329,7 +1329,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) { }, } svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil) - err := svc.RegenerateSetCover(ctx, 1, 2) + err := svc.RegenerateSetCover(ctx, 1, "", 2) if !errors.Is(err, ErrForbidden) { t.Fatalf("expected ErrForbidden, got %v", err) } @@ -1815,7 +1815,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { return &model.Metadata{Duration: 60}, nil }} svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober) - if err := svc.RegenerateSetCover(ctx, 1, 1); err != nil { + if err := svc.RegenerateSetCover(ctx, 1, "", 1); err != nil { t.Fatalf("unexpected error: %v", err) } }) @@ -1832,7 +1832,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { return &model.Metadata{Duration: 60}, nil }} svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober) - if err := svc.RegenerateSetCover(ctx, 1, 2); err != nil { + if err := svc.RegenerateSetCover(ctx, 1, "", 2); err != nil { t.Fatalf("unexpected error: %v", err) } }) @@ -1841,7 +1841,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { set := &model.Set{ID: 1, RootPath: "music", Permissions: []model.SetPermission{{SetID: 1, UserID: 2, Role: model.RoleViewer}}} store := makeStore(1, nil, set) svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil) - err := svc.RegenerateSetCover(ctx, 1, 2) + err := svc.RegenerateSetCover(ctx, 1, "", 2) if !errors.Is(err, ErrForbidden) { t.Fatalf("expected ErrForbidden, got %v", err) } @@ -1850,7 +1850,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { t.Run("set not found", func(t *testing.T) { store := makeStore(1, nil, nil) svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil) - err := svc.RegenerateSetCover(ctx, 1, 1) + err := svc.RegenerateSetCover(ctx, 1, "", 1) if !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound, got %v", err) } @@ -1861,7 +1861,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { media := []model.Media{{ID: 1, SetID: 1, AbsPath: "/tmp/song.mp3", Type: model.MediaTypeAudio}} store := makeStore(1, media, set) svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil) - err := svc.RegenerateSetCover(ctx, 1, 1) + err := svc.RegenerateSetCover(ctx, 1, "", 1) if err == nil { t.Fatal("expected error") } @@ -1878,7 +1878,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { return nil, errors.New("probe err") }} svc := NewMediaService(store, newMockClock(), tmpDir, nil, prober) - err := svc.RegenerateSetCover(ctx, 1, 1) + err := svc.RegenerateSetCover(ctx, 1, "", 1) if err == nil { t.Fatal("expected error") } @@ -1898,28 +1898,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) { return &model.Metadata{Duration: 60}, nil }} svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober) - err := svc.RegenerateSetCover(ctx, 1, 1) - if err == nil { - t.Fatal("expected error") - } - }) - - t.Run("update set failure", func(t *testing.T) { - tmpDir := t.TempDir() - videoPath := filepath.Join(tmpDir, "video.mp4") - _ = os.WriteFile(videoPath, []byte("fake"), 0o644) - set := &model.Set{ID: 1, RootPath: "music"} - media := []model.Media{{ID: 1, SetID: 1, AbsPath: videoPath, Type: model.MediaTypeVideo}} - store := makeStore(1, media, set) - store.SetRepo.UpdateSetFunc = func(ctx context.Context, s *model.Set) error { - return errors.New("update err") - } - thumbGen := &mockThumbGenerator{} - prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) { - return &model.Metadata{Duration: 60}, nil - }} - svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober) - err := svc.RegenerateSetCover(ctx, 1, 1) + err := svc.RegenerateSetCover(ctx, 1, "", 1) if err == nil { t.Fatal("expected error") } diff --git a/internal/service/mock.go b/internal/service/mock.go index 82a25de..50e2e53 100644 --- a/internal/service/mock.go +++ b/internal/service/mock.go @@ -30,7 +30,9 @@ type MockMediaService struct { DownloadMediaFunc func(ctx context.Context, mediaID, userID int64) (*FileResult, error) GetThumbnailFunc func(ctx context.Context, mediaID, userID int64) (*FileResult, error) RegenerateThumbnailFunc func(ctx context.Context, mediaID, userID int64) error - RegenerateSetCoverFunc func(ctx context.Context, setID, userID int64) error + RegenerateSetCoverFunc func(ctx context.Context, setID int64, folder string, userID int64) error + BrowseSetFunc func(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) + GetSetCoverFunc func(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error) ToggleFavoriteFunc func(ctx context.Context, userID, mediaID int64) (bool, error) AssignTagFunc func(ctx context.Context, mediaID, userID int64, tagName string) error RemoveTagFunc func(ctx context.Context, mediaID, userID int64, tagName string) error @@ -90,12 +92,24 @@ func (m *MockMediaService) RegenerateThumbnail(ctx context.Context, mediaID, use } return nil } -func (m *MockMediaService) RegenerateSetCover(ctx context.Context, setID, userID int64) error { +func (m *MockMediaService) RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error { if m.RegenerateSetCoverFunc != nil { - return m.RegenerateSetCoverFunc(ctx, setID, userID) + return m.RegenerateSetCoverFunc(ctx, setID, folder, userID) } return nil } +func (m *MockMediaService) BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) { + if m.BrowseSetFunc != nil { + return m.BrowseSetFunc(ctx, setID, userID, parent) + } + return nil, nil +} +func (m *MockMediaService) GetSetCover(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error) { + if m.GetSetCoverFunc != nil { + return m.GetSetCoverFunc(ctx, setID, folder, userID) + } + return nil, errors.New("not implemented") +} func (m *MockMediaService) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { if m.ToggleFavoriteFunc != nil { return m.ToggleFavoriteFunc(ctx, userID, mediaID) @@ -168,6 +182,9 @@ func (m *MockMediaService) GetSharedMedia(ctx context.Context, token string) (*G } return nil, errors.New("not implemented") } +func (m *MockMediaService) GetSharedThumbnail(ctx context.Context, token string) (*FileResult, error) { + return nil, errors.New("not implemented") +} func (m *MockMediaService) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) { if m.GetNoteFunc != nil { return m.GetNoteFunc(ctx, mediaID, userID) diff --git a/internal/service/mock_test.go b/internal/service/mock_test.go index d9e20d2..c7c3fdc 100644 --- a/internal/service/mock_test.go +++ b/internal/service/mock_test.go @@ -25,7 +25,7 @@ func TestMockMediaService_Defaults(t *testing.T) { m.SoftDeleteMedia(ctx, 1, 1) m.RestoreMedia(ctx, 1, 1) m.RegenerateThumbnail(ctx, 1, 1) - m.RegenerateSetCover(ctx, 1, 1) + m.RegenerateSetCover(ctx, 1, "", 1) m.GetNote(ctx, 1, 1) m.UpsertNote(ctx, &model.Note{MediaID: 1, UserID: 1, Content: "hi"}) m.DeleteNote(ctx, 1, 1) @@ -64,7 +64,7 @@ func TestMockMediaService_WithFuncs(t *testing.T) { DownloadMediaFunc: func(ctx context.Context, mediaID, userID int64) (*FileResult, error) { return nil, nil }, GetThumbnailFunc: func(ctx context.Context, mediaID, userID int64) (*FileResult, error) { return nil, nil }, RegenerateThumbnailFunc: func(ctx context.Context, mediaID, userID int64) error { return nil }, - RegenerateSetCoverFunc: func(ctx context.Context, setID, userID int64) error { return nil }, + RegenerateSetCoverFunc: func(ctx context.Context, setID int64, folder string, userID int64) error { return nil }, ToggleFavoriteFunc: func(ctx context.Context, userID, mediaID int64) (bool, error) { return false, nil }, AssignTagFunc: func(ctx context.Context, mediaID, userID int64, tagName string) error { return nil }, RemoveTagFunc: func(ctx context.Context, mediaID, userID int64, tagName string) error { return nil }, @@ -88,7 +88,7 @@ func TestMockMediaService_WithFuncs(t *testing.T) { m.DownloadMedia(ctx, 1, 1) m.GetThumbnail(ctx, 1, 1) m.RegenerateThumbnail(ctx, 1, 1) - m.RegenerateSetCover(ctx, 1, 1) + m.RegenerateSetCover(ctx, 1, "", 1) m.ToggleFavorite(ctx, 1, 1) m.AssignTag(ctx, 1, 1, "rock") m.RemoveTag(ctx, 1, 1, "rock") diff --git a/internal/service/service.go b/internal/service/service.go index fd96b25..aa3d715 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -19,7 +19,9 @@ type MediaBrowseService interface { DownloadMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) GetThumbnail(ctx context.Context, mediaID, userID int64) (*FileResult, error) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error - RegenerateSetCover(ctx context.Context, setID, userID int64) error + RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error + BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) + GetSetCover(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error) } // MediaWriteService handles mutations such as upload, soft-delete and restore. @@ -29,6 +31,19 @@ type MediaWriteService interface { UploadMedia(ctx context.Context, setID, userID int64, filename string, data io.Reader, size int64) (*model.Media, error) } +// BrowseFolder is a named folder within a set's directory tree. +type BrowseFolder struct { + 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"` + Folders []BrowseFolder `json:"folders"` + Media []model.Media `json:"media"` +} + // GetSharedMediaResult wraps media metadata needed to render a share page. type GetSharedMediaResult struct { Media *model.Media `json:"media"` diff --git a/web/css/layout.css b/web/css/layout.css index 7f0e19e..e52a083 100644 --- a/web/css/layout.css +++ b/web/css/layout.css @@ -103,6 +103,31 @@ body { max-width: 100%; } +/* Breadcrumb bar */ +.breadcrumb-bar { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; + font-size: 0.85rem; +} +.breadcrumb-bar.hidden { display: none !important; } +.breadcrumb-bar button { + background: transparent; + border: none; + color: var(--accent); + padding: 0.15rem 0.25rem; + cursor: pointer; + font: inherit; + border-radius: var(--radius-sm); +} +.breadcrumb-bar button:hover { background: var(--accent-soft); } +.breadcrumb-bar .breadcrumb-sep { color: var(--text-muted); user-select: none; } + +/* Folder card placeholder */ +.folder-card .thumb-wrap .placeholder { font-size: 3rem; } + /* Media grid */ .media-grid { display: grid; diff --git a/web/css/player.css b/web/css/player.css index 7c7b189..5628dfd 100644 --- a/web/css/player.css +++ b/web/css/player.css @@ -15,6 +15,9 @@ .page.has-sidebar .player { inset: auto 0 0 var(--sidebar-width); } +.page.has-sidebar .player.collapsed { + inset: auto auto 0 0; +} .player.is-fullscreen { inset: 0; background: var(--player-stage-bg); diff --git a/web/index.html b/web/index.html index e2b58a4..c5a124f 100644 --- a/web/index.html +++ b/web/index.html @@ -63,6 +63,9 @@ <p class="text-muted text-sm">Press <kbd>s</kbd> to browse sets, <kbd>/</kbd> to search, or <kbd>?</kbd> for help.</p> </div> + <!-- Breadcrumb bar for folder navigation --> + <div id="breadcrumb-bar" class="breadcrumb-bar hidden"></div> + <!-- Media grid: hidden until a set is loaded or search is active --> <div id="media-grid" class="media-grid hidden" tabindex="0" aria-label="Media grid"></div> </div> @@ -113,11 +116,12 @@ <tr><td><kbd>k</kbd> <kbd>j</kbd></td><td>Navigate media list (vim-style)</td></tr> <tr><td><kbd>←</kbd> <kbd>→</kbd></td><td>Switch sets / pages</td></tr> <tr><td><kbd>h</kbd> <kbd>l</kbd></td><td>Switch sets / pages (vim-style)</td></tr> - <tr><td><kbd>Enter</kbd></td><td>Open selected media</td></tr> + <tr><td><kbd>Enter</kbd></td><td>Open selected media / enter folder</td></tr> <tr><td><kbd>Space</kbd> <kbd>p</kbd></td><td>Play / pause</td></tr> <tr><td><kbd>f</kbd></td><td>Toggle fullscreen</td></tr> <tr><td><kbd>c</kbd></td><td>Collapse / expand player stage</td></tr> <tr><td><kbd>Esc</kbd></td><td>Exit fullscreen, deselect item</td></tr> + <tr><td><kbd>Backspace</kbd></td><td>Go up one folder</td></tr> <tr><td><kbd>r</kbd></td><td>Toggle shuffle</td></tr> <tr><td><kbd>s</kbd></td><td>Toggle sets sidebar</td></tr> <tr><td><kbd>/</kbd></td><td>Focus search bar</td></tr> diff --git a/web/js/api.js b/web/js/api.js index dc8ee8d..8e80a0d 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -58,7 +58,18 @@ export const API = { tags: () => api('/api/tags'), download: (id) => api(`/api/media/${id}/download`), regenThumbnail: (id) => api(`/api/media/${id}/thumbnail`, { method: 'POST' }), - regenCover: (setId) => api(`/api/sets/${setId}/cover`, { method: 'POST' }), + browse: (setId, parent) => { + const params = new URLSearchParams(); + if (parent) params.set('parent', parent); + const s = params.toString(); + return api(`/api/sets/${setId}/browse${s ? '?' + s : ''}`); + }, + regenCover: (setId, folder = '') => { + const params = new URLSearchParams(); + if (folder) params.set('folder', folder); + const s = params.toString(); + return api(`/api/sets/${setId}/cover${s ? '?' + s : ''}`, { method: 'POST' }); + }, upload: (setId, formData) => api(`/api/sets/${setId}/upload`, { method: 'POST', body: formData }), addTag: (id, tag) => api(`/api/media/${id}/tags`, { method: 'POST', body: { tag } }), removeTag: (id, tag) => api(`/api/media/${id}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' }), diff --git a/web/js/app.js b/web/js/app.js index 2d0e15d..2bd6aef 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -73,6 +73,7 @@ async function initApp() { initSearch({ onChange: (q) => { state.filters.search = q; + state.folderPath = ''; loadMedia(); }, input: document.getElementById('search-input'), @@ -96,7 +97,13 @@ async function initApp() { toggleSetSelect: () => toggleSetSelection(), enter: () => { const el = currentElement(); - if (el) { selectByElement(el); playSelected(); } + if (!el) return; + selectByElement(el); + if (el.classList.contains('folder-card')) { + enterFolder(el.dataset.name); + } else { + playSelected(); + } }, playPause: () => togglePlay(), fullscreen: () => toggleFullscreen(), @@ -110,6 +117,7 @@ async function initApp() { download: downloadSelected, help: toggleHelp, toolbar: toggleToolbar, + backspace: () => { navigateBack(); }, sidebar: toggleSidebar, upload: () => showUpload(), focusMinDuration: () => focusFilter('filter-min-duration'), @@ -122,15 +130,16 @@ async function initApp() { initHelp(); // Filters - document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; loadMedia(); }); + document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; state.folderPath = ''; loadMedia(); }); document.getElementById('filter-favorites')?.addEventListener('click', (e) => { state.filters.favorites = !state.filters.favorites; e.target.classList.toggle('active', state.filters.favorites); + state.folderPath = ''; loadMedia(); }); - document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; loadMedia(); }); - document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; loadMedia(); }); - document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; loadMedia(); }); + document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; state.folderPath = ''; loadMedia(); }); + document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; state.folderPath = ''; loadMedia(); }); + document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; state.folderPath = ''; loadMedia(); }); document.getElementById('filter-toggle')?.addEventListener('click', () => { document.getElementById('filter-advanced')?.classList.toggle('hidden'); }); @@ -162,6 +171,15 @@ async function initApp() { if (el) { selectByElement(el); playSelected(); } }); + // Folder navigation + document.getElementById('media-grid')?.addEventListener('click', (e) => { + const folder = e.target.closest('.folder-card'); + if (folder) { + e.stopPropagation(); + enterFolder(folder.dataset.name); + } + }); + // Tags modal close document.getElementById('tags-close')?.addEventListener('click', closeTagsModal); document.getElementById('tags-modal')?.addEventListener('click', (e) => { if (e.target === document.getElementById('tags-modal')) closeTagsModal(); }); @@ -186,6 +204,7 @@ function renderSets() { const id = parseInt(row.dataset.id, 10); state.selectedSetIds = [id]; state.selectedSetId = id; + state.folderPath = ''; updateSetRowsUI(); loadMedia(); }); @@ -229,6 +248,7 @@ function toggleSetSelection() { } else { state.selectedSetId = null; } + state.folderPath = ''; updateSetRowsUI(); loadMedia(); } @@ -244,6 +264,7 @@ function setSetByDelta(dx) { const nextIdx = (idx + dx + state.sets.length) % state.sets.length; state.selectedSetId = state.sets[nextIdx].id; state.selectedSetIds = [state.selectedSetId]; + state.folderPath = ''; setActiveSet(state.selectedSetId); loadMedia(); } @@ -255,31 +276,154 @@ async function loadMedia() { grid.classList.remove('hidden'); hint?.classList.add('hidden'); grid.innerHTML = '<p class="text-muted text-sm grid-full">Loading...</p>'; + + const resultCount = document.getElementById('result-count'); + const breadcrumb = document.getElementById('breadcrumb-bar'); + try { const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : ''; - const singleSetId = state.selectedSetIds.length === 1 ? String(state.selectedSetIds[0]) : (state.selectedSetId ?? ''); - const params = { - set_id: setIds ? '' : singleSetId, - set_ids: setIds, - type: state.filters.type, - search: state.filters.search, - favorites: state.filters.favorites ? 'true' : '', - tags: state.filters.tags || '', - min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '', - max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '', - sort: isShuffle() ? 'random' : 'name', - limit: '200', - }; - const data = await API.media(params); - const list = Array.isArray(data) ? data : data?.media || []; - setMedia(list); - renderGrid(list); - document.getElementById('result-count').textContent = `${list.length} items`; + const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null; + + // When viewing a single set with no filters/search, use the browse endpoint + // so that subfolders are presented as folders to navigate into. + if (singleSetId && !setIds && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) { + const data = await API.browse(singleSetId, state.folderPath); + updateBreadcrumb(data.current_path); + setMedia(data.media || []); + renderBrowse(data); + const total = (data.media?.length || 0) + (data.folders?.length || 0); + resultCount.textContent = `${total} items`; + } else { + breadcrumb?.classList.add('hidden'); + const params = { + set_id: setIds ? '' : String(singleSetId || state.selectedSetId || ''), + set_ids: setIds, + type: state.filters.type, + search: state.filters.search, + favorites: state.filters.favorites ? 'true' : '', + tags: state.filters.tags || '', + min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '', + max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '', + sort: isShuffle() ? 'random' : 'name', + limit: '200', + }; + const data = await API.media(params); + const list = Array.isArray(data) ? data : data?.media || []; + setMedia(list); |
