diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/handlers_test.go | 12 | ||||
| -rw-r--r-- | internal/repository/mock.go | 78 | ||||
| -rw-r--r-- | internal/repository/playback_accumulator.go | 9 | ||||
| -rw-r--r-- | internal/repository/playback_progress.go | 55 | ||||
| -rw-r--r-- | internal/repository/repository.go | 8 | ||||
| -rw-r--r-- | internal/repository/sqlite_test.go | 105 |
6 files changed, 255 insertions, 12 deletions
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index cf109c3..4ae98e6 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -1455,15 +1455,27 @@ func (m *mockPingStore) UpsertProgress(ctx context.Context, progress *model.Play func (m *mockPingStore) GetProgress(ctx context.Context, userID, mediaID int64) (*model.PlaybackProgress, error) { return m.store.GetProgress(ctx, userID, mediaID) } +func (m *mockPingStore) DeleteProgress(ctx context.Context, userID, mediaID int64) error { + return m.store.DeleteProgress(ctx, userID, mediaID) +} +func (m *mockPingStore) MarkFinished(ctx context.Context, userID, mediaID int64) error { + return m.store.MarkFinished(ctx, userID, mediaID) +} func (m *mockPingStore) ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) { return m.store.ListProgressByUser(ctx, userID) } +func (m *mockPingStore) ListInProgressMedia(ctx context.Context, userID int64, filter repository.MediaFilter) ([]model.Media, error) { + return m.store.ListInProgressMedia(ctx, userID, filter) +} func (m *mockPingStore) UpsertAccumulator(ctx context.Context, acc *model.PlaybackAccumulator) error { return m.store.UpsertAccumulator(ctx, acc) } func (m *mockPingStore) GetAccumulator(ctx context.Context, sessionID string, mediaID int64) (*model.PlaybackAccumulator, error) { return m.store.GetAccumulator(ctx, sessionID, mediaID) } +func (m *mockPingStore) DeleteAccumulatorByMedia(ctx context.Context, mediaID int64) error { + return m.store.DeleteAccumulatorByMedia(ctx, mediaID) +} func (m *mockPingStore) CreateSession(ctx context.Context, session *model.Session) error { return m.store.CreateSession(ctx, session) } diff --git a/internal/repository/mock.go b/internal/repository/mock.go index 19e5bf2..994fca5 100644 --- a/internal/repository/mock.go +++ b/internal/repository/mock.go @@ -246,11 +246,26 @@ func (m *MockStore) GetProgress(ctx context.Context, userID, mediaID int64) (*mo return m.PlaybackProgressRepo.GetProgress(ctx, userID, mediaID) } +// DeleteProgress implements PlaybackProgressRepo. +func (m *MockStore) DeleteProgress(ctx context.Context, userID, mediaID int64) error { + return m.PlaybackProgressRepo.DeleteProgress(ctx, userID, mediaID) +} + +// MarkFinished implements PlaybackProgressRepo. +func (m *MockStore) MarkFinished(ctx context.Context, userID, mediaID int64) error { + return m.PlaybackProgressRepo.MarkFinished(ctx, userID, mediaID) +} + // ListProgressByUser implements PlaybackProgressRepo. func (m *MockStore) ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) { return m.PlaybackProgressRepo.ListProgressByUser(ctx, userID) } +// ListInProgressMedia implements PlaybackProgressRepo. +func (m *MockStore) ListInProgressMedia(ctx context.Context, userID int64, filter MediaFilter) ([]model.Media, error) { + return m.PlaybackProgressRepo.ListInProgressMedia(ctx, userID, filter) +} + // UpsertAccumulator implements PlaybackAccumulatorRepo. func (m *MockStore) UpsertAccumulator(ctx context.Context, acc *model.PlaybackAccumulator) error { return m.PlaybackAccumulatorRepo.UpsertAccumulator(ctx, acc) @@ -261,6 +276,11 @@ func (m *MockStore) GetAccumulator(ctx context.Context, sessionID string, mediaI return m.PlaybackAccumulatorRepo.GetAccumulator(ctx, sessionID, mediaID) } +// DeleteAccumulatorByMedia implements PlaybackAccumulatorRepo. +func (m *MockStore) DeleteAccumulatorByMedia(ctx context.Context, mediaID int64) error { + return m.PlaybackAccumulatorRepo.DeleteAccumulatorByMedia(ctx, mediaID) +} + // CreateSession implements SessionRepo. func (m *MockStore) CreateSession(ctx context.Context, session *model.Session) error { return m.SessionRepo.CreateSession(ctx, session) @@ -690,9 +710,12 @@ func (m *MockFavoriteRepo) ListFavoritesByUser(ctx context.Context, userID int64 // MockPlaybackProgressRepo is a fake PlaybackProgressRepo. type MockPlaybackProgressRepo struct { - UpsertProgressFunc func(ctx context.Context, progress *model.PlaybackProgress) error - GetProgressFunc func(ctx context.Context, userID, mediaID int64) (*model.PlaybackProgress, error) - ListProgressByUserFunc func(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) + UpsertProgressFunc func(ctx context.Context, progress *model.PlaybackProgress) error + GetProgressFunc func(ctx context.Context, userID, mediaID int64) (*model.PlaybackProgress, error) + DeleteProgressFunc func(ctx context.Context, userID, mediaID int64) error + MarkFinishedFunc func(ctx context.Context, userID, mediaID int64) error + ListProgressByUserFunc func(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) + ListInProgressMediaFunc func(ctx context.Context, userID int64, filter MediaFilter) ([]model.Media, error) } // UpsertProgress calls UpsertProgressFunc or returns nil. @@ -711,6 +734,22 @@ func (m *MockPlaybackProgressRepo) GetProgress(ctx context.Context, userID, medi return nil, nil } +// DeleteProgress calls DeleteProgressFunc or returns nil. +func (m *MockPlaybackProgressRepo) DeleteProgress(ctx context.Context, userID, mediaID int64) error { + if m.DeleteProgressFunc != nil { + return m.DeleteProgressFunc(ctx, userID, mediaID) + } + return nil +} + +// MarkFinished calls MarkFinishedFunc or returns nil. +func (m *MockPlaybackProgressRepo) MarkFinished(ctx context.Context, userID, mediaID int64) error { + if m.MarkFinishedFunc != nil { + return m.MarkFinishedFunc(ctx, userID, mediaID) + } + return nil +} + // ListProgressByUser calls ListProgressByUserFunc or returns nil. func (m *MockPlaybackProgressRepo) ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) { if m.ListProgressByUserFunc != nil { @@ -719,10 +758,19 @@ func (m *MockPlaybackProgressRepo) ListProgressByUser(ctx context.Context, userI return nil, nil } +// ListInProgressMedia calls ListInProgressMediaFunc or returns nil. +func (m *MockPlaybackProgressRepo) ListInProgressMedia(ctx context.Context, userID int64, filter MediaFilter) ([]model.Media, error) { + if m.ListInProgressMediaFunc != nil { + return m.ListInProgressMediaFunc(ctx, userID, filter) + } + return nil, nil +} + // MockPlaybackAccumulatorRepo is a fake PlaybackAccumulatorRepo. type MockPlaybackAccumulatorRepo struct { - UpsertAccumulatorFunc func(ctx context.Context, acc *model.PlaybackAccumulator) error - GetAccumulatorFunc func(ctx context.Context, sessionID string, mediaID int64) (*model.PlaybackAccumulator, error) + UpsertAccumulatorFunc func(ctx context.Context, acc *model.PlaybackAccumulator) error + GetAccumulatorFunc func(ctx context.Context, sessionID string, mediaID int64) (*model.PlaybackAccumulator, error) + DeleteAccumulatorByMediaFunc func(ctx context.Context, mediaID int64) error } // UpsertAccumulator calls UpsertAccumulatorFunc or returns nil. @@ -741,6 +789,14 @@ func (m *MockPlaybackAccumulatorRepo) GetAccumulator(ctx context.Context, sessio return nil, nil } +// DeleteAccumulatorByMedia calls DeleteAccumulatorByMediaFunc or returns nil. +func (m *MockPlaybackAccumulatorRepo) DeleteAccumulatorByMedia(ctx context.Context, mediaID int64) error { + if m.DeleteAccumulatorByMediaFunc != nil { + return m.DeleteAccumulatorByMediaFunc(ctx, mediaID) + } + return nil +} + // MockSessionRepo is a fake SessionRepo. type MockSessionRepo struct { CreateSessionFunc func(ctx context.Context, session *model.Session) error @@ -980,13 +1036,13 @@ type MockPodcastRepo struct { ListFeedsFunc func(ctx context.Context) ([]model.PodcastFeed, error) ListFeedsNeedingCheckFunc func(ctx context.Context, now, before time.Time) ([]model.PodcastFeed, error) - CreateEpisodeFunc func(ctx context.Context, episode *model.PodcastEpisode) (int64, error) - GetEpisodeByIDFunc func(ctx context.Context, id int64) (*model.PodcastEpisode, error) - GetEpisodeByGUIDFunc func(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) - ListEpisodesByFeedFunc func(ctx context.Context, feedID int64, limit, offset int) ([]model.PodcastEpisode, error) + CreateEpisodeFunc func(ctx context.Context, episode *model.PodcastEpisode) (int64, error) + GetEpisodeByIDFunc func(ctx context.Context, id int64) (*model.PodcastEpisode, error) + GetEpisodeByGUIDFunc func(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) + ListEpisodesByFeedFunc func(ctx context.Context, feedID int64, limit, offset int) ([]model.PodcastEpisode, error) ListEpisodesByFeedIDsWithStatusFunc func(ctx context.Context, userID int64, feedIDs []int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error) - UpdateEpisodeMediaFunc func(ctx context.Context, episodeID, mediaID int64, fileName string) error - DeleteEpisodesByFeedFunc func(ctx context.Context, feedID int64) error + UpdateEpisodeMediaFunc func(ctx context.Context, episodeID, mediaID int64, fileName string) error + DeleteEpisodesByFeedFunc func(ctx context.Context, feedID int64) error UpsertEpisodeProgressFunc func(ctx context.Context, status *model.PodcastStatus) error GetEpisodeProgressFunc func(ctx context.Context, userID, episodeID int64) (*model.PodcastStatus, error) diff --git a/internal/repository/playback_accumulator.go b/internal/repository/playback_accumulator.go index bb1fde4..ea2bcf0 100644 --- a/internal/repository/playback_accumulator.go +++ b/internal/repository/playback_accumulator.go @@ -36,3 +36,12 @@ func (s *SQLite) GetAccumulator(ctx context.Context, sessionID string, mediaID i a.Counted = counted != 0 return &a, nil } + +// DeleteAccumulatorByMedia removes all playback accumulators for a media item. +func (s *SQLite) DeleteAccumulatorByMedia(ctx context.Context, mediaID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM playback_accumulator WHERE media_id = ?`, mediaID) + if err != nil { + return fmt.Errorf("delete accumulator by media: %w", err) + } + return nil +} diff --git a/internal/repository/playback_progress.go b/internal/repository/playback_progress.go index 046598a..a2f753e 100644 --- a/internal/repository/playback_progress.go +++ b/internal/repository/playback_progress.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "strings" "codeberg.org/snonux/player/internal/model" ) @@ -35,6 +36,27 @@ func (s *SQLite) GetProgress(ctx context.Context, userID, mediaID int64) (*model return &p, nil } +// DeleteProgress removes playback progress for a user and media. +func (s *SQLite) DeleteProgress(ctx context.Context, userID, mediaID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM playback_progress WHERE user_id = ? AND media_id = ?`, userID, mediaID) + if err != nil { + return fmt.Errorf("delete progress: %w", err) + } + return nil +} + +// MarkFinished marks playback progress finished for a user and media. +func (s *SQLite) MarkFinished(ctx context.Context, userID, mediaID int64) error { + _, err := s.db.ExecContext(ctx, + `UPDATE playback_progress SET finished = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND media_id = ?`, + userID, mediaID, + ) + if err != nil { + return fmt.Errorf("mark finished: %w", err) + } + return nil +} + // ListProgressByUser returns all progress records for a user. func (s *SQLite) ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) { rows, err := s.db.QueryContext(ctx, @@ -53,3 +75,36 @@ func (s *SQLite) ListProgressByUser(ctx context.Context, userID int64) ([]model. } return pp, rows.Err() } + +// ListInProgressMedia returns unfinished, non-deleted media with saved progress for a user. +func (s *SQLite) ListInProgressMedia(ctx context.Context, userID int64, filter MediaFilter) ([]model.Media, error) { + args := []any{userID} + conds := []string{`pp.user_id = ?`, `pp.finished = 0`, `media.deleted_at IS NULL`} + query := `SELECT media.id, media.set_id, media.rel_path, media.file_name, media.abs_path, media.type, media.duration, media.codec, media.resolution, media.bitrate, media.file_size_bytes, media.width, media.height, media.exif_camera, media.exif_lens, media.exif_date, media.exif_iso, media.exif_f_number, media.exif_exposure, media.exif_focal_length, media.thumbnail_path, media.play_count, media.deleted_at, media.created_at FROM playback_progress pp INNER JOIN media ON media.id = pp.media_id` + + if len(filter.AllowedSetIDs) > 0 { + conds = append(conds, "media.set_id IN ("+placeholders(len(filter.AllowedSetIDs))+")") + for _, id := range filter.AllowedSetIDs { + args = append(args, id) + } + } + + query += " WHERE " + strings.Join(conds, " AND ") + query += " ORDER BY pp.updated_at DESC" + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list in-progress media: %w", err) + } + defer rows.Close() + + var media []model.Media + for rows.Next() { + m, err := scanMedia(rows) + if err != nil { + return nil, err + } + media = append(media, *m) + } + return media, rows.Err() +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 4e863b3..e7ff672 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -260,8 +260,14 @@ type PlaybackProgressRepo interface { UpsertProgress(ctx context.Context, progress *model.PlaybackProgress) error // GetProgress returns a user's playback position for a media item. GetProgress(ctx context.Context, userID, mediaID int64) (*model.PlaybackProgress, error) + // DeleteProgress removes a user's playback position for a media item. + DeleteProgress(ctx context.Context, userID, mediaID int64) error + // MarkFinished marks a user's playback position as finished. + MarkFinished(ctx context.Context, userID, mediaID int64) error // ListProgressByUser returns all saved playback positions for a user. ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) + // ListInProgressMedia returns unfinished media with saved playback positions. + ListInProgressMedia(ctx context.Context, userID int64, filter MediaFilter) ([]model.Media, error) } // PlaybackAccumulatorRepo manages the 60s playback counter rule. @@ -270,6 +276,8 @@ type PlaybackAccumulatorRepo interface { UpsertAccumulator(ctx context.Context, acc *model.PlaybackAccumulator) error // GetAccumulator returns the accumulator for a session and media item. GetAccumulator(ctx context.Context, sessionID string, mediaID int64) (*model.PlaybackAccumulator, error) + // DeleteAccumulatorByMedia removes all accumulators for a media item. + DeleteAccumulatorByMedia(ctx context.Context, mediaID int64) error } // SessionRepo manages browser sessions. diff --git a/internal/repository/sqlite_test.go b/internal/repository/sqlite_test.go index bbe1330..adb24d7 100644 --- a/internal/repository/sqlite_test.go +++ b/internal/repository/sqlite_test.go @@ -464,7 +464,7 @@ func TestSQLite_PlaybackProgressRepo(t *testing.T) { uid, _ := s.CreateUser(ctx, &model.User{Username: "u", PasswordHash: "h", CreatedAt: now}) sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now}) mid, _ := s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) - if err := s.UpsertProgress(ctx, &model.PlaybackProgress{UserID: uid, MediaID: mid, PositionSeconds: 42, Finished: true, UpdatedAt: now}); err != nil { + if err := s.UpsertProgress(ctx, &model.PlaybackProgress{UserID: uid, MediaID: mid, PositionSeconds: 42, UpdatedAt: now}); err != nil { t.Fatalf("upsert: %v", err) } p, err := s.GetProgress(ctx, uid, mid) @@ -474,6 +474,16 @@ func TestSQLite_PlaybackProgressRepo(t *testing.T) { if p.PositionSeconds != 42 { t.Fatalf("expected 42, got %f", p.PositionSeconds) } + if p.Finished { + t.Fatal("expected unfinished") + } + if err := s.MarkFinished(ctx, uid, mid); err != nil { + t.Fatalf("mark finished: %v", err) + } + p, err = s.GetProgress(ctx, uid, mid) + if err != nil { + t.Fatalf("get after mark finished: %v", err) + } if !p.Finished { t.Fatal("expected finished") } @@ -484,6 +494,55 @@ func TestSQLite_PlaybackProgressRepo(t *testing.T) { if !pp[0].Finished { t.Fatal("expected listed progress to be finished") } + if err := s.DeleteProgress(ctx, uid, mid); err != nil { + t.Fatalf("delete progress: %v", err) + } + p, err = s.GetProgress(ctx, uid, mid) + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if p != nil { + t.Fatal("expected nil progress after delete") + } + }, + }, + { + name: "list in-progress media respects allowed sets and excludes finished and deleted", + run: func(t *testing.T, ctx context.Context, s *SQLite) { + now := time.Now().Truncate(time.Second) + uid, _ := s.CreateUser(ctx, &model.User{Username: "u", PasswordHash: "h", CreatedAt: now}) + setAllowed, _ := s.CreateSet(ctx, &model.Set{Name: "allowed", RootPath: "/allowed", CreatedAt: now}) + setOther, _ := s.CreateSet(ctx, &model.Set{Name: "other", RootPath: "/other", CreatedAt: now}) + keepID, _ := s.CreateMedia(ctx, &model.Media{SetID: setAllowed, RelPath: "keep.mp4", FileName: "keep.mp4", AbsPath: "/allowed/keep.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) + finishedID, _ := s.CreateMedia(ctx, &model.Media{SetID: setAllowed, RelPath: "finished.mp4", FileName: "finished.mp4", AbsPath: "/allowed/finished.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) + deletedID, _ := s.CreateMedia(ctx, &model.Media{SetID: setAllowed, RelPath: "deleted.mp4", FileName: "deleted.mp4", AbsPath: "/allowed/deleted.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) + otherID, _ := s.CreateMedia(ctx, &model.Media{SetID: setOther, RelPath: "other.mp4", FileName: "other.mp4", AbsPath: "/other/other.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) + + progress := []model.PlaybackProgress{ + {UserID: uid, MediaID: keepID, PositionSeconds: 10, UpdatedAt: now.Add(3 * time.Second)}, + {UserID: uid, MediaID: finishedID, PositionSeconds: 20, Finished: true, UpdatedAt: now.Add(2 * time.Second)}, + {UserID: uid, MediaID: deletedID, PositionSeconds: 30, UpdatedAt: now.Add(time.Second)}, + {UserID: uid, MediaID: otherID, PositionSeconds: 40, UpdatedAt: now}, + } + for i := range progress { + if err := s.UpsertProgress(ctx, &progress[i]); err != nil { + t.Fatalf("upsert progress %d: %v", i, err) + } + } + if err := s.SoftDeleteMedia(ctx, deletedID); err != nil { + t.Fatalf("soft delete: %v", err) + } + + media, err := s.ListInProgressMedia(ctx, uid, MediaFilter{AllowedSetIDs: []int64{setAllowed}}) + if err != nil { + t.Fatalf("list in-progress media: %v", err) + } + if len(media) != 1 { + t.Fatalf("expected 1 in-progress media, got %d: %+v", len(media), media) + } + if media[0].ID != keepID { + t.Fatalf("expected media %d, got %d", keepID, media[0].ID) + } }, }, } @@ -523,6 +582,50 @@ func TestSQLite_PlaybackAccumulatorRepo(t *testing.T) { } }, }, + { + name: "delete by media deletes all sessions for media only", + run: func(t *testing.T, ctx context.Context, s *SQLite) { + now := time.Now().Truncate(time.Second) + uid, _ := s.CreateUser(ctx, &model.User{Username: "u", PasswordHash: "h", CreatedAt: now}) + for _, sessionID := range []string{"sess1", "sess2"} { + if err := s.CreateSession(ctx, &model.Session{ID: sessionID, UserID: uid, ExpiresAt: now.Add(time.Hour), CreatedAt: now}); err != nil { + t.Fatalf("create session %s: %v", sessionID, err) + } + } + sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now}) + mediaID, _ := s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) + otherMediaID, _ := s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "b.mp4", FileName: "b.mp4", AbsPath: "/s/b.mp4", Type: model.MediaTypeVideo, CreatedAt: now}) + accs := []model.PlaybackAccumulator{ + {SessionID: "sess1", MediaID: mediaID, LastPosition: 10, AccumulatedSeconds: 20, UpdatedAt: now}, + {SessionID: "sess2", MediaID: mediaID, LastPosition: 11, AccumulatedSeconds: 21, UpdatedAt: now}, + {SessionID: "sess1", MediaID: otherMediaID, LastPosition: 12, AccumulatedSeconds: 22, UpdatedAt: now}, + } + for i := range accs { + if err := s.UpsertAccumulator(ctx, &accs[i]); err != nil { + t.Fatalf("upsert accumulator %d: %v", i, err) + } + } + if err := s.DeleteAccumulatorByMedia(ctx, mediaID); err != nil { + t.Fatalf("delete accumulator by media: %v", err) + } + for _, sessionID := range []string{"sess1", "sess2"} { + acc, err := s.GetAccumulator(ctx, sessionID, mediaID) + if err != nil { + t.Fatalf("get deleted accumulator %s: %v", sessionID, err) + } + if acc != nil { + t.Fatalf("expected accumulator %s/%d to be deleted", sessionID, mediaID) + } + } + acc, err := s.GetAccumulator(ctx, "sess1", otherMediaID) + if err != nil { + t.Fatalf("get other accumulator: %v", err) + } + if acc == nil { + t.Fatal("expected other media accumulator to remain") + } + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { |
