diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-29 00:18:40 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-29 00:18:40 +0300 |
| commit | 655b01e5b19b72dc133e7b24c658e0ec3f611bb0 (patch) | |
| tree | fbfcd935fe6e447a308a37b80fcf2752f74a2512 /internal | |
| parent | 5b5978dabf2011a64720998cd03cbb01c706475d (diff) | |
feat: SQLite schema migrations, repository interfaces, and concrete SQLite implementations with :memory: table-driven tests (task l9)
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/repository/favorite.go | 62 | ||||
| -rw-r--r-- | internal/repository/media.go | 244 | ||||
| -rw-r--r-- | internal/repository/migrate.go | 134 | ||||
| -rw-r--r-- | internal/repository/mock.go | 635 | ||||
| -rw-r--r-- | internal/repository/note.go | 45 | ||||
| -rw-r--r-- | internal/repository/playback_accumulator.go | 35 | ||||
| -rw-r--r-- | internal/repository/playback_progress.go | 52 | ||||
| -rw-r--r-- | internal/repository/repository.go | 134 | ||||
| -rw-r--r-- | internal/repository/session.go | 58 | ||||
| -rw-r--r-- | internal/repository/set.go | 79 | ||||
| -rw-r--r-- | internal/repository/set_permission.go | 84 | ||||
| -rw-r--r-- | internal/repository/share.go | 95 | ||||
| -rw-r--r-- | internal/repository/sqlite.go | 57 | ||||
| -rw-r--r-- | internal/repository/sqlite_test.go | 737 | ||||
| -rw-r--r-- | internal/repository/tag.go | 108 | ||||
| -rw-r--r-- | internal/repository/user.go | 91 |
16 files changed, 2650 insertions, 0 deletions
diff --git a/internal/repository/favorite.go b/internal/repository/favorite.go new file mode 100644 index 0000000..4d3e0f5 --- /dev/null +++ b/internal/repository/favorite.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/paul/kiss-media-player/internal/model" +) + +// ToggleFavorite inserts or deletes a favorite row, returning whether it is now favorited. +func (s *SQLite) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + row := s.db.QueryRowContext(ctx, + `SELECT 1 FROM favorites WHERE user_id = ? AND media_id = ?`, userID, mediaID) + var dummy int + err := row.Scan(&dummy) + if err != nil { + // Insert new favorite + _, err = s.db.ExecContext(ctx, + `INSERT INTO favorites (user_id, media_id) VALUES (?, ?)`, userID, mediaID) + if err != nil { + return false, fmt.Errorf("insert favorite: %w", err) + } + return true, nil + } + // Delete existing favorite + _, err = s.db.ExecContext(ctx, + `DELETE FROM favorites WHERE user_id = ? AND media_id = ?`, userID, mediaID) + if err != nil { + return false, fmt.Errorf("delete favorite: %w", err) + } + return false, nil +} + +// IsFavorite returns true if the user has favorited the media. +func (s *SQLite) IsFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + row := s.db.QueryRowContext(ctx, + `SELECT 1 FROM favorites WHERE user_id = ? AND media_id = ?`, userID, mediaID) + var dummy int + if err := row.Scan(&dummy); err != nil { + return false, nil + } + return true, nil +} + +// ListFavoritesByUser returns all favorites for a user. +func (s *SQLite) ListFavoritesByUser(ctx context.Context, userID int64) ([]model.Favorite, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT user_id, media_id, created_at FROM favorites WHERE user_id = ? ORDER BY created_at DESC`, userID) + if err != nil { + return nil, fmt.Errorf("list favorites: %w", err) + } + defer rows.Close() + var favs []model.Favorite + for rows.Next() { + var f model.Favorite + if err := rows.Scan(&f.UserID, &f.MediaID, &f.CreatedAt); err != nil { + return nil, err + } + favs = append(favs, f) + } + return favs, rows.Err() +} diff --git a/internal/repository/media.go b/internal/repository/media.go new file mode 100644 index 0000000..1f1b485 --- /dev/null +++ b/internal/repository/media.go @@ -0,0 +1,244 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/paul/kiss-media-player/internal/model" +) + +// CreateMedia inserts a new media and returns the generated ID. +func (s *SQLite) CreateMedia(ctx context.Context, media *model.Media) (int64, error) { + res, err := s.db.ExecContext(ctx, + `INSERT INTO media (set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, thumbnail_path, play_count, deleted_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + media.SetID, media.RelPath, media.FileName, media.AbsPath, string(media.Type), + media.Duration, media.Codec, media.Resolution, media.Bitrate, media.FileSizeBytes, + sqlNullString(media.ThumbnailPath), media.PlayCount, sqlNullTime(media.DeletedAt), media.CreatedAt, + ) + if err != nil { + return 0, fmt.Errorf("insert media: %w", err) + } + return res.LastInsertId() +} + +func scanMedia(row sqlScanner) (*model.Media, error) { + var m model.Media + var deleted sql.NullTime + var mediaType string + var thumbnail sql.NullString + var codec sql.NullString + var resolution sql.NullString + var duration sql.NullFloat64 + var bitrate sql.NullInt64 + var fileSize sql.NullInt64 + err := row.Scan( + &m.ID, &m.SetID, &m.RelPath, &m.FileName, &m.AbsPath, &mediaType, + &duration, &codec, &resolution, &bitrate, &fileSize, + &thumbnail, &m.PlayCount, &deleted, &m.CreatedAt, + ) + if err != nil { + return nil, err + } + m.Type = model.MediaType(mediaType) + if deleted.Valid { + m.DeletedAt = &deleted.Time + } + if thumbnail.Valid { + m.ThumbnailPath = thumbnail.String + } + if codec.Valid { + m.Codec = codec.String + } + if resolution.Valid { + m.Resolution = resolution.String + } + if duration.Valid { + m.Duration = duration.Float64 + } + if bitrate.Valid { + m.Bitrate = int(bitrate.Int64) + } + if fileSize.Valid { + m.FileSizeBytes = fileSize.Int64 + } + return &m, nil +} + +// GetMediaByID retrieves a media by ID. +func (s *SQLite) GetMediaByID(ctx context.Context, id int64) (*model.Media, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, thumbnail_path, play_count, deleted_at, created_at FROM media WHERE id = ?`, id) + return scanMedia(row) +} + +// UpdateMedia updates all mutable fields of a media record. +func (s *SQLite) UpdateMedia(ctx context.Context, media *model.Media) error { + _, err := s.db.ExecContext(ctx, + `UPDATE media SET set_id = ?, rel_path = ?, file_name = ?, abs_path = ?, type = ?, duration = ?, codec = ?, resolution = ?, bitrate = ?, file_size_bytes = ?, thumbnail_path = ?, play_count = ?, deleted_at = ? WHERE id = ?`, + media.SetID, media.RelPath, media.FileName, media.AbsPath, string(media.Type), media.Duration, + media.Codec, media.Resolution, media.Bitrate, media.FileSizeBytes, sqlNullString(media.ThumbnailPath), + media.PlayCount, sqlNullTime(media.DeletedAt), media.ID, + ) + if err != nil { + return fmt.Errorf("update media: %w", err) + } + return nil +} + +// SoftDeleteMedia sets deleted_at to NOW(). +func (s *SQLite) SoftDeleteMedia(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE media SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("soft delete media: %w", err) + } + return nil +} + +// RestoreMedia clears deleted_at. +func (s *SQLite) RestoreMedia(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE media SET deleted_at = NULL WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("restore media: %w", err) + } + return nil +} + +// HardDeleteMedia permanently deletes a media record. +func (s *SQLite) HardDeleteMedia(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM media WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("hard delete media: %w", err) + } + return nil +} + +// ListMedia returns media matching the filter. +func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Media, error) { + var args []any + var conds []string + var joins string + query := `SELECT DISTINCT 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.thumbnail_path, media.play_count, media.deleted_at, media.created_at FROM media` + + if filter.Search != "" { + conds = append(conds, `(media.file_name LIKE ? OR media.rel_path LIKE ?)`) + like := "%" + strings.ReplaceAll(filter.Search, "%", "\\%") + "%" + args = append(args, like, like) + } + if filter.Favorites != nil { + joins += ` INNER JOIN favorites f ON f.media_id = media.id AND f.user_id = ?` + args = append(args, *filter.Favorites) + } + if len(filter.Tags) > 0 { + joins += ` INNER JOIN media_tags mt ON mt.media_id = media.id INNER JOIN tags t ON t.id = mt.tag_id` + conds = append(conds, `t.name IN (`+placeholders(len(filter.Tags))+`)`) + for _, t := range filter.Tags { + args = append(args, t) + } + // Require all tags by grouping and checking count + // This is handled below via HAVING + } + + if filter.SetID != nil { + conds = append(conds, `media.set_id = ?`) + args = append(args, *filter.SetID) + } + if filter.Type != nil { + conds = append(conds, `media.type = ?`) + args = append(args, string(*filter.Type)) + } + if filter.MinDuration != nil { + conds = append(conds, `media.duration >= ?`) + args = append(args, *filter.MinDuration) + } + if filter.MaxDuration != nil { + conds = append(conds, `media.duration <= ?`) + args = append(args, *filter.MaxDuration) + } + conds = append(conds, `media.deleted_at IS NULL`) + + query += joins + if len(conds) > 0 { + query += " WHERE " + strings.Join(conds, " AND ") + } + if len(filter.Tags) > 0 { + query += ` GROUP BY media.id HAVING COUNT(DISTINCT t.name) = ` + fmt.Sprintf("%d", len(filter.Tags)) + } + + switch filter.Sort { + case "duration": + query += " ORDER BY media.duration" + case "play_count": + query += " ORDER BY media.play_count DESC" + case "date": + query += " ORDER BY media.created_at DESC" + case "random": + query += " ORDER BY RANDOM()" + default: + query += " ORDER BY media.file_name" + } + + if filter.Limit > 0 { + query += fmt.Sprintf(" LIMIT %d", filter.Limit) + } + if filter.Offset > 0 { + query += fmt.Sprintf(" OFFSET %d", filter.Offset) + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list 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() +} + +// ListDeletedMedia returns all soft-deleted media. +func (s *SQLite) ListDeletedMedia(ctx context.Context) ([]model.Media, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, thumbnail_path, play_count, deleted_at, created_at FROM media WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC`) + if err != nil { + return nil, fmt.Errorf("list deleted 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() +} + +// IncrementPlayCount increments the play_count of a media by 1. +func (s *SQLite) IncrementPlayCount(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE media SET play_count = play_count + 1 WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("increment play count: %w", err) + } + return nil +} + +func placeholders(n int) string { + if n <= 0 { + return "" + } + parts := make([]string, n) + for i := range parts { + parts[i] = "?" + } + return strings.Join(parts, ",") +} diff --git a/internal/repository/migrate.go b/internal/repository/migrate.go new file mode 100644 index 0000000..60cea03 --- /dev/null +++ b/internal/repository/migrate.go @@ -0,0 +1,134 @@ +package repository + +import ( + "database/sql" + "fmt" +) + +// Migrate creates the database schema if it does not exist. +func Migrate(db *sql.DB) error { + if _, err := db.Exec(`PRAGMA foreign_keys = ON;`); err != nil { + return fmt.Errorf("enable foreign keys: %w", err) + } + + schema := ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + root_path TEXT UNIQUE NOT NULL, + cover_thumbnail_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS set_permissions ( + set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT CHECK(role IN ('owner','viewer')) NOT NULL DEFAULT 'viewer', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (set_id, user_id) +); + +CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE, + rel_path TEXT NOT NULL, + file_name TEXT NOT NULL, + abs_path TEXT NOT NULL, + type TEXT CHECK(type IN ('video','audio')) NOT NULL, + duration REAL, + codec TEXT, + resolution TEXT, + bitrate INTEGER, + file_size_bytes INTEGER, + thumbnail_path TEXT, + play_count INTEGER NOT NULL DEFAULT 0, + deleted_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(set_id, rel_path) +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS media_tags ( + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (media_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, media_id) +); + +CREATE TABLE IF NOT EXISTS playback_progress ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + position_seconds REAL NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, media_id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS playback_accumulator ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + last_position REAL NOT NULL DEFAULT 0, + accumulated_seconds REAL NOT NULL DEFAULT 0, + counted INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (session_id, media_id) +); + +CREATE TABLE IF NOT EXISTS shares ( + token TEXT PRIMARY KEY, + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS media_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(media_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_media_set_id ON media(set_id); +CREATE INDEX IF NOT EXISTS idx_media_rel_path ON media(set_id, rel_path); +CREATE INDEX IF NOT EXISTS idx_media_deleted_at ON media(deleted_at); +CREATE INDEX IF NOT EXISTS idx_media_type ON media(type); +CREATE INDEX IF NOT EXISTS idx_media_filename ON media(file_name); +CREATE INDEX IF NOT EXISTS idx_permissions_user ON set_permissions(user_id); +CREATE INDEX IF NOT EXISTS idx_permissions_set ON set_permissions(set_id); +CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at); +` + if _, err := db.Exec(schema); err != nil { + return fmt.Errorf("execute schema: %w", err) + } + return nil +} diff --git a/internal/repository/mock.go b/internal/repository/mock.go new file mode 100644 index 0000000..7c54e5c --- /dev/null +++ b/internal/repository/mock.go @@ -0,0 +1,635 @@ +// Package repository provides data access abstractions. +package repository + +import ( + "context" + "time" + + "github.com/paul/kiss-media-player/internal/model" +) + +// compile-time checks. +var ( + _ Store = (*MockStore)(nil) +) + +// MockStore is a hand-written fake for all repository interfaces. +// Each embedded struct provides default no-op / zero-value behavior; +// callers override individual func fields to inject test behavior. +type MockStore struct { + UserRepo MockUserRepo + SetRepo MockSetRepo + SetPermissionRepo MockSetPermissionRepo + MediaRepo MockMediaRepo + TagRepo MockTagRepo + FavoriteRepo MockFavoriteRepo + PlaybackProgressRepo MockPlaybackProgressRepo + PlaybackAccumulatorRepo MockPlaybackAccumulatorRepo + SessionRepo MockSessionRepo + ShareRepo MockShareRepo + NoteRepo MockNoteRepo +} + +func (m *MockStore) CreateUser(ctx context.Context, user *model.User) (int64, error) { + return m.UserRepo.CreateUser(ctx, user) +} +func (m *MockStore) GetUserByID(ctx context.Context, id int64) (*model.User, error) { + return m.UserRepo.GetUserByID(ctx, id) +} +func (m *MockStore) GetUserByUsername(ctx context.Context, username string) (*model.User, error) { + return m.UserRepo.GetUserByUsername(ctx, username) +} +func (m *MockStore) ListUsers(ctx context.Context) ([]model.User, error) { + return m.UserRepo.ListUsers(ctx) +} +func (m *MockStore) DeleteUser(ctx context.Context, id int64) error { + return m.UserRepo.DeleteUser(ctx, id) +} +func (m *MockStore) CountUsers(ctx context.Context) (int, error) { return m.UserRepo.CountUsers(ctx) } + +func (m *MockStore) CreateSet(ctx context.Context, set *model.Set) (int64, error) { + return m.SetRepo.CreateSet(ctx, set) +} +func (m *MockStore) GetSetByID(ctx context.Context, id int64) (*model.Set, error) { + return m.SetRepo.GetSetByID(ctx, id) +} +func (m *MockStore) ListSets(ctx context.Context) ([]model.Set, error) { + return m.SetRepo.ListSets(ctx) +} +func (m *MockStore) UpdateSet(ctx context.Context, set *model.Set) error { + return m.SetRepo.UpdateSet(ctx, set) +} +func (m *MockStore) DeleteSet(ctx context.Context, id int64) error { + return m.SetRepo.DeleteSet(ctx, id) +} + +func (m *MockStore) GrantPermission(ctx context.Context, perm *model.SetPermission) error { + return m.SetPermissionRepo.GrantPermission(ctx, perm) +} +func (m *MockStore) RevokePermission(ctx context.Context, setID, userID int64) error { + return m.SetPermissionRepo.RevokePermission(ctx, setID, userID) +} +func (m *MockStore) GetPermission(ctx context.Context, setID, userID int64) (*model.SetPermission, error) { + return m.SetPermissionRepo.GetPermission(ctx, setID, userID) +} +func (m *MockStore) ListPermissionsBySet(ctx context.Context, setID int64) ([]model.SetPermission, error) { + return m.SetPermissionRepo.ListPermissionsBySet(ctx, setID) +} +func (m *MockStore) ListPermissionsByUser(ctx context.Context, userID int64) ([]model.SetPermission, error) { + return m.SetPermissionRepo.ListPermissionsByUser(ctx, userID) +} + +func (m *MockStore) CreateMedia(ctx context.Context, media *model.Media) (int64, error) { + return m.MediaRepo.CreateMedia(ctx, media) +} +func (m *MockStore) GetMediaByID(ctx context.Context, id int64) (*model.Media, error) { + return m.MediaRepo.GetMediaByID(ctx, id) +} +func (m *MockStore) UpdateMedia(ctx context.Context, media *model.Media) error { + return m.MediaRepo.UpdateMedia(ctx, media) +} +func (m *MockStore) SoftDeleteMedia(ctx context.Context, id int64) error { + return m.MediaRepo.SoftDeleteMedia(ctx, id) +} +func (m *MockStore) RestoreMedia(ctx context.Context, id int64) error { + return m.MediaRepo.RestoreMedia(ctx, id) +} +func (m *MockStore) HardDeleteMedia(ctx context.Context, id int64) error { + return m.MediaRepo.HardDeleteMedia(ctx, id) +} +func (m *MockStore) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Media, error) { + return m.MediaRepo.ListMedia(ctx, filter) +} +func (m *MockStore) ListDeletedMedia(ctx context.Context) ([]model.Media, error) { + return m.MediaRepo.ListDeletedMedia(ctx) +} +func (m *MockStore) IncrementPlayCount(ctx context.Context, id int64) error { + return m.MediaRepo.IncrementPlayCount(ctx, id) +} + +func (m *MockStore) CreateTag(ctx context.Context, name string) (int64, error) { + return m.TagRepo.CreateTag(ctx, name) +} +func (m *MockStore) GetTagByID(ctx context.Context, id int64) (*model.Tag, error) { + return m.TagRepo.GetTagByID(ctx, id) +} +func (m *MockStore) GetTagByName(ctx context.Context, name string) (*model.Tag, error) { + return m.TagRepo.GetTagByName(ctx, name) +} +func (m *MockStore) ListTags(ctx context.Context) ([]model.Tag, error) { + return m.TagRepo.ListTags(ctx) +} +func (m *MockStore) DeleteTag(ctx context.Context, id int64) error { + return m.TagRepo.DeleteTag(ctx, id) +} +func (m *MockStore) AssignTag(ctx context.Context, mediaID, tagID int64) error { + return m.TagRepo.AssignTag(ctx, mediaID, tagID) +} +func (m *MockStore) RemoveTag(ctx context.Context, mediaID, tagID int64) error { + return m.TagRepo.RemoveTag(ctx, mediaID, tagID) +} +func (m *MockStore) ListTagsByMedia(ctx context.Context, mediaID int64) ([]model.Tag, error) { + return m.TagRepo.ListTagsByMedia(ctx, mediaID) +} + +func (m *MockStore) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + return m.FavoriteRepo.ToggleFavorite(ctx, userID, mediaID) +} +func (m *MockStore) IsFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + return m.FavoriteRepo.IsFavorite(ctx, userID, mediaID) +} +func (m *MockStore) ListFavoritesByUser(ctx context.Context, userID int64) ([]model.Favorite, error) { + return m.FavoriteRepo.ListFavoritesByUser(ctx, userID) +} + +func (m *MockStore) UpsertProgress(ctx context.Context, progress *model.PlaybackProgress) error { + return m.PlaybackProgressRepo.UpsertProgress(ctx, progress) +} +func (m *MockStore) GetProgress(ctx context.Context, userID, mediaID int64) (*model.PlaybackProgress, error) { + return m.PlaybackProgressRepo.GetProgress(ctx, userID, mediaID) +} +func (m *MockStore) ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) { + return m.PlaybackProgressRepo.ListProgressByUser(ctx, userID) +} + +func (m *MockStore) UpsertAccumulator(ctx context.Context, acc *model.PlaybackAccumulator) error { + return m.PlaybackAccumulatorRepo.UpsertAccumulator(ctx, acc) +} +func (m *MockStore) GetAccumulator(ctx context.Context, sessionID string, mediaID int64) (*model.PlaybackAccumulator, error) { + return m.PlaybackAccumulatorRepo.GetAccumulator(ctx, sessionID, mediaID) +} + +func (m *MockStore) CreateSession(ctx context.Context, session *model.Session) error { + return m.SessionRepo.CreateSession(ctx, session) +} +func (m *MockStore) GetSessionByID(ctx context.Context, id string) (*model.Session, error) { + return m.SessionRepo.GetSessionByID(ctx, id) +} +func (m *MockStore) DeleteSession(ctx context.Context, id string) error { + return m.SessionRepo.DeleteSession(ctx, id) +} +func (m *MockStore) DeleteExpiredSessions(ctx context.Context, now time.Time) error { + return m.SessionRepo.DeleteExpiredSessions(ctx, now) +} + +func (m *MockStore) CreateShare(ctx context.Context, share *model.Share) error { + return m.ShareRepo.CreateShare(ctx, share) +} +func (m *MockStore) GetShareByToken(ctx context.Context, token string) (*model.Share, error) { + return m.ShareRepo.GetShareByToken(ctx, token) +} +func (m *MockStore) ListSharesByMedia(ctx context.Context, mediaID int64) ([]model.Share, error) { + return m.ShareRepo.ListSharesByMedia(ctx, mediaID) +} +func (m *MockStore) UseShare(ctx context.Context, token string) error { + return m.ShareRepo.UseShare(ctx, token) +} +func (m *MockStore) DeleteShare(ctx context.Context, token string) error { + return m.ShareRepo.DeleteShare(ctx, token) +} +func (m *MockStore) DeleteExpiredShares(ctx context.Context, now time.Time) error { + return m.ShareRepo.DeleteExpiredShares(ctx, now) +} + +func (m *MockStore) UpsertNote(ctx context.Context, note *model.Note) error { + return m.NoteRepo.UpsertNote(ctx, note) +} +func (m *MockStore) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) { + return m.NoteRepo.GetNote(ctx, mediaID, userID) +} +func (m *MockStore) DeleteNote(ctx context.Context, mediaID, userID int64) error { + return m.NoteRepo.DeleteNote(ctx, mediaID, userID) +} + +// MockUserRepo is a fake UserRepo. +type MockUserRepo struct { + CreateUserFunc func(ctx context.Context, user *model.User) (int64, error) + GetUserByIDFunc func(ctx context.Context, id int64) (*model.User, error) + GetUserByUsernameFunc func(ctx context.Context, username string) (*model.User, error) + ListUsersFunc func(ctx context.Context) ([]model.User, error) + DeleteUserFunc func(ctx context.Context, id int64) error + CountUsersFunc func(ctx context.Context) (int, error) +} + +func (m *MockUserRepo) CreateUser(ctx context.Context, user *model.User) (int64, error) { + if m.CreateUserFunc != nil { + return m.CreateUserFunc(ctx, user) + } + return 1, nil +} +func (m *MockUserRepo) GetUserByID(ctx context.Context, id int64) (*model.User, error) { + if m.GetUserByIDFunc != nil { + return m.GetUserByIDFunc(ctx, id) + } + return nil, nil +} +func (m *MockUserRepo) GetUserByUsername(ctx context.Context, username string) (*model.User, error) { + if m.GetUserByUsernameFunc != nil { + return m.GetUserByUsernameFunc(ctx, username) + } + return nil, nil +} +func (m *MockUserRepo) ListUsers(ctx context.Context) ([]model.User, error) { + if m.ListUsersFunc != nil { + return m.ListUsersFunc(ctx) + } + return nil, nil +} +func (m *MockUserRepo) DeleteUser(ctx context.Context, id int64) error { + if m.DeleteUserFunc != nil { + return m.DeleteUserFunc(ctx, id) + } + return nil +} +func (m *MockUserRepo) CountUsers(ctx context.Context) (int, error) { + if m.CountUsersFunc != nil { + return m.CountUsersFunc(ctx) + } + return 0, nil +} + +// MockSetRepo is a fake SetRepo. +type MockSetRepo struct { + CreateSetFunc func(ctx context.Context, set *model.Set) (int64, error) + GetSetByIDFunc func(ctx context.Context, id int64) (*model.Set, error) + ListSetsFunc func(ctx context.Context) ([]model.Set, error) + UpdateSetFunc func(ctx context.Context, set *model.Set) error + DeleteSetFunc func(ctx context.Context, id int64) error +} + +func (m *MockSetRepo) CreateSet(ctx context.Context, set *model.Set) (int64, error) { + if m.CreateSetFunc != nil { + return m.CreateSetFunc(ctx, set) + } + return 1, nil +} +func (m *MockSetRepo) GetSetByID(ctx context.Context, id int64) (*model.Set, error) { + if m.GetSetByIDFunc != nil { + return m.GetSetByIDFunc(ctx, id) + } + return nil, nil +} +func (m *MockSetRepo) ListSets(ctx context.Context) ([]model.Set, error) { + if m.ListSetsFunc != nil { + return m.ListSetsFunc(ctx) + } + return nil, nil +} +func (m *MockSetRepo) UpdateSet(ctx context.Context, set *model.Set) error { + if m.UpdateSetFunc != nil { + return m.UpdateSetFunc(ctx, set) + } + return nil +} +func (m *MockSetRepo) DeleteSet(ctx context.Context, id int64) error { + if m.DeleteSetFunc != nil { + return m.DeleteSetFunc(ctx, id) + } + return nil +} + +// MockSetPermissionRepo is a fake SetPermissionRepo. +type MockSetPermissionRepo struct { + GrantPermissionFunc func(ctx context.Context, perm *model.SetPermission) error + RevokePermissionFunc func(ctx context.Context, setID, userID int64) error + GetPermissionFunc func(ctx context.Context, setID, userID int64) (*model.SetPermission, error) + ListPermissionsBySetFunc func(ctx context.Context, setID int64) ([]model.SetPermission, error) + ListPermissionsByUserFunc func(ctx context.Context, userID int64) ([]model.SetPermission, error) +} + +func (m *MockSetPermissionRepo) GrantPermission(ctx context.Context, perm *model.SetPermission) error { + if m.GrantPermissionFunc != nil { + return m.GrantPermissionFunc(ctx, perm) + } + return nil +} +func (m *MockSetPermissionRepo) RevokePermission(ctx context.Context, setID, userID int64) error { + if m.RevokePermissionFunc != nil { + return m.RevokePermissionFunc(ctx, setID, userID) + } + return nil +} +func (m *MockSetPermissionRepo) GetPermission(ctx context.Context, setID, userID int64) (*model.SetPermission, error) { + if m.GetPermissionFunc != nil { + return m.GetPermissionFunc(ctx, setID, userID) + } + return nil, nil +} +func (m *MockSetPermissionRepo) ListPermissionsBySet(ctx context.Context, setID int64) ([]model.SetPermission, error) { + if m.ListPermissionsBySetFunc != nil { + return m.ListPermissionsBySetFunc(ctx, setID) + } + return nil, nil +} +func (m *MockSetPermissionRepo) ListPermissionsByUser(ctx context.Context, userID int64) ([]model.SetPermission, error) { + if m.ListPermissionsByUserFunc != nil { + return m.ListPermissionsByUserFunc(ctx, userID) + } + return nil, nil +} + +// MockMediaRepo is a fake MediaRepo. +type MockMediaRepo struct { + CreateMediaFunc func(ctx context.Context, media *model.Media) (int64, error) + GetMediaByIDFunc func(ctx context.Context, id int64) (*model.Media, error) + UpdateMediaFunc func(ctx context.Context, media *model.Media) error + SoftDeleteMediaFunc func(ctx context.Context, id int64) error + RestoreMediaFunc func(ctx context.Context, id int64) error + HardDeleteMediaFunc func(ctx context.Context, id int64) error + ListMediaFunc func(ctx context.Context, filter MediaFilter) ([]model.Media, error) + ListDeletedMediaFunc func(ctx context.Context) ([]model.Media, error) + IncrementPlayCountFunc func(ctx context.Context, id int64) error +} + +func (m *MockMediaRepo) CreateMedia(ctx context.Context, media *model.Media) (int64, error) { + if m.CreateMediaFunc != nil { + return m.CreateMediaFunc(ctx, media) + } + return 1, nil +} +func (m *MockMediaRepo) GetMediaByID(ctx context.Context, id int64) (*model.Media, error) { + if m.GetMediaByIDFunc != nil { + return m.GetMediaByIDFunc(ctx, id) + } + return nil, nil +} +func (m *MockMediaRepo) UpdateMedia(ctx context.Context, media *model.Media) error { + if m.UpdateMediaFunc != nil { + return m.UpdateMediaFunc(ctx, media) + } + return nil +} +func (m *MockMediaRepo) SoftDeleteMedia(ctx context.Context, id int64) error { + if m.SoftDeleteMediaFunc != nil { + return m.SoftDeleteMediaFunc(ctx, id) + } + return nil +} +func (m *MockMediaRepo) RestoreMedia(ctx context.Context, id int64) error { + if m.RestoreMediaFunc != nil { + return m.RestoreMediaFunc(ctx, id) + } + return nil +} +func (m *MockMediaRepo) HardDeleteMedia(ctx context.Context, id int64) error { + if m.HardDeleteMediaFunc != nil { + return m.HardDeleteMediaFunc(ctx, id) + } + return nil +} +func (m *MockMediaRepo) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Media, error) { + if m.ListMediaFunc != nil { + return m.ListMediaFunc(ctx, filter) + } + return nil, nil +} +func (m *MockMediaRepo) ListDeletedMedia(ctx context.Context) ([]model.Media, error) { + if m.ListDeletedMediaFunc != nil { + return m.ListDeletedMediaFunc(ctx) + } + return nil, nil +} +func (m *MockMediaRepo) IncrementPlayCount(ctx context.Context, id int64) error { + if m.IncrementPlayCountFunc != nil { + return m.IncrementPlayCountFunc(ctx, id) + } + return nil +} + +// MockTagRepo is a fake TagRepo. +type MockTagRepo struct { + CreateTagFunc func(ctx context.Context, name string) (int64, error) + GetTagByIDFunc func(ctx context.Context, id int64) (*model.Tag, error) + GetTagByNameFunc func(ctx context.Context, name string) (*model.Tag, error) + ListTagsFunc func(ctx context.Context) ([]model.Tag, error) + DeleteTagFunc func(ctx context.Context, id int64) error + AssignTagFunc func(ctx context.Context, mediaID, tagID int64) error + RemoveTagFunc func(ctx context.Context, mediaID, tagID int64) error + ListTagsByMediaFunc func(ctx context.Context, mediaID int64) ([]model.Tag, error) +} + +func (m *MockTagRepo) CreateTag(ctx context.Context, name string) (int64, error) { + if m.CreateTagFunc != nil { + return m.CreateTagFunc(ctx, name) + } + return 1, nil +} +func (m *MockTagRepo) GetTagByID(ctx context.Context, id int64) (*model.Tag, error) { + if m.GetTagByIDFunc != nil { + return m.GetTagByIDFunc(ctx, id) + } + return nil, nil +} +func (m *MockTagRepo) GetTagByName(ctx context.Context, name string) (*model.Tag, error) { + if m.GetTagByNameFunc != nil { + return m.GetTagByNameFunc(ctx, name) + } + return nil, nil +} +func (m *MockTagRepo) ListTags(ctx context.Context) ([]model.Tag, error) { + if m.ListTagsFunc != nil { + return m.ListTagsFunc(ctx) + } + return nil, nil +} +func (m *MockTagRepo) DeleteTag(ctx context.Context, id int64) error { + if m.DeleteTagFunc != nil { + return m.DeleteTagFunc(ctx, id) + } + return nil +} +func (m *MockTagRepo) AssignTag(ctx context.Context, mediaID, tagID int64) error { + if m.AssignTagFunc != nil { + return m.AssignTagFunc(ctx, mediaID, tagID) + } + return nil +} +func (m *MockTagRepo) RemoveTag(c |
