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 Use the -i flag for an interactive interface with navigation keys.`, 213 RunE: func(cmd *cobra.Command, args []string) error { 214 interactive, _ := cmd.Flags().GetBool("interactive") 215 - return c.handler.SearchAndAdd(cmd.Context(), args, interactive) 216 }, 217 } 218 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection")
··· 212 Use the -i flag for an interactive interface with navigation keys.`, 213 RunE: func(cmd *cobra.Command, args []string) error { 214 interactive, _ := cmd.Flags().GetBool("interactive") 215 + query := strings.Join(args, " ") 216 + return c.handler.SearchAndAdd(cmd.Context(), query, interactive) 217 }, 218 } 219 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection")
+33 -10
internal/handlers/books.go
··· 17 ) 18 19 // BookHandler handles all book-related commands 20 type BookHandler struct { 21 db *store.Database 22 config *store.Config ··· 24 service *services.BookService 25 reader io.Reader 26 } 27 28 // NewBookHandler creates a new book handler 29 func NewBookHandler() (*BookHandler, error) { ··· 94 } 95 96 // 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 - } 107 } 108 109 if interactive { ··· 309 fmt.Println() 310 return nil 311 }
··· 17 ) 18 19 // BookHandler handles all book-related commands 20 + // 21 + // Implements MediaHandler interface for polymorphic media handling 22 type BookHandler struct { 23 db *store.Database 24 config *store.Config ··· 26 service *services.BookService 27 reader io.Reader 28 } 29 + 30 + // Ensure BookHandler implements MediaHandler interface 31 + var _ MediaHandler = (*BookHandler)(nil) 32 33 // NewBookHandler creates a new book handler 34 func NewBookHandler() (*BookHandler, error) { ··· 99 } 100 101 // SearchAndAdd searches for books and allows user to select and add to queue 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") 105 } 106 107 if interactive { ··· 307 fmt.Println() 308 return nil 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 ctx := context.Background() 125 t.Run("fails with empty args", func(t *testing.T) { 126 args := []string{} 127 - err := handler.SearchAndAdd(ctx, args, false) 128 if err == nil { 129 t.Error("Expected error for empty args") 130 } 131 - 132 - if !strings.Contains(err.Error(), "usage: book add") { 133 - t.Errorf("Expected usage error, got: %v", err) 134 - } 135 }) 136 137 t.Run("context cancellation during search", func(t *testing.T) { 138 ctx, cancel := context.WithCancel(context.Background()) 139 cancel() 140 - 141 - if err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false); err == nil { 142 t.Error("Expected error for cancelled context") 143 } 144 }) ··· 149 150 handler.service = services.NewBookService(mockServer.URL()) 151 152 - err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false) 153 if err == nil { 154 t.Error("Expected error for HTTP 500") 155 } ··· 165 166 handler.service = services.NewBookService(mockServer.URL()) 167 168 - err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false) 169 if err == nil { 170 t.Error("Expected error for malformed JSON") 171 } ··· 186 handler.service = services.NewBookService(mockServer.URL()) 187 188 args := []string{"nonexistent", "book"} 189 - err := handler.SearchAndAdd(ctx, args, false) 190 if err != nil { 191 t.Errorf("Expected no error for empty results, got: %v", err) 192 } ··· 201 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 202 defer cancel() 203 204 - if err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false); err == nil { 205 t.Error("Expected error for timeout") 206 } 207 }) ··· 219 ctx, cancel := context.WithCancel(context.Background()) 220 cancel() 221 222 - if err := handler.SearchAndAdd(ctx, []string{"test", "book"}, false); err == nil { 223 t.Error("Expected error for cancelled context") 224 } 225 }) ··· 289 handler.SetInputReader(MenuSelection(1)) 290 291 args := []string{"test", "search"} 292 - err := handler.SearchAndAdd(ctx, args, false) 293 if err != nil { 294 t.Errorf("Expected successful search and add, got error: %v", err) 295 } ··· 318 handler.SetInputReader(MenuCancel()) 319 320 args := []string{"another", "search"} 321 - err := handler.SearchAndAdd(ctx, args, false) 322 if err != nil { 323 t.Errorf("Expected no error on cancellation, got: %v", err) 324 } ··· 345 handler.SetInputReader(MenuSelection(5)) 346 347 args := []string{"choice", "test"} 348 - err := handler.SearchAndAdd(ctx, args, false) 349 if err == nil { 350 t.Error("Expected error for invalid choice") 351 }
··· 124 ctx := context.Background() 125 t.Run("fails with empty args", func(t *testing.T) { 126 args := []string{} 127 + query := strings.Join(args, " ") 128 + err := handler.SearchAndAdd(ctx, query, false) 129 if err == nil { 130 t.Error("Expected error for empty args") 131 } 132 }) 133 134 t.Run("context cancellation during search", func(t *testing.T) { 135 ctx, cancel := context.WithCancel(context.Background()) 136 cancel() 137 + query := strings.Join([]string{"test", "book"}, " ") 138 + if err := handler.SearchAndAdd(ctx, query, false); err == nil { 139 t.Error("Expected error for cancelled context") 140 } 141 }) ··· 146 147 handler.service = services.NewBookService(mockServer.URL()) 148 149 + err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false) 150 if err == nil { 151 t.Error("Expected error for HTTP 500") 152 } ··· 162 163 handler.service = services.NewBookService(mockServer.URL()) 164 165 + err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false) 166 if err == nil { 167 t.Error("Expected error for malformed JSON") 168 } ··· 183 handler.service = services.NewBookService(mockServer.URL()) 184 185 args := []string{"nonexistent", "book"} 186 + query := strings.Join(args, " ") 187 + err := handler.SearchAndAdd(ctx, query, false) 188 if err != nil { 189 t.Errorf("Expected no error for empty results, got: %v", err) 190 } ··· 199 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 200 defer cancel() 201 202 + if err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false); err == nil { 203 t.Error("Expected error for timeout") 204 } 205 }) ··· 217 ctx, cancel := context.WithCancel(context.Background()) 218 cancel() 219 220 + if err := handler.SearchAndAdd(ctx, strings.Join([]string{"test", "book"}, " "), false); err == nil { 221 t.Error("Expected error for cancelled context") 222 } 223 }) ··· 287 handler.SetInputReader(MenuSelection(1)) 288 289 args := []string{"test", "search"} 290 + query := strings.Join(args, " ") 291 + err := handler.SearchAndAdd(ctx, query, false) 292 if err != nil { 293 t.Errorf("Expected successful search and add, got error: %v", err) 294 } ··· 317 handler.SetInputReader(MenuCancel()) 318 319 args := []string{"another", "search"} 320 + query := strings.Join(args, " ") 321 + err := handler.SearchAndAdd(ctx, query, false) 322 if err != nil { 323 t.Errorf("Expected no error on cancellation, got: %v", err) 324 } ··· 345 handler.SetInputReader(MenuSelection(5)) 346 347 args := []string{"choice", "test"} 348 + query := strings.Join(args, " ") 349 + err := handler.SearchAndAdd(ctx, query, false) 350 if err == nil { 351 t.Error("Expected error for invalid choice") 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 reader io.Reader 25 } 26 27 // NewMovieHandler creates a new movie handler 28 func NewMovieHandler() (*MovieHandler, error) { 29 db, err := store.NewDatabase() ··· 218 } 219 220 // UpdateStatus changes the status of a movie 221 - func (h *MovieHandler) UpdateStatus(ctx context.Context, movieID int64, status string) error { 222 validStatuses := []string{"queued", "watched", "removed"} 223 if !slices.Contains(validStatuses, status) { 224 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", ")) ··· 245 246 // MarkWatched marks a movie as watched 247 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") 254 } 255 256 // Remove removes a movie from the queue
··· 24 reader io.Reader 25 } 26 27 + // Ensure MovieHandler implements interface [MediaHandler] 28 + var _ MediaHandler = (*MovieHandler)(nil) 29 + 30 // NewMovieHandler creates a new movie handler 31 func NewMovieHandler() (*MovieHandler, error) { 32 db, err := store.NewDatabase() ··· 221 } 222 223 // 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, ", ")) ··· 252 253 // MarkWatched marks a movie as watched 254 func (h *MovieHandler) MarkWatched(ctx context.Context, id string) error { 255 + return h.UpdateStatus(ctx, id, "watched") 256 } 257 258 // Remove removes a movie from the queue
+6 -9
internal/handlers/movies_test.go
··· 358 handler := createTestMovieHandler(t) 359 defer handler.Close() 360 361 - 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() 373 374 - 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 } 470 471 - 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", ""} 571 572 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 } 578 579 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() 360 361 + 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() 373 374 + 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 }) 411 ··· 465 t.Errorf("Failed to view created movie: %v", err) 466 } 467 468 + 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", ""} 568 569 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 } 575 576 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 ) 17 18 // TVHandler handles all TV show-related commands 19 type TVHandler struct { 20 db *store.Database 21 config *store.Config ··· 23 service *services.TVService 24 reader io.Reader 25 } 26 27 // NewTVHandler creates a new TV handler 28 func NewTVHandler() (*TVHandler, error) { ··· 232 } 233 234 // UpdateStatus changes the status of a TV show 235 - func (h *TVHandler) UpdateStatus(ctx context.Context, showID int64, status string) error { 236 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 } 259 260 // 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 } 264 265 // MarkWatched marks a TV show as watched 266 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") 273 } 274 275 // Remove removes a TV show from the queue ··· 317 318 // UpdateTVShowStatus changes the status of a TV show 319 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) 325 } 326 327 // MarkTVShowWatching marks a TV show as currently watching 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) 334 }
··· 16 ) 17 18 // TVHandler handles all TV show-related commands 19 + // 20 + // Implements MediaHandler interface for polymorphic media handling 21 type 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) 31 32 // NewTVHandler creates a new TV handler 33 func NewTVHandler() (*TVHandler, error) { ··· 237 } 238 239 // 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 } 268 269 // 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 } 273 274 // MarkWatched marks a TV show as watched 275 func (h *TVHandler) MarkWatched(ctx context.Context, id string) error { 276 + return h.UpdateStatus(ctx, id, "watched") 277 } 278 279 // Remove removes a TV show from the queue ··· 321 322 // UpdateTVShowStatus changes the status of a TV show 323 func (h *TVHandler) UpdateTVShowStatus(ctx context.Context, id, status string) error { 324 + return h.UpdateStatus(ctx, id, status) 325 } 326 327 // MarkTVShowWatching marks a TV show as currently watching 328 func (h *TVHandler) MarkTVShowWatching(ctx context.Context, id string) error { 329 + return h.MarkWatching(ctx, id) 330 }
+12 -21
internal/handlers/tv_test.go
··· 355 handler := createTestTVHandler(t) 356 defer handler.Close() 357 358 - 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() 383 384 - 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() 396 397 - 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() 408 409 - 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() 418 419 - 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() 428 429 - 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 }) 447 448 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 }) 460 461 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 } 530 531 - 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 } 540 541 - 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", ""} 652 653 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 } 659 660 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() 357 358 + 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() 383 384 + 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() 396 397 + 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() 408 409 + 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() 418 419 + 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() 428 429 + 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 } 443 }) 444 445 t.Run("MarkTVShowWatching_InvalidID", func(t *testing.T) { ··· 450 if err == nil { 451 t.Error("Expected error for invalid TV show ID") 452 } 453 }) 454 455 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") 462 } 463 }) 464 ··· 519 t.Errorf("Failed to view created TV show: %v", err) 520 } 521 522 + 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 } 531 532 + 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", ""} 643 644 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 } 650 651 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 }
+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 ) 12 13 // BookRepository provides database operations for books 14 type BookRepository struct { 15 db *sql.DB 16 } 17 18 // NewBookRepository creates a new book repository 19 func NewBookRepository(db *sql.DB) *BookRepository { 20 - return &BookRepository{db: db} 21 } 22 23 // Create stores a new book and returns its assigned ID ··· 25 now := time.Now() 26 book.Added = now 27 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) 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 } 43 44 book.ID = id 45 return id, nil 46 } 47 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 // List retrieves books with optional filtering and sorting 93 func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 94 query := r.buildListQuery(opts) 95 args := r.buildListArgs(opts) 96 97 - 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 } 114 115 func (r *BookRepository) buildListQuery(opts BookListOptions) string { ··· 187 return args 188 } 189 190 - func (r *BookRepository) scanBookRow(rows *sql.Rows, book *models.Book) error { 191 var pages sql.NullInt64 192 193 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 194 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 195 return err 196 } ··· 246 query += " WHERE " + strings.Join(conditions, " AND ") 247 } 248 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 256 } 257 258 // GetQueued retrieves all books in the queue
··· 11 ) 12 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 17 type BookRepository struct { 18 + *BaseMediaRepository[*models.Book] 19 db *sql.DB 20 } 21 22 // NewBookRepository creates a new book repository 23 func 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 } 48 49 // Create stores a new book and returns its assigned ID ··· 51 now := time.Now() 52 book.Added = now 53 54 + id, err := r.BaseMediaRepository.Create(ctx, book) 55 if err != nil { 56 + return 0, err 57 } 58 59 book.ID = id 60 return id, nil 61 } 62 63 // List retrieves books with optional filtering and sorting 64 func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 65 query := r.buildListQuery(opts) 66 args := r.buildListArgs(opts) 67 68 + items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 69 if err != nil { 70 + return nil, err 71 } 72 + return items, nil 73 } 74 75 func (r *BookRepository) buildListQuery(opts BookListOptions) string { ··· 147 return args 148 } 149 150 + // scanBookRow scans a database row into a book model 151 + func scanBookRow(rows *sql.Rows, book *models.Book) error { 152 var pages sql.NullInt64 153 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, 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 } 225 226 + return r.BaseMediaRepository.CountQuery(ctx, query, args...) 227 } 228 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 13 // MovieRepository provides database operations for movies 14 type MovieRepository struct { 15 db *sql.DB 16 } 17 18 // NewMovieRepository creates a new movie repository 19 func NewMovieRepository(db *sql.DB) *MovieRepository { 20 - return &MovieRepository{db: db} 21 } 22 23 // Create stores a new movie and returns its assigned ID ··· 25 now := time.Now() 26 movie.Added = now 27 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) 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 } 42 43 movie.ID = id 44 return id, nil 45 } 46 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 // List retrieves movies with optional filtering and sorting 90 func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) { 91 query := r.buildListQuery(opts) 92 args := r.buildListArgs(opts) 93 94 - 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 } 111 112 func (r *MovieRepository) buildListQuery(opts MovieListOptions) string { ··· 177 return args 178 } 179 180 - func (r *MovieRepository) scanMovieRow(rows *sql.Rows, movie *models.Movie) error { 181 return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating, 182 &movie.Notes, &movie.Added, &movie.Watched) 183 } 184 185 // Find retrieves movies matching specific conditions 186 func (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 } 232 233 // 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
··· 12 13 // MovieRepository provides database operations for movies 14 type MovieRepository struct { 15 + *BaseMediaRepository[*models.Movie] 16 db *sql.DB 17 } 18 19 // NewMovieRepository creates a new movie repository 20 func 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 } 45 46 // Create stores a new movie and returns its assigned ID ··· 48 now := time.Now() 49 movie.Added = now 50 51 + id, err := r.BaseMediaRepository.Create(ctx, movie) 52 if err != nil { 53 + return 0, err 54 } 55 56 movie.ID = id 57 return id, nil 58 } 59 60 // List retrieves movies with optional filtering and sorting 61 func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) { 62 query := r.buildListQuery(opts) 63 args := r.buildListArgs(opts) 64 65 + items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 66 if err != nil { 67 + return nil, err 68 } 69 + return items, nil 70 } 71 72 func (r *MovieRepository) buildListQuery(opts MovieListOptions) string { ··· 137 return args 138 } 139 140 + // 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 } 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 + 152 // Find retrieves movies matching specific conditions 153 func (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...) 191 } 192 193 // GetQueued retrieves all movies in the queue ··· 206 if err != nil { 207 return err 208 } 209 now := time.Now() 210 movie.Status = "watched" 211 movie.Watched = &now 212 return r.Update(ctx, movie) 213 } 214
+2
internal/repo/task_repository.go
··· 51 } 52 53 // TaskRepository provides database operations for tasks 54 type TaskRepository struct { 55 db *sql.DB 56 }
··· 51 } 52 53 // TaskRepository provides database operations for tasks 54 + // 55 + // TODO: Implement Repository interface (Validate method) similar to ArticleRepository 56 type TaskRepository struct { 57 db *sql.DB 58 }
+36 -82
internal/repo/tv_repository.go
··· 12 13 // TVRepository provides database operations for TV shows 14 type TVRepository struct { 15 db *sql.DB 16 } 17 18 // NewTVRepository creates a new TV show repository 19 func NewTVRepository(db *sql.DB) *TVRepository { 20 - return &TVRepository{db: db} 21 } 22 23 // Create stores a new TV show and returns its assigned ID ··· 25 now := time.Now() 26 tvShow.Added = now 27 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) 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 } 43 44 tvShow.ID = id 45 return id, nil 46 } 47 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 // List retrieves TV shows with optional filtering and sorting 93 func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) { 94 query := r.buildListQuery(opts) 95 args := r.buildListArgs(opts) 96 97 - 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 } 114 115 func (r *TVRepository) buildListQuery(opts TVListOptions) string { ··· 186 return args 187 } 188 189 - 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 } 193 194 // Find retrieves TV shows matching specific conditions 195 func (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 } 236 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 244 } 245 246 // 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
··· 12 13 // TVRepository provides database operations for TV shows 14 type TVRepository struct { 15 + *BaseMediaRepository[*models.TVShow] 16 db *sql.DB 17 } 18 19 // NewTVRepository creates a new TV show repository 20 func NewTVRepository(db *sql.DB) *TVRepository { 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 + } 44 } 45 46 // Create stores a new TV show and returns its assigned ID ··· 48 now := time.Now() 49 tvShow.Added = now 50 51 + id, err := r.BaseMediaRepository.Create(ctx, tvShow) 52 if err != nil { 53 + return 0, err 54 } 55 56 tvShow.ID = id 57 return id, nil 58 } 59 60 // List retrieves TV shows with optional filtering and sorting 61 func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) { 62 query := r.buildListQuery(opts) 63 args := r.buildListArgs(opts) 64 65 + items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 66 if err != nil { 67 + return nil, err 68 } 69 + return items, nil 70 } 71 72 func (r *TVRepository) buildListQuery(opts TVListOptions) string { ··· 143 return args 144 } 145 146 + func scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error { 147 return rows.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status, 148 &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched) 149 } 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 + 156 // Find retrieves TV shows matching specific conditions 157 func (r *TVRepository) Find(ctx context.Context, conditions TVListOptions) ([]*models.TVShow, error) { 158 return r.List(ctx, conditions) ··· 196 query += " WHERE " + strings.Join(conditions, " AND ") 197 } 198 199 + return r.BaseMediaRepository.CountQuery(ctx, query, args...) 200 } 201 202 // GetQueued retrieves all TV shows in the queue ··· 230 if err != nil { 231 return err 232 } 233 now := time.Now() 234 tvShow.Status = "watched" 235 tvShow.LastWatched = &now 236 return r.Update(ctx, tvShow) 237 } 238