From 23d017ff90abfe7aeea7fb43619d7469e3ec0c2a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 2 May 2026 11:45:27 +0300 Subject: more on this --- internal/api/handlers_media.go | 53 ++++++++++- internal/api/handlers_more_test.go | 2 +- internal/api/server.go | 4 +- internal/scanner/scanner.go | 1 + internal/service/media_browse.go | 154 ++++++++++++++++++++++++++++-- internal/service/media_test.go | 37 ++------ internal/service/mock.go | 23 ++++- internal/service/mock_test.go | 6 +- internal/service/service.go | 17 +++- web/css/layout.css | 25 +++++ web/css/player.css | 3 + web/index.html | 6 +- web/js/api.js | 13 ++- web/js/app.js | 190 ++++++++++++++++++++++++++++++++----- web/js/keyboard.js | 1 + web/js/selection.js | 2 +- 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 @@

Press s to browse sets, / to search, or ? for help.

+ + + @@ -113,11 +116,12 @@ k jNavigate media list (vim-style) Switch sets / pages h lSwitch sets / pages (vim-style) - EnterOpen selected media + EnterOpen selected media / enter folder Space pPlay / pause fToggle fullscreen cCollapse / expand player stage EscExit fullscreen, deselect item + BackspaceGo up one folder rToggle shuffle sToggle sets sidebar /Focus search bar 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 = '

Loading...

'; + + 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); + renderGrid(list); + resultCount.textContent = `${list.length} items`; + } } catch (err) { grid.innerHTML = `

${escapeHtml(err.message)}

`; } } +function updateBreadcrumb(currentPath) { + const el = document.getElementById('breadcrumb-bar'); + if (!el) return; + const setName = state.sets.find((s) => s.id === state.selectedSetId)?.name || 'Set'; + const parts = currentPath ? currentPath.split('/') : []; + + let html = ``; + let accumulated = ''; + for (const part of parts) { + accumulated = accumulated ? `${accumulated}/${part}` : part; + html += ` / `; + } + el.innerHTML = html; + el.classList.remove('hidden'); + el.querySelectorAll('button').forEach((b) => { + b.addEventListener('click', () => { + state.folderPath = b.dataset.folder; + loadMedia(); + }); + }); +} + +function enterFolder(name) { + state.folderPath = state.folderPath ? `${state.folderPath}/${name}` : name; + loadMedia(); +} + +function navigateBack() { + if (!state.folderPath) return; + const last = state.folderPath.lastIndexOf('/'); + if (last < 0) { + state.folderPath = ''; + } else { + state.folderPath = state.folderPath.substring(0, last); + } + loadMedia(); +} + +function renderBrowse(data) { + const grid = document.getElementById('media-grid'); + if (!grid) return; + const folders = data.folders || []; + const media = data.media || []; + if (!folders.length && !media.length) { + grid.innerHTML = '

Folder is empty.

'; + return; + } + const folderHtml = folders.map((f, i) => renderFolder(f, i)).join(''); + const mediaHtml = media.map((m, i) => renderItem(m, i + folders.length)).join(''); + grid.innerHTML = folderHtml + mediaHtml; + + grid.querySelectorAll('.folder-card').forEach((el) => { + el.addEventListener('click', () => enterFolder(el.dataset.name)); + }); + + grid.querySelectorAll('.folder-card [data-action="regen-folder-cover"]').forEach((b) => { + b.addEventListener('click', async (e) => { + e.stopPropagation(); + const setId = state.selectedSetId; + if (!setId) return; + const name = b.closest('.folder-card')?.dataset.name; + const folder = state.folderPath ? `${state.folderPath}/${name}` : name; + try { await API.regenCover(setId, folder); toast('Folder cover regenerated'); loadMedia(); } + catch (err) { toast(err.message || 'Cover failed', 'error'); } + }); + }); + + grid.querySelectorAll('.media-card, .media-row').forEach((el) => { + el.addEventListener('click', () => { selectByElement(el); }); + const playBtn = el.querySelector('[data-action="play"]'); + const favBtn = el.querySelector('[data-action="favorite"]'); + const noteBtn = el.querySelector('[data-action="notes"]'); + const downloadBtn = el.querySelector('[data-action="download"]'); + const tagBtn = el.querySelector('[data-action="tags"]'); + const thumbBtn = el.querySelector('[data-action="regen-thumb"]'); + playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); }); + favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); }); + noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); }); + downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); }); + tagBtn?.addEventListener('click', (e) => { e.stopPropagation(); openTagsForElement(el); }); + thumbBtn?.addEventListener('click', (e) => { e.stopPropagation(); regenThumb(el.dataset.id); }); + }); +} + +function renderFolder(folder, index) { + const name = typeof folder === 'string' ? folder : folder.name; + const hasCover = typeof folder === 'object' && folder?.has_cover; + const coverImg = hasCover + ? `` + : null; + return ` +
+
+ ${hasCover ? coverImg : '📁'} +
+ +
+
+
+
${escapeHtml(name)}
+
Folder
+
+
+ `; +} + function renderGrid(items) { const grid = document.getElementById('media-grid'); if (!grid) return; diff --git a/web/js/keyboard.js b/web/js/keyboard.js index 0e937b0..62dc652 100644 --- a/web/js/keyboard.js +++ b/web/js/keyboard.js @@ -76,6 +76,7 @@ export function initKeyboard(handlers) { break; case 'f': handlers.fullscreen?.(e); break; case 'Escape': handlers.escape?.(e); break; + case 'Backspace': handlers.backspace?.(e); break; case 'r': handlers.shuffle?.(e); break; case 's': handlers.sidebar?.(e); break; case '/': diff --git a/web/js/selection.js b/web/js/selection.js index c83fd81..6ee4a58 100644 --- a/web/js/selection.js +++ b/web/js/selection.js @@ -11,7 +11,7 @@ export function initSelection() { } function refreshCards() { - return Array.from(document.querySelectorAll('.media-card, .media-row')); + return Array.from(document.querySelectorAll('.media-card, .media-row, .folder-card')); } // Compute grid geometry from the rendered cards. diff --git a/web/js/state.js b/web/js/state.js index 6597c00..e8cc632 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -6,6 +6,7 @@ export const state = { media: [], filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '' }, isAdmin: false, + folderPath: '', // current subfolder path within the selected set }; export function getState() { return state; } -- cgit v1.2.3