···1+package handlers
2+3+import (
4+ "context"
5+ "io"
6+)
7+8+// MediaHandler defines common operations for media handlers
9+//
10+// This interface captures the shared behavior across media handlers for polymorphic handling of different media types.
11+type MediaHandler interface {
12+ // SearchAndAdd searches for media and allows user to select and add to queue
13+ SearchAndAdd(ctx context.Context, query string, interactive bool) error
14+ // List lists all media items with optional status filtering
15+ List(ctx context.Context, status string) error
16+ // UpdateStatus changes the status of a media item
17+ UpdateStatus(ctx context.Context, id, status string) error
18+ // Remove removes a media item from the queue
19+ Remove(ctx context.Context, id string) error
20+ // SetInputReader sets the input reader for interactive prompts
21+ SetInputReader(reader io.Reader)
22+ // Close cleans up resources
23+ Close() error
24+}
25+26+// Searchable defines search behavior for media handlers
27+type Searchable interface {
28+ SearchAndAdd(ctx context.Context, query string, interactive bool) error
29+}
30+31+// Listable defines list behavior for media handlers
32+type Listable interface {
33+ List(ctx context.Context, status string) error
34+}
35+36+// StatusUpdatable defines status update behavior for media handlers
37+type StatusUpdatable interface {
38+ UpdateStatus(ctx context.Context, id, status string) error
39+}
40+41+// Removable defines remove behavior for media handlers
42+type Removable interface {
43+ Remove(ctx context.Context, id string) error
44+}
···1+package handlers
2+3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strconv"
8+9+ "github.com/stormlightlabs/noteleaf/internal/models"
10+)
11+12+// MediaPrinter defines how to format a media item for display
13+type MediaPrinter[T any] func(item *T)
14+15+// ListMediaItems is a generic utility for listing media items with status filtering
16+func ListMediaItems[T any](
17+ ctx context.Context,
18+ status string,
19+ mediaType string,
20+ listAll func(ctx context.Context) ([]*T, error),
21+ listByStatus func(ctx context.Context, status string) ([]*T, error),
22+ printer MediaPrinter[T],
23+) error {
24+ var items []*T
25+ var err error
26+27+ if status == "" {
28+ items, err = listAll(ctx)
29+ if err != nil {
30+ return fmt.Errorf("failed to list %s: %w", mediaType, err)
31+ }
32+ } else {
33+ items, err = listByStatus(ctx, status)
34+ if err != nil {
35+ return fmt.Errorf("failed to get %s %s: %w", status, mediaType, err)
36+ }
37+ }
38+39+ if len(items) == 0 {
40+ if status == "" {
41+ fmt.Printf("No %s found\n", mediaType)
42+ } else {
43+ fmt.Printf("No %s %s found\n", status, mediaType)
44+ }
45+ return nil
46+ }
47+48+ fmt.Printf("Found %d %s:\n\n", len(items), mediaType)
49+ for _, item := range items {
50+ printer(item)
51+ }
52+53+ return nil
54+}
55+56+// PromptUserChoice prompts the user to select from a list of results
57+func PromptUserChoice(reader io.Reader, maxChoices int) (int, error) {
58+ fmt.Print("\nEnter number to add (1-", maxChoices, "), or 0 to cancel: ")
59+60+ var choice int
61+ if reader != nil {
62+ if _, err := fmt.Fscanf(reader, "%d", &choice); err != nil {
63+ return 0, fmt.Errorf("invalid input")
64+ }
65+ } else {
66+ if _, err := fmt.Scanf("%d", &choice); err != nil {
67+ return 0, fmt.Errorf("invalid input")
68+ }
69+ }
70+71+ if choice == 0 {
72+ fmt.Println("Cancelled.")
73+ return 0, nil
74+ }
75+ if choice < 1 || choice > maxChoices {
76+ return 0, fmt.Errorf("invalid choice: %d", choice)
77+ }
78+ return choice, nil
79+}
80+81+// ParseID converts a string ID to int64
82+func ParseID(id string, itemType string) (int64, error) {
83+ itemID, err := strconv.ParseInt(id, 10, 64)
84+ if err != nil {
85+ return 0, fmt.Errorf("invalid %s ID: %s", itemType, id)
86+ }
87+ return itemID, nil
88+}
89+90+// PrintSearchResults displays search results with a type-specific formatter
91+func PrintSearchResults[T models.Model](results []*models.Model, formatter func(*models.Model, int)) error {
92+ if len(results) == 0 {
93+ fmt.Println("No results found.")
94+ return nil
95+ }
96+97+ fmt.Printf("Found %d result(s):\n\n", len(results))
98+ for i, result := range results {
99+ formatter(result, i+1)
100+ }
101+ return nil
102+}
+9-7
internal/handlers/movies.go
···24 reader io.Reader
25}
2600027// NewMovieHandler creates a new movie handler
28func NewMovieHandler() (*MovieHandler, error) {
29 db, err := store.NewDatabase()
···218}
219220// UpdateStatus changes the status of a movie
221-func (h *MovieHandler) UpdateStatus(ctx context.Context, movieID int64, status string) error {
0000222 validStatuses := []string{"queued", "watched", "removed"}
223 if !slices.Contains(validStatuses, status) {
224 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", "))
···245246// MarkWatched marks a movie as watched
247func (h *MovieHandler) MarkWatched(ctx context.Context, id string) error {
248- movieID, err := strconv.ParseInt(id, 10, 64)
249- if err != nil {
250- return fmt.Errorf("invalid movie ID: %s", id)
251- }
252-253- return h.UpdateStatus(ctx, movieID, "watched")
254}
255256// Remove removes a movie from the queue
···24 reader io.Reader
25}
2627+// Ensure MovieHandler implements interface [MediaHandler]
28+var _ MediaHandler = (*MovieHandler)(nil)
29+30// NewMovieHandler creates a new movie handler
31func NewMovieHandler() (*MovieHandler, error) {
32 db, err := store.NewDatabase()
···221}
222223// UpdateStatus changes the status of a movie
224+func (h *MovieHandler) UpdateStatus(ctx context.Context, id, status string) error {
225+ movieID, err := strconv.ParseInt(id, 10, 64)
226+ if err != nil {
227+ return fmt.Errorf("invalid movie ID %w", err)
228+ }
229 validStatuses := []string{"queued", "watched", "removed"}
230 if !slices.Contains(validStatuses, status) {
231 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", "))
···252253// MarkWatched marks a movie as watched
254func (h *MovieHandler) MarkWatched(ctx context.Context, id string) error {
255+ return h.UpdateStatus(ctx, id, "watched")
00000256}
257258// Remove removes a movie from the queue
+6-9
internal/handlers/movies_test.go
···358 handler := createTestMovieHandler(t)
359 defer handler.Close()
360361- err := handler.UpdateStatus(context.Background(), 1, "invalid")
362 if err == nil {
363 t.Error("Expected error for invalid status")
364 }
···371 handler := createTestMovieHandler(t)
372 defer handler.Close()
373374- err := handler.UpdateStatus(context.Background(), 999, "watched")
375 if err == nil {
376 t.Error("Expected error for non-existent movie")
377 }
···406 err := handler.MarkWatched(context.Background(), "invalid")
407 if err == nil {
408 t.Error("Expected error for invalid movie ID")
409- }
410- if err.Error() != "invalid movie ID: invalid" {
411- t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err)
412 }
413 })
414···468 t.Errorf("Failed to view created movie: %v", err)
469 }
470471- err = handler.UpdateStatus(context.Background(), id, "watched")
472 if err != nil {
473 t.Errorf("Failed to update movie status: %v", err)
474 }
···540 },
541 {
542 name: "Update status of non-existent movie",
543- fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") },
544 },
545 {
546 name: "Mark non-existent movie as watched",
···570 invalid := []string{"invalid", "pending", "completed", ""}
571572 for _, status := range valid {
573- if err := handler.UpdateStatus(context.Background(), 999, status); err != nil &&
574 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watched, removed)", status) {
575 t.Errorf("Status '%s' should be valid but was rejected", status)
576 }
577 }
578579 for _, status := range invalid {
580- err := handler.UpdateStatus(context.Background(), 1, status)
581 if err == nil {
582 t.Errorf("Status '%s' should be invalid but was accepted", status)
583 }
···358 handler := createTestMovieHandler(t)
359 defer handler.Close()
360361+ err := handler.UpdateStatus(context.Background(), "1", "invalid")
362 if err == nil {
363 t.Error("Expected error for invalid status")
364 }
···371 handler := createTestMovieHandler(t)
372 defer handler.Close()
373374+ err := handler.UpdateStatus(context.Background(), "999", "watched")
375 if err == nil {
376 t.Error("Expected error for non-existent movie")
377 }
···406 err := handler.MarkWatched(context.Background(), "invalid")
407 if err == nil {
408 t.Error("Expected error for invalid movie ID")
000409 }
410 })
411···465 t.Errorf("Failed to view created movie: %v", err)
466 }
467468+ err = handler.UpdateStatus(context.Background(), strconv.Itoa(int(id)), "watched")
469 if err != nil {
470 t.Errorf("Failed to update movie status: %v", err)
471 }
···537 },
538 {
539 name: "Update status of non-existent movie",
540+ fn: func() error { return handler.UpdateStatus(ctx, strconv.Itoa(int(nonExistentID)), "watched") },
541 },
542 {
543 name: "Mark non-existent movie as watched",
···567 invalid := []string{"invalid", "pending", "completed", ""}
568569 for _, status := range valid {
570+ if err := handler.UpdateStatus(context.Background(), "999", status); err != nil &&
571 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watched, removed)", status) {
572 t.Errorf("Status '%s' should be valid but was rejected", status)
573 }
574 }
575576 for _, status := range invalid {
577+ err := handler.UpdateStatus(context.Background(), "1", status)
578 if err == nil {
579 t.Errorf("Status '%s' should be invalid but was accepted", status)
580 }
+15-19
internal/handlers/tv.go
···16)
1718// TVHandler handles all TV show-related commands
0019type TVHandler struct {
20 db *store.Database
21 config *store.Config
···23 service *services.TVService
24 reader io.Reader
25}
0002627// NewTVHandler creates a new TV handler
28func NewTVHandler() (*TVHandler, error) {
···232}
233234// UpdateStatus changes the status of a TV show
235-func (h *TVHandler) UpdateStatus(ctx context.Context, showID int64, status string) error {
0000236 validStatuses := []string{"queued", "watching", "watched", "removed"}
237 if !slices.Contains(validStatuses, status) {
238 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", "))
···258}
259260// MarkWatching marks a TV show as currently watching
261-func (h *TVHandler) MarkWatching(ctx context.Context, showID int64) error {
262- return h.UpdateStatus(ctx, showID, "watching")
263}
264265// MarkWatched marks a TV show as watched
266func (h *TVHandler) MarkWatched(ctx context.Context, id string) error {
267- showID, err := strconv.ParseInt(id, 10, 64)
268- if err != nil {
269- return fmt.Errorf("invalid TV show ID: %s", id)
270- }
271-272- return h.UpdateStatus(ctx, showID, "watched")
273}
274275// Remove removes a TV show from the queue
···317318// UpdateTVShowStatus changes the status of a TV show
319func (h *TVHandler) UpdateTVShowStatus(ctx context.Context, id, status string) error {
320- showID, err := strconv.ParseInt(id, 10, 64)
321- if err != nil {
322- return fmt.Errorf("invalid TV show ID: %s", id)
323- }
324- return h.UpdateStatus(ctx, showID, status)
325}
326327// MarkTVShowWatching marks a TV show as currently watching
328func (h *TVHandler) MarkTVShowWatching(ctx context.Context, id string) error {
329- showID, err := strconv.ParseInt(id, 10, 64)
330- if err != nil {
331- return fmt.Errorf("invalid TV show ID: %s", id)
332- }
333- return h.MarkWatching(ctx, showID)
334}
···16)
1718// TVHandler handles all TV show-related commands
19+//
20+// Implements MediaHandler interface for polymorphic media handling
21type TVHandler struct {
22 db *store.Database
23 config *store.Config
···25 service *services.TVService
26 reader io.Reader
27}
28+29+// Ensure TVHandler implements MediaHandler interface
30+var _ MediaHandler = (*TVHandler)(nil)
3132// NewTVHandler creates a new TV handler
33func NewTVHandler() (*TVHandler, error) {
···237}
238239// UpdateStatus changes the status of a TV show
240+func (h *TVHandler) UpdateStatus(ctx context.Context, id, status string) error {
241+ showID, err := strconv.ParseInt(id, 10, 64)
242+ if err != nil {
243+ return fmt.Errorf("invalid tv show ID %w", err)
244+ }
245 validStatuses := []string{"queued", "watching", "watched", "removed"}
246 if !slices.Contains(validStatuses, status) {
247 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", "))
···267}
268269// MarkWatching marks a TV show as currently watching
270+func (h *TVHandler) MarkWatching(ctx context.Context, id string) error {
271+ return h.UpdateStatus(ctx, id, "watching")
272}
273274// MarkWatched marks a TV show as watched
275func (h *TVHandler) MarkWatched(ctx context.Context, id string) error {
276+ return h.UpdateStatus(ctx, id, "watched")
00000277}
278279// Remove removes a TV show from the queue
···321322// UpdateTVShowStatus changes the status of a TV show
323func (h *TVHandler) UpdateTVShowStatus(ctx context.Context, id, status string) error {
324+ return h.UpdateStatus(ctx, id, status)
0000325}
326327// MarkTVShowWatching marks a TV show as currently watching
328func (h *TVHandler) MarkTVShowWatching(ctx context.Context, id string) error {
329+ return h.MarkWatching(ctx, id)
0000330}
+12-21
internal/handlers/tv_test.go
···355 handler := createTestTVHandler(t)
356 defer handler.Close()
357358- err := handler.View(context.Background(), strconv.Itoa(int(999)))
359 if err == nil {
360 t.Error("Expected error for non-existent TV show")
361 }
···381 handler := createTestTVHandler(t)
382 defer handler.Close()
383384- err := handler.UpdateStatus(context.Background(), 1, "invalid")
385 if err == nil {
386 t.Error("Expected error for invalid status")
387 }
···394 handler := createTestTVHandler(t)
395 defer handler.Close()
396397- err := handler.UpdateStatus(context.Background(), 999, "watched")
398 if err == nil {
399 t.Error("Expected error for non-existent TV show")
400 }
···406 handler := createTestTVHandler(t)
407 defer handler.Close()
408409- err := handler.MarkWatching(context.Background(), 999)
410 if err == nil {
411 t.Error("Expected error for non-existent TV show")
412 }
···416 handler := createTestTVHandler(t)
417 defer handler.Close()
418419- err := handler.MarkWatched(context.Background(), strconv.Itoa(int(999)))
420 if err == nil {
421 t.Error("Expected error for non-existent TV show")
422 }
···426 handler := createTestTVHandler(t)
427 defer handler.Close()
428429- err := handler.Remove(context.Background(), strconv.Itoa(int(999)))
430 if err == nil {
431 t.Error("Expected error for non-existent TV show")
432 }
···440 if err == nil {
441 t.Error("Expected error for invalid TV show ID")
442 }
443- if err.Error() != "invalid TV show ID: invalid" {
444- t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err)
445- }
446 })
447448 t.Run("MarkTVShowWatching_InvalidID", func(t *testing.T) {
···453 if err == nil {
454 t.Error("Expected error for invalid TV show ID")
455 }
456- if err.Error() != "invalid TV show ID: invalid" {
457- t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err)
458- }
459 })
460461 t.Run("MarkWatched_InvalidID", func(t *testing.T) {
···465 err := handler.MarkWatched(context.Background(), "invalid")
466 if err == nil {
467 t.Error("Expected error for invalid TV show ID")
468- }
469- if err.Error() != "invalid TV show ID: invalid" {
470- t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err)
471 }
472 })
473···528 t.Errorf("Failed to view created TV show: %v", err)
529 }
530531- err = handler.UpdateStatus(context.Background(), id, "watching")
532 if err != nil {
533 t.Errorf("Failed to update TV show status: %v", err)
534 }
···538 t.Errorf("Failed to mark TV show as watched: %v", err)
539 }
540541- err = handler.MarkWatching(context.Background(), id)
542 if err != nil {
543 t.Errorf("Failed to mark TV show as watching: %v", err)
544 }
···617 },
618 {
619 name: "Update status of non-existent show",
620- fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") },
621 },
622 {
623 name: "Mark non-existent show as watching",
624- fn: func() error { return handler.MarkWatching(ctx, nonExistentID) },
625 },
626 {
627 name: "Mark non-existent show as watched",
···651 invalid := []string{"invalid", "pending", "completed", ""}
652653 for _, status := range valid {
654- if err := handler.UpdateStatus(context.Background(), 999, status); err != nil &&
655 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watching, watched, removed)", status) {
656 t.Errorf("Status '%s' should be valid but was rejected", status)
657 }
658 }
659660 for _, status := range invalid {
661- err := handler.UpdateStatus(context.Background(), 1, status)
662 if err == nil {
663 t.Errorf("Status '%s' should be invalid but was accepted", status)
664 }
···355 handler := createTestTVHandler(t)
356 defer handler.Close()
357358+ err := handler.View(context.Background(), "999")
359 if err == nil {
360 t.Error("Expected error for non-existent TV show")
361 }
···381 handler := createTestTVHandler(t)
382 defer handler.Close()
383384+ err := handler.UpdateStatus(context.Background(), "1", "invalid")
385 if err == nil {
386 t.Error("Expected error for invalid status")
387 }
···394 handler := createTestTVHandler(t)
395 defer handler.Close()
396397+ err := handler.UpdateStatus(context.Background(), "999", "watched")
398 if err == nil {
399 t.Error("Expected error for non-existent TV show")
400 }
···406 handler := createTestTVHandler(t)
407 defer handler.Close()
408409+ err := handler.MarkWatching(context.Background(), "999")
410 if err == nil {
411 t.Error("Expected error for non-existent TV show")
412 }
···416 handler := createTestTVHandler(t)
417 defer handler.Close()
418419+ err := handler.MarkWatched(context.Background(), "999")
420 if err == nil {
421 t.Error("Expected error for non-existent TV show")
422 }
···426 handler := createTestTVHandler(t)
427 defer handler.Close()
428429+ err := handler.Remove(context.Background(), "999")
430 if err == nil {
431 t.Error("Expected error for non-existent TV show")
432 }
···440 if err == nil {
441 t.Error("Expected error for invalid TV show ID")
442 }
000443 })
444445 t.Run("MarkTVShowWatching_InvalidID", func(t *testing.T) {
···450 if err == nil {
451 t.Error("Expected error for invalid TV show ID")
452 }
000453 })
454455 t.Run("MarkWatched_InvalidID", func(t *testing.T) {
···459 err := handler.MarkWatched(context.Background(), "invalid")
460 if err == nil {
461 t.Error("Expected error for invalid TV show ID")
000462 }
463 })
464···519 t.Errorf("Failed to view created TV show: %v", err)
520 }
521522+ err = handler.UpdateStatus(context.Background(), strconv.Itoa(int(id)), "watching")
523 if err != nil {
524 t.Errorf("Failed to update TV show status: %v", err)
525 }
···529 t.Errorf("Failed to mark TV show as watched: %v", err)
530 }
531532+ err = handler.MarkWatching(context.Background(), strconv.Itoa(int(id)))
533 if err != nil {
534 t.Errorf("Failed to mark TV show as watching: %v", err)
535 }
···608 },
609 {
610 name: "Update status of non-existent show",
611+ fn: func() error { return handler.UpdateStatus(ctx, strconv.Itoa(int(nonExistentID)), "watched") },
612 },
613 {
614 name: "Mark non-existent show as watching",
615+ fn: func() error { return handler.MarkWatching(ctx, strconv.Itoa(int(nonExistentID))) },
616 },
617 {
618 name: "Mark non-existent show as watched",
···642 invalid := []string{"invalid", "pending", "completed", ""}
643644 for _, status := range valid {
645+ if err := handler.UpdateStatus(context.Background(), "999", status); err != nil &&
646 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watching, watched, removed)", status) {
647 t.Errorf("Status '%s' should be valid but was rejected", status)
648 }
649 }
650651 for _, status := range invalid {
652+ err := handler.UpdateStatus(context.Background(), "1", status)
653 if err == nil {
654 t.Errorf("Status '%s' should be invalid but was accepted", status)
655 }
···1+package repo
2+3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+8+ "github.com/stormlightlabs/noteleaf/internal/models"
9+)
10+11+// MediaConfig defines configuration for a media repository
12+//
13+// T should be a pointer type (*models.Book, *models.Movie, *models.TVShow)
14+type MediaConfig[T models.Model] struct {
15+ TableName string // TableName is the database table name (e.g., "books", "movies", "tv_shows")
16+ New func() T // New creates a new zero-value instance of T
17+ Scan func(rows *sql.Rows, item T) error // Scan reads a database row into a model instance
18+ ScanSingle func(row *sql.Row, item T) error // ScanSingle reads a single row from QueryRow into a model instance
19+ InsertColumns string // InsertColumns returns the column names for INSERT statements
20+ UpdateColumns string // UpdateColumns returns the SET clause for UPDATE statements (without WHERE)
21+ InsertValues func(item T) []any // InsertValues extracts values from a model for INSERT
22+ UpdateValues func(item T) []any // UpdateValues extracts values from a model for UPDATE (item values + ID)
23+}
24+25+// BaseMediaRepository provides shared CRUD operations for media types
26+//
27+// This generic implementation eliminates duplicate code across Book, Movie, and TV repositories.
28+// Type-specific behavior is configured via MediaConfig.
29+//
30+// T should be a pointer type (*models.Book, *models.Movie, *models.TVShow)
31+type BaseMediaRepository[T models.Model] struct {
32+ db *sql.DB
33+ config MediaConfig[T]
34+}
35+36+// NewBaseMediaRepository creates a new base media repository
37+func NewBaseMediaRepository[T models.Model](db *sql.DB, config MediaConfig[T]) *BaseMediaRepository[T] {
38+ return &BaseMediaRepository[T]{
39+ db: db,
40+ config: config,
41+ }
42+}
43+44+// Create stores a new media item and returns its assigned ID
45+func (r *BaseMediaRepository[T]) Create(ctx context.Context, item T) (int64, error) {
46+ query := fmt.Sprintf(
47+ "INSERT INTO %s (%s) VALUES (%s)",
48+ r.config.TableName,
49+ r.config.InsertColumns,
50+ buildPlaceholders(r.config.InsertValues(item)),
51+ )
52+53+ result, err := r.db.ExecContext(ctx, query, r.config.InsertValues(item)...)
54+ if err != nil {
55+ return 0, fmt.Errorf("failed to insert %s: %w", r.config.TableName, err)
56+ }
57+58+ id, err := result.LastInsertId()
59+ if err != nil {
60+ return 0, fmt.Errorf("failed to get last insert id: %w", err)
61+ }
62+63+ return id, nil
64+}
65+66+// Get retrieves a media item by ID
67+//
68+// Returns T directly (which is already a pointer type like *models.Book)
69+func (r *BaseMediaRepository[T]) Get(ctx context.Context, id int64) (T, error) {
70+ query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", r.config.TableName)
71+ row := r.db.QueryRowContext(ctx, query, id)
72+73+ item := r.config.New()
74+ if err := r.config.ScanSingle(row, item); err != nil {
75+ var zero T
76+ if err == sql.ErrNoRows {
77+ return zero, fmt.Errorf("%s with id %d not found", r.config.TableName, id)
78+ }
79+ return zero, fmt.Errorf("failed to get %s: %w", r.config.TableName, err)
80+ }
81+82+ return item, nil
83+}
84+85+// Update modifies an existing media item
86+func (r *BaseMediaRepository[T]) Update(ctx context.Context, item T) error {
87+ query := fmt.Sprintf(
88+ "UPDATE %s SET %s WHERE id = ?",
89+ r.config.TableName,
90+ r.config.UpdateColumns,
91+ )
92+93+ _, err := r.db.ExecContext(ctx, query, r.config.UpdateValues(item)...)
94+ if err != nil {
95+ return fmt.Errorf("failed to update %s: %w", r.config.TableName, err)
96+ }
97+98+ return nil
99+}
100+101+// Delete removes a media item by ID
102+func (r *BaseMediaRepository[T]) Delete(ctx context.Context, id int64) error {
103+ query := fmt.Sprintf("DELETE FROM %s WHERE id = ?", r.config.TableName)
104+ _, err := r.db.ExecContext(ctx, query, id)
105+ if err != nil {
106+ return fmt.Errorf("failed to delete %s: %w", r.config.TableName, err)
107+ }
108+ return nil
109+}
110+111+// ListQuery executes a custom query and scans results
112+//
113+// Returns []T where T is a pointer type (e.g., []*models.Book)
114+func (r *BaseMediaRepository[T]) ListQuery(ctx context.Context, query string, args ...any) ([]T, error) {
115+ rows, err := r.db.QueryContext(ctx, query, args...)
116+ if err != nil {
117+ return nil, fmt.Errorf("failed to list %s: %w", r.config.TableName, err)
118+ }
119+ defer rows.Close()
120+121+ var items []T
122+ for rows.Next() {
123+ item := r.config.New()
124+ if err := r.config.Scan(rows, item); err != nil {
125+ return nil, err
126+ }
127+ items = append(items, item)
128+ }
129+130+ return items, rows.Err()
131+}
132+133+// CountQuery executes a custom COUNT query
134+func (r *BaseMediaRepository[T]) CountQuery(ctx context.Context, query string, args ...any) (int64, error) {
135+ var count int64
136+ err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
137+ if err != nil {
138+ return 0, fmt.Errorf("failed to count %s: %w", r.config.TableName, err)
139+ }
140+ return count, nil
141+}
142+143+// buildPlaceholders generates "?,?,?" for SQL placeholders
144+func buildPlaceholders(values []any) string {
145+ if len(values) == 0 {
146+ return ""
147+ }
148+149+ placeholders := "?"
150+ for i := 1; i < len(values); i++ {
151+ placeholders += ",?"
152+ }
153+ return placeholders
154+}
+51-80
internal/repo/book_repository.go
···11)
1213// BookRepository provides database operations for books
00014type BookRepository struct {
015 db *sql.DB
16}
1718// NewBookRepository creates a new book repository
19func NewBookRepository(db *sql.DB) *BookRepository {
20- return &BookRepository{db: db}
000000000000000000000021}
2223// Create stores a new book and returns its assigned ID
···25 now := time.Now()
26 book.Added = now
2728- query := `
29- INSERT INTO books (title, author, status, progress, pages, rating, notes, added, started, finished)
30- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
31-32- result, err := r.db.ExecContext(ctx, query,
33- book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating,
34- book.Notes, book.Added, book.Started, book.Finished)
35 if err != nil {
36- return 0, fmt.Errorf("failed to insert book: %w", err)
37- }
38-39- id, err := result.LastInsertId()
40- if err != nil {
41- return 0, fmt.Errorf("failed to get last insert id: %w", err)
42 }
4344 book.ID = id
45 return id, nil
46}
4748-// Get retrieves a book by ID
49-func (r *BookRepository) Get(ctx context.Context, id int64) (*models.Book, error) {
50- query := `
51- SELECT id, title, author, status, progress, pages, rating, notes, added, started, finished
52- FROM books WHERE id = ?`
53-54- book := &models.Book{}
55- err := r.db.QueryRowContext(ctx, query, id).Scan(
56- &book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &book.Pages,
57- &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished)
58- if err != nil {
59- return nil, fmt.Errorf("failed to get book: %w", err)
60- }
61-62- return book, nil
63-}
64-65-// Update modifies an existing book
66-func (r *BookRepository) Update(ctx context.Context, book *models.Book) error {
67- query := `
68- UPDATE books SET title = ?, author = ?, status = ?, progress = ?, pages = ?,
69- rating = ?, notes = ?, started = ?, finished = ?
70- WHERE id = ?`
71-72- _, err := r.db.ExecContext(ctx, query,
73- book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating,
74- book.Notes, book.Started, book.Finished, book.ID)
75- if err != nil {
76- return fmt.Errorf("failed to update book: %w", err)
77- }
78-79- return nil
80-}
81-82-// Delete removes a book by ID
83-func (r *BookRepository) Delete(ctx context.Context, id int64) error {
84- query := "DELETE FROM books WHERE id = ?"
85- _, err := r.db.ExecContext(ctx, query, id)
86- if err != nil {
87- return fmt.Errorf("failed to delete book: %w", err)
88- }
89- return nil
90-}
91-92// List retrieves books with optional filtering and sorting
93func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) {
94 query := r.buildListQuery(opts)
95 args := r.buildListArgs(opts)
9697- rows, err := r.db.QueryContext(ctx, query, args...)
98 if err != nil {
99- return nil, fmt.Errorf("failed to list books: %w", err)
100 }
101- defer rows.Close()
102-103- var books []*models.Book
104- for rows.Next() {
105- book := &models.Book{}
106- if err := r.scanBookRow(rows, book); err != nil {
107- return nil, err
108- }
109- books = append(books, book)
110- }
111-112- return books, rows.Err()
113}
114115func (r *BookRepository) buildListQuery(opts BookListOptions) string {
···187 return args
188}
189190-func (r *BookRepository) scanBookRow(rows *sql.Rows, book *models.Book) error {
0191 var pages sql.NullInt64
192193 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages,
0000000000000000194 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil {
195 return err
196 }
···246 query += " WHERE " + strings.Join(conditions, " AND ")
247 }
248249- var count int64
250- err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
251- if err != nil {
252- return 0, fmt.Errorf("failed to count books: %w", err)
253- }
254-255- return count, nil
256}
257258// GetQueued retrieves all books in the queue
···11)
1213// BookRepository provides database operations for books
14+//
15+// Uses BaseMediaRepository for common CRUD operations.
16+// TODO: Implement Repository interface (Validate method) similar to ArticleRepository
17type BookRepository struct {
18+ *BaseMediaRepository[*models.Book]
19 db *sql.DB
20}
2122// NewBookRepository creates a new book repository
23func NewBookRepository(db *sql.DB) *BookRepository {
24+ config := MediaConfig[*models.Book]{
25+ TableName: "books",
26+ New: func() *models.Book { return &models.Book{} },
27+ InsertColumns: "title, author, status, progress, pages, rating, notes, added, started, finished",
28+ UpdateColumns: "title = ?, author = ?, status = ?, progress = ?, pages = ?, rating = ?, notes = ?, started = ?, finished = ?",
29+ InsertValues: func(book *models.Book) []any {
30+ return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Added, book.Started, book.Finished}
31+ },
32+ UpdateValues: func(book *models.Book) []any {
33+ return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Started, book.Finished, book.ID}
34+ },
35+ Scan: func(rows *sql.Rows, book *models.Book) error {
36+ return scanBookRow(rows, book)
37+ },
38+ ScanSingle: func(row *sql.Row, book *models.Book) error {
39+ return scanBookRowSingle(row, book)
40+ },
41+ }
42+43+ return &BookRepository{
44+ BaseMediaRepository: NewBaseMediaRepository(db, config),
45+ db: db,
46+ }
47}
4849// Create stores a new book and returns its assigned ID
···51 now := time.Now()
52 book.Added = now
5354+ id, err := r.BaseMediaRepository.Create(ctx, book)
00000055 if err != nil {
56+ return 0, err
0000057 }
5859 book.ID = id
60 return id, nil
61}
620000000000000000000000000000000000000000000063// List retrieves books with optional filtering and sorting
64func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) {
65 query := r.buildListQuery(opts)
66 args := r.buildListArgs(opts)
6768+ items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...)
69 if err != nil {
70+ return nil, err
71 }
72+ return items, nil
0000000000073}
7475func (r *BookRepository) buildListQuery(opts BookListOptions) string {
···147 return args
148}
149150+// scanBookRow scans a database row into a book model
151+func scanBookRow(rows *sql.Rows, book *models.Book) error {
152 var pages sql.NullInt64
153154 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages,
155+ &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil {
156+ return err
157+ }
158+159+ if pages.Valid {
160+ book.Pages = int(pages.Int64)
161+ }
162+163+ return nil
164+}
165+166+// scanBookRowSingle scans a single database row into a book model
167+func scanBookRowSingle(row *sql.Row, book *models.Book) error {
168+ var pages sql.NullInt64
169+170+ if err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages,
171 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil {
172 return err
173 }
···223 query += " WHERE " + strings.Join(conditions, " AND ")
224 }
225226+ return r.BaseMediaRepository.CountQuery(ctx, query, args...)
000000227}
228229// GetQueued retrieves all books in the queue
+40
internal/repo/media_repository.go
···0000000000000000000000000000000000000000
···1+package repo
2+3+import (
4+ "context"
5+6+ "github.com/stormlightlabs/noteleaf/internal/models"
7+)
8+9+// MediaRepository defines CRUD operations for media types (Books, Movies, TV)
10+//
11+// This interface captures the shared behavior across media repositories
12+type MediaRepository[T models.Model] interface {
13+ // Create stores a new media item and returns its assigned ID
14+ Create(ctx context.Context, item *T) (int64, error)
15+16+ // Get retrieves a media item by ID
17+ Get(ctx context.Context, id int64) (*T, error)
18+19+ // Update modifies an existing media item
20+ Update(ctx context.Context, item *T) error
21+22+ // Delete removes a media item by ID
23+ Delete(ctx context.Context, id int64) error
24+25+ // List retrieves media items with optional filtering and sorting
26+ List(ctx context.Context, opts any) ([]*T, error)
27+28+ // Count returns the number of media items matching conditions
29+ Count(ctx context.Context, opts any) (int64, error)
30+}
31+32+// StatusFilterable extends MediaRepository with status-based filtering
33+//
34+// Media types (Books, Movies, TV) support status-based queries like "queued", "reading", "watching", "watched", "finished"
35+type StatusFilterable[T models.Model] interface {
36+ MediaRepository[T]
37+38+ // GetByStatus retrieves all items with the given status
39+ GetByStatus(ctx context.Context, status string) ([]*T, error)
40+}
+38-80
internal/repo/movie_repository.go
···1213// MovieRepository provides database operations for movies
14type MovieRepository struct {
015 db *sql.DB
16}
1718// NewMovieRepository creates a new movie repository
19func NewMovieRepository(db *sql.DB) *MovieRepository {
20- return &MovieRepository{db: db}
000000000000000000000021}
2223// Create stores a new movie and returns its assigned ID
···25 now := time.Now()
26 movie.Added = now
2728- query := `
29- INSERT INTO movies (title, year, status, rating, notes, added, watched)
30- VALUES (?, ?, ?, ?, ?, ?, ?)`
31-32- result, err := r.db.ExecContext(ctx, query,
33- movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Added, movie.Watched)
34 if err != nil {
35- return 0, fmt.Errorf("failed to insert movie: %w", err)
36- }
37-38- id, err := result.LastInsertId()
39- if err != nil {
40- return 0, fmt.Errorf("failed to get last insert id: %w", err)
41 }
4243 movie.ID = id
44 return id, nil
45}
4647-// Get retrieves a movie by ID
48-func (r *MovieRepository) Get(ctx context.Context, id int64) (*models.Movie, error) {
49- query := `
50- SELECT id, title, year, status, rating, notes, added, watched
51- FROM movies WHERE id = ?`
52-53- movie := &models.Movie{}
54- err := r.db.QueryRowContext(ctx, query, id).Scan(
55- &movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
56- &movie.Notes, &movie.Added, &movie.Watched)
57- if err != nil {
58- return nil, fmt.Errorf("failed to get movie: %w", err)
59- }
60-61- return movie, nil
62-}
63-64-// Update modifies an existing movie
65-func (r *MovieRepository) Update(ctx context.Context, movie *models.Movie) error {
66- query := `
67- UPDATE movies SET title = ?, year = ?, status = ?, rating = ?, notes = ?, watched = ?
68- WHERE id = ?`
69-70- _, err := r.db.ExecContext(ctx, query,
71- movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Watched, movie.ID)
72- if err != nil {
73- return fmt.Errorf("failed to update movie: %w", err)
74- }
75-76- return nil
77-}
78-79-// Delete removes a movie by ID
80-func (r *MovieRepository) Delete(ctx context.Context, id int64) error {
81- query := "DELETE FROM movies WHERE id = ?"
82- _, err := r.db.ExecContext(ctx, query, id)
83- if err != nil {
84- return fmt.Errorf("failed to delete movie: %w", err)
85- }
86- return nil
87-}
88-89// List retrieves movies with optional filtering and sorting
90func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) {
91 query := r.buildListQuery(opts)
92 args := r.buildListArgs(opts)
9394- rows, err := r.db.QueryContext(ctx, query, args...)
95 if err != nil {
96- return nil, fmt.Errorf("failed to list movies: %w", err)
97 }
98- defer rows.Close()
99-100- var movies []*models.Movie
101- for rows.Next() {
102- movie := &models.Movie{}
103- if err := r.scanMovieRow(rows, movie); err != nil {
104- return nil, err
105- }
106- movies = append(movies, movie)
107- }
108-109- return movies, rows.Err()
110}
111112func (r *MovieRepository) buildListQuery(opts MovieListOptions) string {
···177 return args
178}
179180-func (r *MovieRepository) scanMovieRow(rows *sql.Rows, movie *models.Movie) error {
0181 return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
182 &movie.Notes, &movie.Added, &movie.Watched)
183}
184000000185// Find retrieves movies matching specific conditions
186func (r *MovieRepository) Find(ctx context.Context, conditions MovieListOptions) ([]*models.Movie, error) {
187 return r.List(ctx, conditions)
···220 if len(conditions) > 0 {
221 query += " WHERE " + strings.Join(conditions, " AND ")
222 }
223-224- var count int64
225- err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
226- if err != nil {
227- return 0, fmt.Errorf("failed to count movies: %w", err)
228- }
229-230- return count, nil
231}
232233// GetQueued retrieves all movies in the queue
···246 if err != nil {
247 return err
248 }
249-250 now := time.Now()
251 movie.Status = "watched"
252 movie.Watched = &now
253-254 return r.Update(ctx, movie)
255}
256
···1213// MovieRepository provides database operations for movies
14type MovieRepository struct {
15+ *BaseMediaRepository[*models.Movie]
16 db *sql.DB
17}
1819// NewMovieRepository creates a new movie repository
20func NewMovieRepository(db *sql.DB) *MovieRepository {
21+ config := MediaConfig[*models.Movie]{
22+ TableName: "movies",
23+ New: func() *models.Movie { return &models.Movie{} },
24+ InsertColumns: "title, year, status, rating, notes, added, watched",
25+ UpdateColumns: "title = ?, year = ?, status = ?, rating = ?, notes = ?, watched = ?",
26+ InsertValues: func(movie *models.Movie) []any {
27+ return []any{movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Added, movie.Watched}
28+ },
29+ UpdateValues: func(movie *models.Movie) []any {
30+ return []any{movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Watched, movie.ID}
31+ },
32+ Scan: func(rows *sql.Rows, movie *models.Movie) error {
33+ return scanMovieRow(rows, movie)
34+ },
35+ ScanSingle: func(row *sql.Row, movie *models.Movie) error {
36+ return scanMovieRowSingle(row, movie)
37+ },
38+ }
39+40+ return &MovieRepository{
41+ BaseMediaRepository: NewBaseMediaRepository(db, config),
42+ db: db,
43+ }
44}
4546// Create stores a new movie and returns its assigned ID
···48 now := time.Now()
49 movie.Added = now
5051+ id, err := r.BaseMediaRepository.Create(ctx, movie)
0000052 if err != nil {
53+ return 0, err
0000054 }
5556 movie.ID = id
57 return id, nil
58}
5900000000000000000000000000000000000000000060// List retrieves movies with optional filtering and sorting
61func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) {
62 query := r.buildListQuery(opts)
63 args := r.buildListArgs(opts)
6465+ items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...)
66 if err != nil {
67+ return nil, err
68 }
69+ return items, nil
0000000000070}
7172func (r *MovieRepository) buildListQuery(opts MovieListOptions) string {
···137 return args
138}
139140+// scanMovieRow scans a database row into a [models.Movie]
141+func scanMovieRow(rows *sql.Rows, movie *models.Movie) error {
142 return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
143 &movie.Notes, &movie.Added, &movie.Watched)
144}
145146+// scanMovieRowSingle scans a single database row into a [models.Movie]
147+func scanMovieRowSingle(row *sql.Row, movie *models.Movie) error {
148+ return row.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
149+ &movie.Notes, &movie.Added, &movie.Watched)
150+}
151+152// Find retrieves movies matching specific conditions
153func (r *MovieRepository) Find(ctx context.Context, conditions MovieListOptions) ([]*models.Movie, error) {
154 return r.List(ctx, conditions)
···187 if len(conditions) > 0 {
188 query += " WHERE " + strings.Join(conditions, " AND ")
189 }
190+ return r.BaseMediaRepository.CountQuery(ctx, query, args...)
0000000191}
192193// GetQueued retrieves all movies in the queue
···206 if err != nil {
207 return err
208 }
0209 now := time.Now()
210 movie.Status = "watched"
211 movie.Watched = &now
0212 return r.Update(ctx, movie)
213}
214
+2
internal/repo/task_repository.go
···51}
5253// TaskRepository provides database operations for tasks
0054type TaskRepository struct {
55 db *sql.DB
56}
···51}
5253// TaskRepository provides database operations for tasks
54+//
55+// TODO: Implement Repository interface (Validate method) similar to ArticleRepository
56type TaskRepository struct {
57 db *sql.DB
58}
+36-82
internal/repo/tv_repository.go
···1213// TVRepository provides database operations for TV shows
14type TVRepository struct {
015 db *sql.DB
16}
1718// NewTVRepository creates a new TV show repository
19func NewTVRepository(db *sql.DB) *TVRepository {
20- return &TVRepository{db: db}
000000000000000000000021}
2223// Create stores a new TV show and returns its assigned ID
···25 now := time.Now()
26 tvShow.Added = now
2728- query := `
29- INSERT INTO tv_shows (title, season, episode, status, rating, notes, added, last_watched)
30- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
31-32- result, err := r.db.ExecContext(ctx, query,
33- tvShow.Title, tvShow.Season, tvShow.Episode, tvShow.Status, tvShow.Rating,
34- tvShow.Notes, tvShow.Added, tvShow.LastWatched)
35 if err != nil {
36- return 0, fmt.Errorf("failed to insert TV show: %w", err)
37- }
38-39- id, err := result.LastInsertId()
40- if err != nil {
41- return 0, fmt.Errorf("failed to get last insert id: %w", err)
42 }
4344 tvShow.ID = id
45 return id, nil
46}
4748-// Get retrieves a TV show by ID
49-func (r *TVRepository) Get(ctx context.Context, id int64) (*models.TVShow, error) {
50- query := `
51- SELECT id, title, season, episode, status, rating, notes, added, last_watched
52- FROM tv_shows WHERE id = ?`
53-54- tvShow := &models.TVShow{}
55- err := r.db.QueryRowContext(ctx, query, id).Scan(
56- &tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status,
57- &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched)
58- if err != nil {
59- return nil, fmt.Errorf("failed to get TV show: %w", err)
60- }
61-62- return tvShow, nil
63-}
64-65-// Update modifies an existing TV show
66-func (r *TVRepository) Update(ctx context.Context, tvShow *models.TVShow) error {
67- query := `
68- UPDATE tv_shows SET title = ?, season = ?, episode = ?, status = ?, rating = ?,
69- notes = ?, last_watched = ?
70- WHERE id = ?`
71-72- _, err := r.db.ExecContext(ctx, query,
73- tvShow.Title, tvShow.Season, tvShow.Episode, tvShow.Status, tvShow.Rating,
74- tvShow.Notes, tvShow.LastWatched, tvShow.ID)
75- if err != nil {
76- return fmt.Errorf("failed to update TV show: %w", err)
77- }
78-79- return nil
80-}
81-82-// Delete removes a TV show by ID
83-func (r *TVRepository) Delete(ctx context.Context, id int64) error {
84- query := "DELETE FROM tv_shows WHERE id = ?"
85- _, err := r.db.ExecContext(ctx, query, id)
86- if err != nil {
87- return fmt.Errorf("failed to delete TV show: %w", err)
88- }
89- return nil
90-}
91-92// List retrieves TV shows with optional filtering and sorting
93func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) {
94 query := r.buildListQuery(opts)
95 args := r.buildListArgs(opts)
9697- rows, err := r.db.QueryContext(ctx, query, args...)
98 if err != nil {
99- return nil, fmt.Errorf("failed to list TV shows: %w", err)
100 }
101- defer rows.Close()
102-103- var tvShows []*models.TVShow
104- for rows.Next() {
105- tvShow := &models.TVShow{}
106- if err := r.scanTVShowRow(rows, tvShow); err != nil {
107- return nil, err
108- }
109- tvShows = append(tvShows, tvShow)
110- }
111-112- return tvShows, rows.Err()
113}
114115func (r *TVRepository) buildListQuery(opts TVListOptions) string {
···186 return args
187}
188189-func (r *TVRepository) scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error {
190 return rows.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status,
191 &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched)
192}
19300000194// Find retrieves TV shows matching specific conditions
195func (r *TVRepository) Find(ctx context.Context, conditions TVListOptions) ([]*models.TVShow, error) {
196 return r.List(ctx, conditions)
···234 query += " WHERE " + strings.Join(conditions, " AND ")
235 }
236237- var count int64
238- err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
239- if err != nil {
240- return 0, fmt.Errorf("failed to count TV shows: %w", err)
241- }
242-243- return count, nil
244}
245246// GetQueued retrieves all TV shows in the queue
···274 if err != nil {
275 return err
276 }
277-278 now := time.Now()
279 tvShow.Status = "watched"
280 tvShow.LastWatched = &now
281-282 return r.Update(ctx, tvShow)
283}
284