summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-02 11:45:27 +0300
committerPaul Buetow <paul@buetow.org>2026-05-02 11:45:27 +0300
commit23d017ff90abfe7aeea7fb43619d7469e3ec0c2a (patch)
tree95380288fa9d7ee362e43f5f91dcfd3370a79469
parent5275a0a4cec129bc1ea66beab934bf472f7a7fd2 (diff)
more on this
-rw-r--r--internal/api/handlers_media.go53
-rw-r--r--internal/api/handlers_more_test.go2
-rw-r--r--internal/api/server.go4
-rw-r--r--internal/scanner/scanner.go1
-rw-r--r--internal/service/media_browse.go154
-rw-r--r--internal/service/media_test.go37
-rw-r--r--internal/service/mock.go23
-rw-r--r--internal/service/mock_test.go6
-rw-r--r--internal/service/service.go17
-rw-r--r--web/css/layout.css25
-rw-r--r--web/css/player.css3
-rw-r--r--web/index.html6
-rw-r--r--web/js/api.js13
-rw-r--r--web/js/app.js190
-rw-r--r--web/js/keyboard.js1
-rw-r--r--web/js/selection.js2
-rw-r--r--web/js/state.js1
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);