cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

refactor: shared behavioral interfaces for media domains (handler & repo layer)

+560 -324
+2 -1
cmd/commands.go
··· 212 212 Use the -i flag for an interactive interface with navigation keys.`, 213 213 RunE: func(cmd *cobra.Command, args []string) error { 214 214 interactive, _ := cmd.Flags().GetBool("interactive") 215 - return c.handler.SearchAndAdd(cmd.Context(), args, interactive) 215 + query := strings.Join(args, " ") 216 + return c.handler.SearchAndAdd(cmd.Context(), query, interactive) 216 217 }, 217 218 } 218 219 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection")
+33 -10
internal/handlers/books.go
··· 17 17 ) 18 18 19 19 // BookHandler handles all book-related commands 20 + // 21 + // Implements MediaHandler interface for polymorphic media handling 20 22 type BookHandler struct { 21 23 db *store.Database 22 24 config *store.Config ··· 24 26 service *services.BookService 25 27 reader io.Reader 26 28 } 29 + 30 + // Ensure BookHandler implements MediaHandler interface 31 + var _ MediaHandler = (*BookHandler)(nil) 27 32 28 33 // NewBookHandler creates a new book handler 29 34 func NewBookHandler() (*BookHandler, error) { ··· 94 99 } 95 100 96 101 // SearchAndAdd searches for books and allows user to select and add to queue 97 - func (h *BookHandler) SearchAndAdd(ctx context.Context, args []string, interactive bool) error { 98 - if len(args) == 0 { 99 - return fmt.Errorf("usage: book add <search query>") 100 - } 101 - 102 - query := args[0] 103 - if len(args) > 1 { 104 - for _, arg := range args[1:] { 105 - query += " " + arg 106 - } 102 + func (h *BookHandler) SearchAndAdd(ctx context.Context, query string, interactive bool) error { 103 + if query == "" { 104 + return fmt.Errorf("search query cannot be empty") 107 105 } 108 106 109 107 if interactive { ··· 309 307 fmt.Println() 310 308 return nil 311 309 } 310 + 311 + // Remove removes a book from the queue 312 + func (h *BookHandler) Remove(ctx context.Context, id string) error { 313 + bookID, err := ParseID(id, "book") 314 + if err != nil { 315 + return err 316 + } 317 + 318 + book, err := h.repos.Books.Get(ctx, bookID) 319 + if err != nil { 320 + return fmt.Errorf("book %d not found: %w", bookID, err) 321 + } 322 + 323 + if err := h.repos.Books.Delete(ctx, bookID); err != nil { 324 + return fmt.Errorf("failed to remove book: %w", err) 325 + } 326 + 327 + fmt.Printf("โœ“ Removed book: %s", book.Title) 328 + if book.Author != "" { 329 + fmt.Printf(" by %s", book.Author) 330 + } 331 + fmt.Println() 332 + 333 + return nil 334 + }
+16 -15
internal/handlers/books_test.go
··· 124 124 ctx := context.Background() 125 125 t.Run("fails with empty args", func(t *testing.T) { 126 126 args := []string{} 127 - err := handler.SearchAndAdd(ctx, args, false) 127 + query := strings.Join(args, " ") 128 + err := handler.SearchAndAdd(ctx, query, false) 128 129 if err == nil { 129 130 t.Error("Expected error for empty args") 130 131 } 131 - 132 - if !strings.Contains(err.Error(), "usage: book add") { 133 - t.Errorf("Expected usage error, got: %v", err) 134 - } 135 132 }) 136 133 137 134 t.Run("context cancellation during search", func(t *testing.T) { 138 135 ctx, cancel := context.WithCancel(context.Background()) 139 136 cancel() 140 - 141 - if err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false); err == nil { 137 + query := strings.Join([]string{"test", "book"}, " ") 138 + if err := handler.SearchAndAdd(ctx, query, false); err == nil { 142 139 t.Error("Expected error for cancelled context") 143 140 } 144 141 }) ··· 149 146 150 147 handler.service = services.NewBookService(mockServer.URL()) 151 148 152 - err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false) 149 + err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false) 153 150 if err == nil { 154 151 t.Error("Expected error for HTTP 500") 155 152 } ··· 165 162 166 163 handler.service = services.NewBookService(mockServer.URL()) 167 164 168 - err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false) 165 + err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false) 169 166 if err == nil { 170 167 t.Error("Expected error for malformed JSON") 171 168 } ··· 186 183 handler.service = services.NewBookService(mockServer.URL()) 187 184 188 185 args := []string{"nonexistent", "book"} 189 - err := handler.SearchAndAdd(ctx, args, false) 186 + query := strings.Join(args, " ") 187 + err := handler.SearchAndAdd(ctx, query, false) 190 188 if err != nil { 191 189 t.Errorf("Expected no error for empty results, got: %v", err) 192 190 } ··· 201 199 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 202 200 defer cancel() 203 201 204 - if err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false); err == nil { 202 + if err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false); err == nil { 205 203 t.Error("Expected error for timeout") 206 204 } 207 205 }) ··· 219 217 ctx, cancel := context.WithCancel(context.Background()) 220 218 cancel() 221 219 222 - if err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false); err == nil { 220 + if err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false); err == nil { 223 221 t.Error("Expected error for cancelled context") 224 222 } 225 223 }) ··· 289 287 handler.SetInputReader(MenuSelection(1)) 290 288 291 289 args := []string{"test", "search"} 292 - err := handler.SearchAndAdd(ctx, args, false) 290 + query := strings.Join(args, " ") 291 + err := handler.SearchAndAdd(ctx, query, false) 293 292 if err != nil { 294 293 t.Errorf("Expected successful search and add, got error: %v", err) 295 294 } ··· 318 317 handler.SetInputReader(MenuCancel()) 319 318 320 319 args := []string{"another", "search"} 321 - err := handler.SearchAndAdd(ctx, args, false) 320 + query := strings.Join(args, " ") 321 + err := handler.SearchAndAdd(ctx, query, false) 322 322 if err != nil { 323 323 t.Errorf("Expected no error on cancellation, got: %v", err) 324 324 } ··· 345 345 handler.SetInputReader(MenuSelection(5)) 346 346 347 347 args := []string{"choice", "test"} 348 - err := handler.SearchAndAdd(ctx, args, false) 348 + query := strings.Join(args, " ") 349 + err := handler.SearchAndAdd(ctx, query, false) 349 350 if err == nil { 350 351 t.Error("Expected error for invalid choice") 351 352 }
+44
internal/handlers/media_handler.go
··· 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 + }
+102
internal/handlers/media_utilities.go
··· 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 24 reader io.Reader 25 25 } 26 26 27 + // Ensure MovieHandler implements interface [MediaHandler] 28 + var _ MediaHandler = (*MovieHandler)(nil) 29 + 27 30 // NewMovieHandler creates a new movie handler 28 31 func NewMovieHandler() (*MovieHandler, error) { 29 32 db, err := store.NewDatabase() ··· 218 221 } 219 222 220 223 // UpdateStatus changes the status of a movie 221 - func (h *MovieHandler) UpdateStatus(ctx context.Context, movieID int64, status string) error { 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 + } 222 229 validStatuses := []string{"queued", "watched", "removed"} 223 230 if !slices.Contains(validStatuses, status) { 224 231 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", ")) ··· 245 252 246 253 // MarkWatched marks a movie as watched 247 254 func (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") 255 + return h.UpdateStatus(ctx, id, "watched") 254 256 } 255 257 256 258 // Remove removes a movie from the queue
+6 -9
internal/handlers/movies_test.go
··· 358 358 handler := createTestMovieHandler(t) 359 359 defer handler.Close() 360 360 361 - err := handler.UpdateStatus(context.Background(), 1, "invalid") 361 + err := handler.UpdateStatus(context.Background(), "1", "invalid") 362 362 if err == nil { 363 363 t.Error("Expected error for invalid status") 364 364 } ··· 371 371 handler := createTestMovieHandler(t) 372 372 defer handler.Close() 373 373 374 - err := handler.UpdateStatus(context.Background(), 999, "watched") 374 + err := handler.UpdateStatus(context.Background(), "999", "watched") 375 375 if err == nil { 376 376 t.Error("Expected error for non-existent movie") 377 377 } ··· 406 406 err := handler.MarkWatched(context.Background(), "invalid") 407 407 if err == nil { 408 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 409 } 413 410 }) 414 411 ··· 468 465 t.Errorf("Failed to view created movie: %v", err) 469 466 } 470 467 471 - err = handler.UpdateStatus(context.Background(), id, "watched") 468 + err = handler.UpdateStatus(context.Background(), strconv.Itoa(int(id)), "watched") 472 469 if err != nil { 473 470 t.Errorf("Failed to update movie status: %v", err) 474 471 } ··· 540 537 }, 541 538 { 542 539 name: "Update status of non-existent movie", 543 - fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") }, 540 + fn: func() error { return handler.UpdateStatus(ctx, strconv.Itoa(int(nonExistentID)), "watched") }, 544 541 }, 545 542 { 546 543 name: "Mark non-existent movie as watched", ··· 570 567 invalid := []string{"invalid", "pending", "completed", ""} 571 568 572 569 for _, status := range valid { 573 - if err := handler.UpdateStatus(context.Background(), 999, status); err != nil && 570 + if err := handler.UpdateStatus(context.Background(), "999", status); err != nil && 574 571 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watched, removed)", status) { 575 572 t.Errorf("Status '%s' should be valid but was rejected", status) 576 573 } 577 574 } 578 575 579 576 for _, status := range invalid { 580 - err := handler.UpdateStatus(context.Background(), 1, status) 577 + err := handler.UpdateStatus(context.Background(), "1", status) 581 578 if err == nil { 582 579 t.Errorf("Status '%s' should be invalid but was accepted", status) 583 580 }
+15 -19
internal/handlers/tv.go
··· 16 16 ) 17 17 18 18 // TVHandler handles all TV show-related commands 19 + // 20 + // Implements MediaHandler interface for polymorphic media handling 19 21 type TVHandler struct { 20 22 db *store.Database 21 23 config *store.Config ··· 23 25 service *services.TVService 24 26 reader io.Reader 25 27 } 28 + 29 + // Ensure TVHandler implements MediaHandler interface 30 + var _ MediaHandler = (*TVHandler)(nil) 26 31 27 32 // NewTVHandler creates a new TV handler 28 33 func NewTVHandler() (*TVHandler, error) { ··· 232 237 } 233 238 234 239 // UpdateStatus changes the status of a TV show 235 - func (h *TVHandler) UpdateStatus(ctx context.Context, showID int64, status string) error { 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 + } 236 245 validStatuses := []string{"queued", "watching", "watched", "removed"} 237 246 if !slices.Contains(validStatuses, status) { 238 247 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", ")) ··· 258 267 } 259 268 260 269 // 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") 270 + func (h *TVHandler) MarkWatching(ctx context.Context, id string) error { 271 + return h.UpdateStatus(ctx, id, "watching") 263 272 } 264 273 265 274 // MarkWatched marks a TV show as watched 266 275 func (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") 276 + return h.UpdateStatus(ctx, id, "watched") 273 277 } 274 278 275 279 // Remove removes a TV show from the queue ··· 317 321 318 322 // UpdateTVShowStatus changes the status of a TV show 319 323 func (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) 324 + return h.UpdateStatus(ctx, id, status) 325 325 } 326 326 327 327 // MarkTVShowWatching marks a TV show as currently watching 328 328 func (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) 329 + return h.MarkWatching(ctx, id) 334 330 }
+12 -21
internal/handlers/tv_test.go
··· 355 355 handler := createTestTVHandler(t) 356 356 defer handler.Close() 357 357 358 - err := handler.View(context.Background(), strconv.Itoa(int(999))) 358 + err := handler.View(context.Background(), "999") 359 359 if err == nil { 360 360 t.Error("Expected error for non-existent TV show") 361 361 } ··· 381 381 handler := createTestTVHandler(t) 382 382 defer handler.Close() 383 383 384 - err := handler.UpdateStatus(context.Background(), 1, "invalid") 384 + err := handler.UpdateStatus(context.Background(), "1", "invalid") 385 385 if err == nil { 386 386 t.Error("Expected error for invalid status") 387 387 } ··· 394 394 handler := createTestTVHandler(t) 395 395 defer handler.Close() 396 396 397 - err := handler.UpdateStatus(context.Background(), 999, "watched") 397 + err := handler.UpdateStatus(context.Background(), "999", "watched") 398 398 if err == nil { 399 399 t.Error("Expected error for non-existent TV show") 400 400 } ··· 406 406 handler := createTestTVHandler(t) 407 407 defer handler.Close() 408 408 409 - err := handler.MarkWatching(context.Background(), 999) 409 + err := handler.MarkWatching(context.Background(), "999") 410 410 if err == nil { 411 411 t.Error("Expected error for non-existent TV show") 412 412 } ··· 416 416 handler := createTestTVHandler(t) 417 417 defer handler.Close() 418 418 419 - err := handler.MarkWatched(context.Background(), strconv.Itoa(int(999))) 419 + err := handler.MarkWatched(context.Background(), "999") 420 420 if err == nil { 421 421 t.Error("Expected error for non-existent TV show") 422 422 } ··· 426 426 handler := createTestTVHandler(t) 427 427 defer handler.Close() 428 428 429 - err := handler.Remove(context.Background(), strconv.Itoa(int(999))) 429 + err := handler.Remove(context.Background(), "999") 430 430 if err == nil { 431 431 t.Error("Expected error for non-existent TV show") 432 432 } ··· 440 440 if err == nil { 441 441 t.Error("Expected error for invalid TV show ID") 442 442 } 443 - if err.Error() != "invalid TV show ID: invalid" { 444 - t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 445 - } 446 443 }) 447 444 448 445 t.Run("MarkTVShowWatching_InvalidID", func(t *testing.T) { ··· 453 450 if err == nil { 454 451 t.Error("Expected error for invalid TV show ID") 455 452 } 456 - if err.Error() != "invalid TV show ID: invalid" { 457 - t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 458 - } 459 453 }) 460 454 461 455 t.Run("MarkWatched_InvalidID", func(t *testing.T) { ··· 465 459 err := handler.MarkWatched(context.Background(), "invalid") 466 460 if err == nil { 467 461 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 462 } 472 463 }) 473 464 ··· 528 519 t.Errorf("Failed to view created TV show: %v", err) 529 520 } 530 521 531 - err = handler.UpdateStatus(context.Background(), id, "watching") 522 + err = handler.UpdateStatus(context.Background(), strconv.Itoa(int(id)), "watching") 532 523 if err != nil { 533 524 t.Errorf("Failed to update TV show status: %v", err) 534 525 } ··· 538 529 t.Errorf("Failed to mark TV show as watched: %v", err) 539 530 } 540 531 541 - err = handler.MarkWatching(context.Background(), id) 532 + err = handler.MarkWatching(context.Background(), strconv.Itoa(int(id))) 542 533 if err != nil { 543 534 t.Errorf("Failed to mark TV show as watching: %v", err) 544 535 } ··· 617 608 }, 618 609 { 619 610 name: "Update status of non-existent show", 620 - fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") }, 611 + fn: func() error { return handler.UpdateStatus(ctx, strconv.Itoa(int(nonExistentID)), "watched") }, 621 612 }, 622 613 { 623 614 name: "Mark non-existent show as watching", 624 - fn: func() error { return handler.MarkWatching(ctx, nonExistentID) }, 615 + fn: func() error { return handler.MarkWatching(ctx, strconv.Itoa(int(nonExistentID))) }, 625 616 }, 626 617 { 627 618 name: "Mark non-existent show as watched", ··· 651 642 invalid := []string{"invalid", "pending", "completed", ""} 652 643 653 644 for _, status := range valid { 654 - if err := handler.UpdateStatus(context.Background(), 999, status); err != nil && 645 + if err := handler.UpdateStatus(context.Background(), "999", status); err != nil && 655 646 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watching, watched, removed)", status) { 656 647 t.Errorf("Status '%s' should be valid but was rejected", status) 657 648 } 658 649 } 659 650 660 651 for _, status := range invalid { 661 - err := handler.UpdateStatus(context.Background(), 1, status) 652 + err := handler.UpdateStatus(context.Background(), "1", status) 662 653 if err == nil { 663 654 t.Errorf("Status '%s' should be invalid but was accepted", status) 664 655 }
+154
internal/repo/base_media_repository.go
··· 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 11 ) 12 12 13 13 // BookRepository provides database operations for books 14 + // 15 + // Uses BaseMediaRepository for common CRUD operations. 16 + // TODO: Implement Repository interface (Validate method) similar to ArticleRepository 14 17 type BookRepository struct { 18 + *BaseMediaRepository[*models.Book] 15 19 db *sql.DB 16 20 } 17 21 18 22 // NewBookRepository creates a new book repository 19 23 func NewBookRepository(db *sql.DB) *BookRepository { 20 - return &BookRepository{db: db} 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 + } 21 47 } 22 48 23 49 // Create stores a new book and returns its assigned ID ··· 25 51 now := time.Now() 26 52 book.Added = now 27 53 28 - 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) 54 + id, err := r.BaseMediaRepository.Create(ctx, book) 35 55 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) 56 + return 0, err 42 57 } 43 58 44 59 book.ID = id 45 60 return id, nil 46 61 } 47 62 48 - // 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 63 // List retrieves books with optional filtering and sorting 93 64 func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 94 65 query := r.buildListQuery(opts) 95 66 args := r.buildListArgs(opts) 96 67 97 - rows, err := r.db.QueryContext(ctx, query, args...) 68 + items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 98 69 if err != nil { 99 - return nil, fmt.Errorf("failed to list books: %w", err) 70 + return nil, err 100 71 } 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() 72 + return items, nil 113 73 } 114 74 115 75 func (r *BookRepository) buildListQuery(opts BookListOptions) string { ··· 187 147 return args 188 148 } 189 149 190 - func (r *BookRepository) scanBookRow(rows *sql.Rows, book *models.Book) error { 150 + // scanBookRow scans a database row into a book model 151 + func scanBookRow(rows *sql.Rows, book *models.Book) error { 191 152 var pages sql.NullInt64 192 153 193 154 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, 194 171 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 195 172 return err 196 173 } ··· 246 223 query += " WHERE " + strings.Join(conditions, " AND ") 247 224 } 248 225 249 - 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 226 + return r.BaseMediaRepository.CountQuery(ctx, query, args...) 256 227 } 257 228 258 229 // GetQueued retrieves all books in the queue
+40
internal/repo/media_repository.go
··· 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
··· 12 12 13 13 // MovieRepository provides database operations for movies 14 14 type MovieRepository struct { 15 + *BaseMediaRepository[*models.Movie] 15 16 db *sql.DB 16 17 } 17 18 18 19 // NewMovieRepository creates a new movie repository 19 20 func NewMovieRepository(db *sql.DB) *MovieRepository { 20 - return &MovieRepository{db: db} 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 + } 21 44 } 22 45 23 46 // Create stores a new movie and returns its assigned ID ··· 25 48 now := time.Now() 26 49 movie.Added = now 27 50 28 - 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) 51 + id, err := r.BaseMediaRepository.Create(ctx, movie) 34 52 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) 53 + return 0, err 41 54 } 42 55 43 56 movie.ID = id 44 57 return id, nil 45 58 } 46 59 47 - // 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 60 // List retrieves movies with optional filtering and sorting 90 61 func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) { 91 62 query := r.buildListQuery(opts) 92 63 args := r.buildListArgs(opts) 93 64 94 - rows, err := r.db.QueryContext(ctx, query, args...) 65 + items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 95 66 if err != nil { 96 - return nil, fmt.Errorf("failed to list movies: %w", err) 67 + return nil, err 97 68 } 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() 69 + return items, nil 110 70 } 111 71 112 72 func (r *MovieRepository) buildListQuery(opts MovieListOptions) string { ··· 177 137 return args 178 138 } 179 139 180 - func (r *MovieRepository) scanMovieRow(rows *sql.Rows, movie *models.Movie) error { 140 + // scanMovieRow scans a database row into a [models.Movie] 141 + func scanMovieRow(rows *sql.Rows, movie *models.Movie) error { 181 142 return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating, 182 143 &movie.Notes, &movie.Added, &movie.Watched) 183 144 } 184 145 146 + // 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 + 185 152 // Find retrieves movies matching specific conditions 186 153 func (r *MovieRepository) Find(ctx context.Context, conditions MovieListOptions) ([]*models.Movie, error) { 187 154 return r.List(ctx, conditions) ··· 220 187 if len(conditions) > 0 { 221 188 query += " WHERE " + strings.Join(conditions, " AND ") 222 189 } 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 190 + return r.BaseMediaRepository.CountQuery(ctx, query, args...) 231 191 } 232 192 233 193 // GetQueued retrieves all movies in the queue ··· 246 206 if err != nil { 247 207 return err 248 208 } 249 - 250 209 now := time.Now() 251 210 movie.Status = "watched" 252 211 movie.Watched = &now 253 - 254 212 return r.Update(ctx, movie) 255 213 } 256 214
+2
internal/repo/task_repository.go
··· 51 51 } 52 52 53 53 // TaskRepository provides database operations for tasks 54 + // 55 + // TODO: Implement Repository interface (Validate method) similar to ArticleRepository 54 56 type TaskRepository struct { 55 57 db *sql.DB 56 58 }
+36 -82
internal/repo/tv_repository.go
··· 12 12 13 13 // TVRepository provides database operations for TV shows 14 14 type TVRepository struct { 15 + *BaseMediaRepository[*models.TVShow] 15 16 db *sql.DB 16 17 } 17 18 18 19 // NewTVRepository creates a new TV show repository 19 20 func NewTVRepository(db *sql.DB) *TVRepository { 20 - return &TVRepository{db: db} 21 + config := MediaConfig[*models.TVShow]{ 22 + TableName: "tv_shows", 23 + New: func() *models.TVShow { return &models.TVShow{} }, 24 + InsertColumns: "title, season, episode, status, rating, notes, added, last_watched", 25 + UpdateColumns: "title = ?, season = ?, episode = ?, status = ?, rating = ?, notes = ?, last_watched = ?", 26 + InsertValues: func(show *models.TVShow) []any { 27 + return []any{show.Title, show.Season, show.Episode, show.Status, show.Rating, show.Notes, show.Added, show.LastWatched} 28 + }, 29 + UpdateValues: func(show *models.TVShow) []any { 30 + return []any{show.Title, show.Season, show.Episode, show.Status, show.Rating, show.Notes, show.LastWatched, show.ID} 31 + }, 32 + Scan: func(rows *sql.Rows, show *models.TVShow) error { 33 + return scanTVShowRow(rows, show) 34 + }, 35 + ScanSingle: func(row *sql.Row, show *models.TVShow) error { 36 + return scanTVShowRowSingle(row, show) 37 + }, 38 + } 39 + 40 + return &TVRepository{ 41 + BaseMediaRepository: NewBaseMediaRepository(db, config), 42 + db: db, 43 + } 21 44 } 22 45 23 46 // Create stores a new TV show and returns its assigned ID ··· 25 48 now := time.Now() 26 49 tvShow.Added = now 27 50 28 - 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) 51 + id, err := r.BaseMediaRepository.Create(ctx, tvShow) 35 52 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) 53 + return 0, err 42 54 } 43 55 44 56 tvShow.ID = id 45 57 return id, nil 46 58 } 47 59 48 - // 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 60 // List retrieves TV shows with optional filtering and sorting 93 61 func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) { 94 62 query := r.buildListQuery(opts) 95 63 args := r.buildListArgs(opts) 96 64 97 - rows, err := r.db.QueryContext(ctx, query, args...) 65 + items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 98 66 if err != nil { 99 - return nil, fmt.Errorf("failed to list TV shows: %w", err) 67 + return nil, err 100 68 } 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() 69 + return items, nil 113 70 } 114 71 115 72 func (r *TVRepository) buildListQuery(opts TVListOptions) string { ··· 186 143 return args 187 144 } 188 145 189 - func (r *TVRepository) scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error { 146 + func scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error { 190 147 return rows.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status, 191 148 &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched) 192 149 } 193 150 151 + func scanTVShowRowSingle(row *sql.Row, tvShow *models.TVShow) error { 152 + return row.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status, 153 + &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched) 154 + } 155 + 194 156 // Find retrieves TV shows matching specific conditions 195 157 func (r *TVRepository) Find(ctx context.Context, conditions TVListOptions) ([]*models.TVShow, error) { 196 158 return r.List(ctx, conditions) ··· 234 196 query += " WHERE " + strings.Join(conditions, " AND ") 235 197 } 236 198 237 - 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 199 + return r.BaseMediaRepository.CountQuery(ctx, query, args...) 244 200 } 245 201 246 202 // GetQueued retrieves all TV shows in the queue ··· 274 230 if err != nil { 275 231 return err 276 232 } 277 - 278 233 now := time.Now() 279 234 tvShow.Status = "watched" 280 235 tvShow.LastWatched = &now 281 - 282 236 return r.Update(ctx, tvShow) 283 237 } 284 238