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

feat: movie & tv handlers

+1693 -92
+317
internal/handlers/movies.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "strconv" 8 + "strings" 9 + "time" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + "github.com/stormlightlabs/noteleaf/internal/repo" 13 + "github.com/stormlightlabs/noteleaf/internal/services" 14 + "github.com/stormlightlabs/noteleaf/internal/store" 15 + ) 16 + 17 + // MovieHandler handles all movie-related commands 18 + type MovieHandler struct { 19 + db *store.Database 20 + config *store.Config 21 + repos *repo.Repositories 22 + service *services.MovieService 23 + } 24 + 25 + // NewMovieHandler creates a new movie handler 26 + func NewMovieHandler() (*MovieHandler, error) { 27 + db, err := store.NewDatabase() 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to initialize database: %w", err) 30 + } 31 + 32 + config, err := store.LoadConfig() 33 + if err != nil { 34 + return nil, fmt.Errorf("failed to load configuration: %w", err) 35 + } 36 + 37 + repos := repo.NewRepositories(db.DB) 38 + service := services.NewMovieService() 39 + 40 + return &MovieHandler{ 41 + db: db, 42 + config: config, 43 + repos: repos, 44 + service: service, 45 + }, nil 46 + } 47 + 48 + // Close cleans up resources 49 + func (h *MovieHandler) Close() error { 50 + if err := h.service.Close(); err != nil { 51 + return fmt.Errorf("failed to close service: %w", err) 52 + } 53 + return h.db.Close() 54 + } 55 + 56 + // SearchAndAdd searches for movies and allows user to select and add to queue 57 + func (h *MovieHandler) SearchAndAdd(ctx context.Context, query string, interactive bool) error { 58 + if query == "" { 59 + return fmt.Errorf("search query cannot be empty") 60 + } 61 + 62 + fmt.Printf("Searching for movies: %s\n", query) 63 + fmt.Print("Loading...") 64 + 65 + results, err := h.service.Search(ctx, query, 1, 5) 66 + if err != nil { 67 + fmt.Println(" failed!") 68 + return fmt.Errorf("search failed: %w", err) 69 + } 70 + 71 + fmt.Println(" done!") 72 + fmt.Println() 73 + 74 + if len(results) == 0 { 75 + fmt.Println("No movies found.") 76 + return nil 77 + } 78 + 79 + fmt.Printf("Found %d result(s):\n\n", len(results)) 80 + for i, result := range results { 81 + if movie, ok := (*result).(*models.Movie); ok { 82 + fmt.Printf("[%d] %s", i+1, movie.Title) 83 + if movie.Year > 0 { 84 + fmt.Printf(" (%d)", movie.Year) 85 + } 86 + if movie.Rating > 0 { 87 + fmt.Printf(" โ˜…%.1f", movie.Rating) 88 + } 89 + if movie.Notes != "" { 90 + notes := movie.Notes 91 + if len(notes) > 80 { 92 + notes = notes[:77] + "..." 93 + } 94 + fmt.Printf("\n %s", notes) 95 + } 96 + fmt.Println() 97 + } 98 + } 99 + 100 + fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ") 101 + 102 + var choice int 103 + if _, err := fmt.Scanf("%d", &choice); err != nil { 104 + return fmt.Errorf("invalid input") 105 + } 106 + 107 + if choice == 0 { 108 + fmt.Println("Cancelled.") 109 + return nil 110 + } 111 + 112 + if choice < 1 || choice > len(results) { 113 + return fmt.Errorf("invalid choice: %d", choice) 114 + } 115 + 116 + selectedMovie, ok := (*results[choice-1]).(*models.Movie) 117 + if !ok { 118 + return fmt.Errorf("error processing selected movie") 119 + } 120 + 121 + if _, err := h.repos.Movies.Create(ctx, selectedMovie); err != nil { 122 + return fmt.Errorf("failed to add movie: %w", err) 123 + } 124 + 125 + fmt.Printf("โœ“ Added movie: %s", selectedMovie.Title) 126 + if selectedMovie.Year > 0 { 127 + fmt.Printf(" (%d)", selectedMovie.Year) 128 + } 129 + fmt.Println() 130 + 131 + return nil 132 + } 133 + 134 + // List movies with status filtering 135 + func (h *MovieHandler) List(ctx context.Context, status string) error { 136 + var movies []*models.Movie 137 + var err error 138 + 139 + switch status { 140 + case "": 141 + movies, err = h.repos.Movies.List(ctx, repo.MovieListOptions{}) 142 + if err != nil { 143 + return fmt.Errorf("failed to list movies: %w", err) 144 + } 145 + case "queued": 146 + movies, err = h.repos.Movies.GetQueued(ctx) 147 + if err != nil { 148 + return fmt.Errorf("failed to get queued movies: %w", err) 149 + } 150 + case "watched": 151 + movies, err = h.repos.Movies.GetWatched(ctx) 152 + if err != nil { 153 + return fmt.Errorf("failed to get watched movies: %w", err) 154 + } 155 + default: 156 + return fmt.Errorf("invalid status: %s (use: queued, watched, or leave empty for all)", status) 157 + } 158 + 159 + if len(movies) == 0 { 160 + if status == "" { 161 + fmt.Println("No movies found") 162 + } else { 163 + fmt.Printf("No %s movies found\n", status) 164 + } 165 + return nil 166 + } 167 + 168 + fmt.Printf("Found %d movie(s):\n\n", len(movies)) 169 + for _, movie := range movies { 170 + h.printMovie(movie) 171 + } 172 + 173 + return nil 174 + } 175 + 176 + // View displays detailed information about a specific movie 177 + func (h *MovieHandler) View(ctx context.Context, movieID int64) error { 178 + movie, err := h.repos.Movies.Get(ctx, movieID) 179 + if err != nil { 180 + return fmt.Errorf("failed to get movie %d: %w", movieID, err) 181 + } 182 + 183 + fmt.Printf("Movie: %s", movie.Title) 184 + if movie.Year > 0 { 185 + fmt.Printf(" (%d)", movie.Year) 186 + } 187 + fmt.Printf("\nID: %d\n", movie.ID) 188 + fmt.Printf("Status: %s\n", movie.Status) 189 + 190 + if movie.Rating > 0 { 191 + fmt.Printf("Rating: โ˜…%.1f\n", movie.Rating) 192 + } 193 + 194 + fmt.Printf("Added: %s\n", movie.Added.Format("2006-01-02 15:04:05")) 195 + 196 + if movie.Watched != nil { 197 + fmt.Printf("Watched: %s\n", movie.Watched.Format("2006-01-02 15:04:05")) 198 + } 199 + 200 + if movie.Notes != "" { 201 + fmt.Printf("Notes: %s\n", movie.Notes) 202 + } 203 + 204 + return nil 205 + } 206 + 207 + // UpdateStatus changes the status of a movie 208 + func (h *MovieHandler) UpdateStatus(ctx context.Context, movieID int64, status string) error { 209 + validStatuses := []string{"queued", "watched", "removed"} 210 + if !slices.Contains(validStatuses, status) { 211 + return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", ")) 212 + } 213 + 214 + movie, err := h.repos.Movies.Get(ctx, movieID) 215 + if err != nil { 216 + return fmt.Errorf("movie %d not found: %w", movieID, err) 217 + } 218 + 219 + movie.Status = status 220 + if status == "watched" && movie.Watched == nil { 221 + now := time.Now() 222 + movie.Watched = &now 223 + } 224 + 225 + if err := h.repos.Movies.Update(ctx, movie); err != nil { 226 + return fmt.Errorf("failed to update movie status: %w", err) 227 + } 228 + 229 + fmt.Printf("โœ“ Movie '%s' marked as %s\n", movie.Title, status) 230 + return nil 231 + } 232 + 233 + // MarkWatched marks a movie as watched 234 + func (h *MovieHandler) MarkWatched(ctx context.Context, movieID int64) error { 235 + return h.UpdateStatus(ctx, movieID, "watched") 236 + } 237 + 238 + // Remove removes a movie from the queue 239 + func (h *MovieHandler) Remove(ctx context.Context, movieID int64) error { 240 + movie, err := h.repos.Movies.Get(ctx, movieID) 241 + if err != nil { 242 + return fmt.Errorf("movie %d not found: %w", movieID, err) 243 + } 244 + 245 + if err := h.repos.Movies.Delete(ctx, movieID); err != nil { 246 + return fmt.Errorf("failed to remove movie: %w", err) 247 + } 248 + 249 + fmt.Printf("โœ“ Removed movie: %s", movie.Title) 250 + if movie.Year > 0 { 251 + fmt.Printf(" (%d)", movie.Year) 252 + } 253 + fmt.Println() 254 + 255 + return nil 256 + } 257 + 258 + func (h *MovieHandler) printMovie(movie *models.Movie) { 259 + fmt.Printf("[%d] %s", movie.ID, movie.Title) 260 + if movie.Year > 0 { 261 + fmt.Printf(" (%d)", movie.Year) 262 + } 263 + if movie.Status != "queued" { 264 + fmt.Printf(" (%s)", movie.Status) 265 + } 266 + if movie.Rating > 0 { 267 + fmt.Printf(" โ˜…%.1f", movie.Rating) 268 + } 269 + fmt.Println() 270 + } 271 + 272 + // SearchAndAddMovie searches for movies and allows user to select and add to queue 273 + func (h *MovieHandler) SearchAndAddMovie(ctx context.Context, query string, interactive bool) error { 274 + return h.SearchAndAdd(ctx, query, interactive) 275 + } 276 + 277 + // ListMovies lists all movies in the queue with status filtering 278 + func (h *MovieHandler) ListMovies(ctx context.Context, status string) error { 279 + return h.List(ctx, status) 280 + } 281 + 282 + // ViewMovie displays detailed information about a specific movie 283 + func (h *MovieHandler) ViewMovie(ctx context.Context, id string) error { 284 + movieID, err := strconv.ParseInt(id, 10, 64) 285 + if err != nil { 286 + return fmt.Errorf("invalid movie ID: %s", id) 287 + } 288 + return h.View(ctx, movieID) 289 + } 290 + 291 + // UpdateMovieStatus changes the status of a movie 292 + func (h *MovieHandler) UpdateMovieStatus(ctx context.Context, id, status string) error { 293 + movieID, err := strconv.ParseInt(id, 10, 64) 294 + if err != nil { 295 + return fmt.Errorf("invalid movie ID: %s", id) 296 + } 297 + return h.UpdateStatus(ctx, movieID, status) 298 + } 299 + 300 + // MarkMovieWatched marks a movie as watched 301 + func (h *MovieHandler) MarkMovieWatched(ctx context.Context, id string) error { 302 + movieID, err := strconv.ParseInt(id, 10, 64) 303 + if err != nil { 304 + return fmt.Errorf("invalid movie ID: %s", id) 305 + } 306 + return h.MarkWatched(ctx, movieID) 307 + } 308 + 309 + // RemoveMovie removes a movie from the queue 310 + func (h *MovieHandler) RemoveMovie(ctx context.Context, id string) error { 311 + movieID, err := strconv.ParseInt(id, 10, 64) 312 + if err != nil { 313 + return fmt.Errorf("invalid movie ID: %s", id) 314 + } 315 + 316 + return h.Remove(ctx, movieID) 317 + }
+452
internal/handlers/movies_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "testing" 7 + "time" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/models" 10 + ) 11 + 12 + func createTestMovieHandler(t *testing.T) *MovieHandler { 13 + handler, err := NewMovieHandler() 14 + if err != nil { 15 + t.Fatalf("Failed to create test movie handler: %v", err) 16 + } 17 + return handler 18 + } 19 + 20 + func createTestMovie() *models.Movie { 21 + now := time.Now() 22 + return &models.Movie{ 23 + ID: 1, 24 + Title: "Test Movie", 25 + Year: 2023, 26 + Status: "queued", 27 + Rating: 4.5, 28 + Notes: "Test notes", 29 + Added: now, 30 + } 31 + } 32 + 33 + func TestMovieHandler(t *testing.T) { 34 + t.Run("New", func(t *testing.T) { 35 + handler := createTestMovieHandler(t) 36 + defer handler.Close() 37 + 38 + if handler.db == nil { 39 + t.Error("Expected database to be initialized") 40 + } 41 + if handler.config == nil { 42 + t.Error("Expected config to be initialized") 43 + } 44 + if handler.repos == nil { 45 + t.Error("Expected repositories to be initialized") 46 + } 47 + if handler.service == nil { 48 + t.Error("Expected service to be initialized") 49 + } 50 + }) 51 + 52 + t.Run("Close", func(t *testing.T) { 53 + handler := createTestMovieHandler(t) 54 + 55 + err := handler.Close() 56 + if err != nil { 57 + t.Errorf("Expected no error when closing handler, got: %v", err) 58 + } 59 + }) 60 + 61 + t.Run("Search and Add", func(t *testing.T) { 62 + t.Run("Empty Query", func(t *testing.T) { 63 + handler := createTestMovieHandler(t) 64 + defer handler.Close() 65 + 66 + err := handler.SearchAndAdd(context.Background(), "", false) 67 + if err == nil { 68 + t.Error("Expected error for empty query") 69 + } 70 + if err.Error() != "search query cannot be empty" { 71 + t.Errorf("Expected 'search query cannot be empty', got: %v", err) 72 + } 73 + }) 74 + 75 + t.Run("Search Error", func(t *testing.T) { 76 + handler := createTestMovieHandler(t) 77 + defer handler.Close() 78 + 79 + // Test with malformed search that should cause network error 80 + err := handler.SearchAndAdd(context.Background(), "test movie", false) 81 + // We expect this to work with the actual service, so we test for successful completion 82 + // or a specific network error - this tests the error handling path in the code 83 + if err != nil { 84 + // This is expected - the search might fail due to network issues in test environment 85 + if err.Error() != "search query cannot be empty" { 86 + // We got a search error, which tests our error handling path 87 + t.Logf("Search failed as expected in test environment: %v", err) 88 + } 89 + } 90 + }) 91 + 92 + t.Run("Network Error", func(t *testing.T) { 93 + handler := createTestMovieHandler(t) 94 + defer handler.Close() 95 + 96 + // Test search with a query that will likely fail due to network issues in test env 97 + // This tests the error handling path 98 + err := handler.SearchAndAdd(context.Background(), "unlikely_to_find_this_movie_12345", false) 99 + // We don't expect a specific error, but this tests the error handling path 100 + if err != nil { 101 + t.Logf("Network error encountered (expected in test environment): %v", err) 102 + } 103 + }) 104 + 105 + }) 106 + 107 + t.Run("List", func(t *testing.T) { 108 + t.Run("Invalid Status", func(t *testing.T) { 109 + handler := createTestMovieHandler(t) 110 + defer handler.Close() 111 + 112 + err := handler.List(context.Background(), "invalid_status") 113 + if err == nil { 114 + t.Error("Expected error for invalid status") 115 + } 116 + if err.Error() != "invalid status: invalid_status (use: queued, watched, or leave empty for all)" { 117 + t.Errorf("Expected invalid status error, got: %v", err) 118 + } 119 + }) 120 + 121 + t.Run("All Movies", func(t *testing.T) { 122 + handler := createTestMovieHandler(t) 123 + defer handler.Close() 124 + 125 + // Test with empty status (all movies) 126 + err := handler.List(context.Background(), "") 127 + if err != nil { 128 + t.Errorf("Expected no error for listing all movies, got: %v", err) 129 + } 130 + }) 131 + 132 + t.Run("Queued Movies", func(t *testing.T) { 133 + handler := createTestMovieHandler(t) 134 + defer handler.Close() 135 + 136 + err := handler.List(context.Background(), "queued") 137 + if err != nil { 138 + t.Errorf("Expected no error for listing queued movies, got: %v", err) 139 + } 140 + }) 141 + 142 + t.Run("Watched Movies", func(t *testing.T) { 143 + handler := createTestMovieHandler(t) 144 + defer handler.Close() 145 + 146 + err := handler.List(context.Background(), "watched") 147 + if err != nil { 148 + t.Errorf("Expected no error for listing watched movies, got: %v", err) 149 + } 150 + }) 151 + 152 + }) 153 + 154 + t.Run("View", func(t *testing.T) { 155 + t.Run("Movie Not Found", func(t *testing.T) { 156 + handler := createTestMovieHandler(t) 157 + defer handler.Close() 158 + 159 + err := handler.View(context.Background(), 999) 160 + if err == nil { 161 + t.Error("Expected error for non-existent movie") 162 + } 163 + }) 164 + 165 + t.Run("Invalid ID", func(t *testing.T) { 166 + handler := createTestMovieHandler(t) 167 + defer handler.Close() 168 + 169 + err := handler.ViewMovie(context.Background(), "invalid") 170 + if err == nil { 171 + t.Error("Expected error for invalid movie ID") 172 + } 173 + if err.Error() != "invalid movie ID: invalid" { 174 + t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err) 175 + } 176 + }) 177 + }) 178 + 179 + t.Run("Update", func(t *testing.T) { 180 + t.Run("Update Status", func(t *testing.T) { 181 + t.Run("Invalid", func(t *testing.T) { 182 + handler := createTestMovieHandler(t) 183 + defer handler.Close() 184 + 185 + err := handler.UpdateStatus(context.Background(), 1, "invalid") 186 + if err == nil { 187 + t.Error("Expected error for invalid status") 188 + } 189 + if err.Error() != "invalid status: invalid (valid: queued, watched, removed)" { 190 + t.Errorf("Expected invalid status error, got: %v", err) 191 + } 192 + }) 193 + 194 + t.Run("Movie Not Found", func(t *testing.T) { 195 + handler := createTestMovieHandler(t) 196 + defer handler.Close() 197 + 198 + err := handler.UpdateStatus(context.Background(), 999, "watched") 199 + if err == nil { 200 + t.Error("Expected error for non-existent movie") 201 + } 202 + }) 203 + }) 204 + }) 205 + 206 + t.Run("MarkWatched_MovieNotFound", func(t *testing.T) { 207 + handler := createTestMovieHandler(t) 208 + defer handler.Close() 209 + 210 + err := handler.MarkWatched(context.Background(), 999) 211 + if err == nil { 212 + t.Error("Expected error for non-existent movie") 213 + } 214 + }) 215 + 216 + t.Run("Remove_MovieNotFound", func(t *testing.T) { 217 + handler := createTestMovieHandler(t) 218 + defer handler.Close() 219 + 220 + err := handler.Remove(context.Background(), 999) 221 + if err == nil { 222 + t.Error("Expected error for non-existent movie") 223 + } 224 + }) 225 + 226 + t.Run("UpdateMovieStatus_InvalidID", func(t *testing.T) { 227 + handler := createTestMovieHandler(t) 228 + defer handler.Close() 229 + 230 + err := handler.UpdateMovieStatus(context.Background(), "invalid", "watched") 231 + if err == nil { 232 + t.Error("Expected error for invalid movie ID") 233 + } 234 + if err.Error() != "invalid movie ID: invalid" { 235 + t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err) 236 + } 237 + }) 238 + 239 + t.Run("MarkMovieWatched_InvalidID", func(t *testing.T) { 240 + handler := createTestMovieHandler(t) 241 + defer handler.Close() 242 + 243 + err := handler.MarkMovieWatched(context.Background(), "invalid") 244 + if err == nil { 245 + t.Error("Expected error for invalid movie ID") 246 + } 247 + if err.Error() != "invalid movie ID: invalid" { 248 + t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err) 249 + } 250 + }) 251 + 252 + t.Run("RemoveMovie_InvalidID", func(t *testing.T) { 253 + handler := createTestMovieHandler(t) 254 + defer handler.Close() 255 + 256 + err := handler.RemoveMovie(context.Background(), "invalid") 257 + if err == nil { 258 + t.Error("Expected error for invalid movie ID") 259 + } 260 + if err.Error() != "invalid movie ID: invalid" { 261 + t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err) 262 + } 263 + }) 264 + 265 + t.Run("printMovie", func(t *testing.T) { 266 + handler := createTestMovieHandler(t) 267 + defer handler.Close() 268 + 269 + movie := createTestMovie() 270 + 271 + handler.printMovie(movie) 272 + 273 + minimalMovie := &models.Movie{ 274 + ID: 2, 275 + Title: "Minimal Movie", 276 + } 277 + handler.printMovie(minimalMovie) 278 + 279 + watchedMovie := &models.Movie{ 280 + ID: 3, 281 + Title: "Watched Movie", 282 + Year: 2022, 283 + Status: "watched", 284 + Rating: 3.5, 285 + } 286 + handler.printMovie(watchedMovie) 287 + }) 288 + 289 + t.Run("SearchAndAddMovie", func(t *testing.T) { 290 + handler := createTestMovieHandler(t) 291 + defer handler.Close() 292 + 293 + err := handler.SearchAndAddMovie(context.Background(), "", false) 294 + if err == nil { 295 + t.Error("Expected error for empty query") 296 + } 297 + }) 298 + 299 + t.Run("List Movies", func(t *testing.T) { 300 + handler := createTestMovieHandler(t) 301 + defer handler.Close() 302 + 303 + err := handler.ListMovies(context.Background(), "") 304 + if err != nil { 305 + t.Errorf("Expected no error for listing all movies, got: %v", err) 306 + } 307 + 308 + err = handler.ListMovies(context.Background(), "invalid") 309 + if err == nil { 310 + t.Error("Expected error for invalid status") 311 + } 312 + }) 313 + 314 + t.Run("Integration", func(t *testing.T) { 315 + t.Run("CreateAndRetrieve", func(t *testing.T) { 316 + handler := createTestMovieHandler(t) 317 + defer handler.Close() 318 + 319 + movie := createTestMovie() 320 + movie.ID = 0 321 + 322 + id, err := handler.repos.Movies.Create(context.Background(), movie) 323 + if err != nil { 324 + t.Errorf("Failed to create movie: %v", err) 325 + return 326 + } 327 + 328 + err = handler.View(context.Background(), id) 329 + if err != nil { 330 + t.Errorf("Failed to view created movie: %v", err) 331 + } 332 + 333 + err = handler.UpdateStatus(context.Background(), id, "watched") 334 + if err != nil { 335 + t.Errorf("Failed to update movie status: %v", err) 336 + } 337 + 338 + err = handler.MarkWatched(context.Background(), id) 339 + if err != nil { 340 + t.Errorf("Failed to mark movie as watched: %v", err) 341 + } 342 + 343 + err = handler.Remove(context.Background(), id) 344 + if err != nil { 345 + t.Errorf("Failed to remove movie: %v", err) 346 + } 347 + }) 348 + 349 + t.Run("StatusFiltering", func(t *testing.T) { 350 + handler := createTestMovieHandler(t) 351 + defer handler.Close() 352 + 353 + queuedMovie := &models.Movie{ 354 + Title: "Queued Movie", 355 + Status: "queued", 356 + Added: time.Now(), 357 + } 358 + watchedMovie := &models.Movie{ 359 + Title: "Watched Movie", 360 + Status: "watched", 361 + Added: time.Now(), 362 + } 363 + 364 + id1, err := handler.repos.Movies.Create(context.Background(), queuedMovie) 365 + if err != nil { 366 + t.Errorf("Failed to create queued movie: %v", err) 367 + return 368 + } 369 + defer handler.repos.Movies.Delete(context.Background(), id1) 370 + 371 + id2, err := handler.repos.Movies.Create(context.Background(), watchedMovie) 372 + if err != nil { 373 + t.Errorf("Failed to create watched movie: %v", err) 374 + return 375 + } 376 + defer handler.repos.Movies.Delete(context.Background(), id2) 377 + 378 + testCases := []string{"", "queued", "watched"} 379 + for _, status := range testCases { 380 + err = handler.List(context.Background(), status) 381 + if err != nil { 382 + t.Errorf("Failed to list movies with status '%s': %v", status, err) 383 + } 384 + } 385 + }) 386 + }) 387 + 388 + t.Run("ErrorPaths", func(t *testing.T) { 389 + handler := createTestMovieHandler(t) 390 + defer handler.Close() 391 + 392 + ctx := context.Background() 393 + nonExistentID := int64(999999) 394 + 395 + testCases := []struct { 396 + name string 397 + fn func() error 398 + }{ 399 + { 400 + name: "View non-existent movie", 401 + fn: func() error { return handler.View(ctx, nonExistentID) }, 402 + }, 403 + { 404 + name: "Update status of non-existent movie", 405 + fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") }, 406 + }, 407 + { 408 + name: "Mark non-existent movie as watched", 409 + fn: func() error { return handler.MarkWatched(ctx, nonExistentID) }, 410 + }, 411 + { 412 + name: "Remove non-existent movie", 413 + fn: func() error { return handler.Remove(ctx, nonExistentID) }, 414 + }, 415 + } 416 + 417 + for _, tc := range testCases { 418 + t.Run(tc.name, func(t *testing.T) { 419 + err := tc.fn() 420 + if err == nil { 421 + t.Errorf("Expected error for %s", tc.name) 422 + } 423 + }) 424 + } 425 + }) 426 + 427 + t.Run("ValidStatusValues", func(t *testing.T) { 428 + handler := createTestMovieHandler(t) 429 + defer handler.Close() 430 + 431 + valid := []string{"queued", "watched", "removed"} 432 + invalid := []string{"invalid", "pending", "completed", ""} 433 + 434 + for _, status := range valid { 435 + if err := handler.UpdateStatus(context.Background(), 999, status); err != nil && 436 + err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watched, removed)", status) { 437 + t.Errorf("Status '%s' should be valid but was rejected", status) 438 + } 439 + } 440 + 441 + for _, status := range invalid { 442 + err := handler.UpdateStatus(context.Background(), 1, status) 443 + if err == nil { 444 + t.Errorf("Status '%s' should be invalid but was accepted", status) 445 + } 446 + got := fmt.Sprintf("invalid status: %s (valid: queued, watched, removed)", status) 447 + if err.Error() != got { 448 + t.Errorf("Expected '%s', got: %v", got, err) 449 + } 450 + } 451 + }) 452 + }
+2 -1
internal/handlers/tasks.go
··· 143 143 return nil 144 144 } 145 145 146 - func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, context string) error { 146 + // TODO: include context field 147 + func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, _ string) error { 147 148 taskTable := ui.NewTaskListFromTable(h.repos.Tasks, os.Stdout, os.Stdin, false, showAll, status, priority, project) 148 149 return taskTable.Browse(ctx) 149 150 }
+343
internal/handlers/tv.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "strconv" 8 + "strings" 9 + "time" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + "github.com/stormlightlabs/noteleaf/internal/repo" 13 + "github.com/stormlightlabs/noteleaf/internal/services" 14 + "github.com/stormlightlabs/noteleaf/internal/store" 15 + ) 16 + 17 + // TVHandler handles all TV show-related commands 18 + type TVHandler struct { 19 + db *store.Database 20 + config *store.Config 21 + repos *repo.Repositories 22 + service *services.TVService 23 + } 24 + 25 + // NewTVHandler creates a new TV handler 26 + func NewTVHandler() (*TVHandler, error) { 27 + db, err := store.NewDatabase() 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to initialize database: %w", err) 30 + } 31 + 32 + config, err := store.LoadConfig() 33 + if err != nil { 34 + return nil, fmt.Errorf("failed to load configuration: %w", err) 35 + } 36 + 37 + repos := repo.NewRepositories(db.DB) 38 + service := services.NewTVService() 39 + 40 + return &TVHandler{ 41 + db: db, 42 + config: config, 43 + repos: repos, 44 + service: service, 45 + }, nil 46 + } 47 + 48 + // Close cleans up resources 49 + func (h *TVHandler) Close() error { 50 + if err := h.service.Close(); err != nil { 51 + return fmt.Errorf("failed to close service: %w", err) 52 + } 53 + return h.db.Close() 54 + } 55 + 56 + // SearchAndAdd searches for TV shows and allows user to select and add to queue 57 + func (h *TVHandler) SearchAndAdd(ctx context.Context, query string, interactive bool) error { 58 + if query == "" { 59 + return fmt.Errorf("search query cannot be empty") 60 + } 61 + 62 + fmt.Printf("Searching for TV shows: %s\n", query) 63 + fmt.Print("Loading...") 64 + 65 + results, err := h.service.Search(ctx, query, 1, 5) 66 + if err != nil { 67 + fmt.Println(" failed!") 68 + return fmt.Errorf("search failed: %w", err) 69 + } 70 + 71 + fmt.Println(" done!") 72 + fmt.Println() 73 + 74 + if len(results) == 0 { 75 + fmt.Println("No TV shows found.") 76 + return nil 77 + } 78 + 79 + fmt.Printf("Found %d result(s):\n\n", len(results)) 80 + for i, result := range results { 81 + if show, ok := (*result).(*models.TVShow); ok { 82 + fmt.Printf("[%d] %s", i+1, show.Title) 83 + if show.Season > 0 { 84 + fmt.Printf(" (Season %d)", show.Season) 85 + } 86 + if show.Rating > 0 { 87 + fmt.Printf(" โ˜…%.1f", show.Rating) 88 + } 89 + if show.Notes != "" { 90 + notes := show.Notes 91 + if len(notes) > 80 { 92 + notes = notes[:77] + "..." 93 + } 94 + fmt.Printf("\n %s", notes) 95 + } 96 + fmt.Println() 97 + } 98 + } 99 + 100 + fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ") 101 + 102 + var choice int 103 + if _, err := fmt.Scanf("%d", &choice); err != nil { 104 + return fmt.Errorf("invalid input") 105 + } 106 + 107 + if choice == 0 { 108 + fmt.Println("Cancelled.") 109 + return nil 110 + } 111 + 112 + if choice < 1 || choice > len(results) { 113 + return fmt.Errorf("invalid choice: %d", choice) 114 + } 115 + 116 + selectedShow, ok := (*results[choice-1]).(*models.TVShow) 117 + if !ok { 118 + return fmt.Errorf("error processing selected TV show") 119 + } 120 + 121 + if _, err := h.repos.TV.Create(ctx, selectedShow); err != nil { 122 + return fmt.Errorf("failed to add TV show: %w", err) 123 + } 124 + 125 + fmt.Printf("โœ“ Added TV show: %s", selectedShow.Title) 126 + if selectedShow.Season > 0 { 127 + fmt.Printf(" (Season %d)", selectedShow.Season) 128 + } 129 + fmt.Println() 130 + 131 + return nil 132 + } 133 + 134 + // List TV shows with status filtering 135 + func (h *TVHandler) List(ctx context.Context, status string) error { 136 + var shows []*models.TVShow 137 + var err error 138 + 139 + switch status { 140 + case "": 141 + shows, err = h.repos.TV.List(ctx, repo.TVListOptions{}) 142 + if err != nil { 143 + return fmt.Errorf("failed to list TV shows: %w", err) 144 + } 145 + case "queued": 146 + shows, err = h.repos.TV.GetQueued(ctx) 147 + if err != nil { 148 + return fmt.Errorf("failed to get queued TV shows: %w", err) 149 + } 150 + case "watching": 151 + shows, err = h.repos.TV.GetWatching(ctx) 152 + if err != nil { 153 + return fmt.Errorf("failed to get watching TV shows: %w", err) 154 + } 155 + case "watched": 156 + shows, err = h.repos.TV.GetWatched(ctx) 157 + if err != nil { 158 + return fmt.Errorf("failed to get watched TV shows: %w", err) 159 + } 160 + default: 161 + return fmt.Errorf("invalid status: %s (use: queued, watching, watched, or leave empty for all)", status) 162 + } 163 + 164 + if len(shows) == 0 { 165 + if status == "" { 166 + fmt.Println("No TV shows found") 167 + } else { 168 + fmt.Printf("No %s TV shows found\n", status) 169 + } 170 + return nil 171 + } 172 + 173 + fmt.Printf("Found %d TV show(s):\n\n", len(shows)) 174 + for _, show := range shows { 175 + h.printTVShow(show) 176 + } 177 + 178 + return nil 179 + } 180 + 181 + // View displays detailed information about a specific TV show 182 + func (h *TVHandler) View(ctx context.Context, showID int64) error { 183 + show, err := h.repos.TV.Get(ctx, showID) 184 + if err != nil { 185 + return fmt.Errorf("failed to get TV show %d: %w", showID, err) 186 + } 187 + 188 + fmt.Printf("TV Show: %s", show.Title) 189 + if show.Season > 0 { 190 + fmt.Printf(" (Season %d", show.Season) 191 + if show.Episode > 0 { 192 + fmt.Printf(", Episode %d", show.Episode) 193 + } 194 + fmt.Print(")") 195 + } 196 + fmt.Printf("\nID: %d\n", show.ID) 197 + fmt.Printf("Status: %s\n", show.Status) 198 + 199 + if show.Rating > 0 { 200 + fmt.Printf("Rating: โ˜…%.1f\n", show.Rating) 201 + } 202 + 203 + fmt.Printf("Added: %s\n", show.Added.Format("2006-01-02 15:04:05")) 204 + 205 + if show.LastWatched != nil { 206 + fmt.Printf("Last Watched: %s\n", show.LastWatched.Format("2006-01-02 15:04:05")) 207 + } 208 + 209 + if show.Notes != "" { 210 + fmt.Printf("Notes: %s\n", show.Notes) 211 + } 212 + 213 + return nil 214 + } 215 + 216 + // UpdateStatus changes the status of a TV show 217 + func (h *TVHandler) UpdateStatus(ctx context.Context, showID int64, status string) error { 218 + validStatuses := []string{"queued", "watching", "watched", "removed"} 219 + if !slices.Contains(validStatuses, status) { 220 + return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", ")) 221 + } 222 + 223 + show, err := h.repos.TV.Get(ctx, showID) 224 + if err != nil { 225 + return fmt.Errorf("TV show %d not found: %w", showID, err) 226 + } 227 + 228 + show.Status = status 229 + if (status == "watching" || status == "watched") && show.LastWatched == nil { 230 + now := time.Now() 231 + show.LastWatched = &now 232 + } 233 + 234 + if err := h.repos.TV.Update(ctx, show); err != nil { 235 + return fmt.Errorf("failed to update TV show status: %w", err) 236 + } 237 + 238 + fmt.Printf("โœ“ TV show '%s' marked as %s\n", show.Title, status) 239 + return nil 240 + } 241 + 242 + // MarkWatching marks a TV show as currently watching 243 + func (h *TVHandler) MarkWatching(ctx context.Context, showID int64) error { 244 + return h.UpdateStatus(ctx, showID, "watching") 245 + } 246 + 247 + // MarkWatched marks a TV show as watched 248 + func (h *TVHandler) MarkWatched(ctx context.Context, showID int64) error { 249 + return h.UpdateStatus(ctx, showID, "watched") 250 + } 251 + 252 + // Remove removes a TV show from the queue 253 + func (h *TVHandler) Remove(ctx context.Context, showID int64) error { 254 + show, err := h.repos.TV.Get(ctx, showID) 255 + if err != nil { 256 + return fmt.Errorf("TV show %d not found: %w", showID, err) 257 + } 258 + 259 + if err := h.repos.TV.Delete(ctx, showID); err != nil { 260 + return fmt.Errorf("failed to remove TV show: %w", err) 261 + } 262 + 263 + fmt.Printf("โœ“ Removed TV show: %s", show.Title) 264 + if show.Season > 0 { 265 + fmt.Printf(" (Season %d)", show.Season) 266 + } 267 + fmt.Println() 268 + 269 + return nil 270 + } 271 + 272 + func (h *TVHandler) printTVShow(show *models.TVShow) { 273 + fmt.Printf("[%d] %s", show.ID, show.Title) 274 + if show.Season > 0 { 275 + fmt.Printf(" (Season %d", show.Season) 276 + if show.Episode > 0 { 277 + fmt.Printf(", Ep %d", show.Episode) 278 + } 279 + fmt.Print(")") 280 + } 281 + if show.Status != "queued" { 282 + fmt.Printf(" (%s)", show.Status) 283 + } 284 + if show.Rating > 0 { 285 + fmt.Printf(" โ˜…%.1f", show.Rating) 286 + } 287 + fmt.Println() 288 + } 289 + 290 + // SearchAndAddTV searches for TV shows and allows user to select and add to queue 291 + func (h *TVHandler) SearchAndAddTV(ctx context.Context, query string, interactive bool) error { 292 + return h.SearchAndAdd(ctx, query, interactive) 293 + } 294 + 295 + // ListTVShows lists all TV shows in the queue with status filtering 296 + func (h *TVHandler) ListTVShows(ctx context.Context, status string) error { 297 + return h.List(ctx, status) 298 + } 299 + 300 + // ViewTVShow displays detailed information about a specific TV show 301 + func (h *TVHandler) ViewTVShow(ctx context.Context, id string) error { 302 + showID, err := strconv.ParseInt(id, 10, 64) 303 + if err != nil { 304 + return fmt.Errorf("invalid TV show ID: %s", id) 305 + } 306 + return h.View(ctx, showID) 307 + } 308 + 309 + // UpdateTVShowStatus changes the status of a TV show 310 + func (h *TVHandler) UpdateTVShowStatus(ctx context.Context, id, status string) error { 311 + showID, err := strconv.ParseInt(id, 10, 64) 312 + if err != nil { 313 + return fmt.Errorf("invalid TV show ID: %s", id) 314 + } 315 + return h.UpdateStatus(ctx, showID, status) 316 + } 317 + 318 + // MarkTVShowWatching marks a TV show as currently watching 319 + func (h *TVHandler) MarkTVShowWatching(ctx context.Context, id 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.MarkWatching(ctx, showID) 325 + } 326 + 327 + // MarkTVShowWatched marks a TV show as watched 328 + func (h *TVHandler) MarkTVShowWatched(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.MarkWatched(ctx, showID) 334 + } 335 + 336 + // RemoveTVShow removes a TV show from the queue 337 + func (h *TVHandler) RemoveTVShow(ctx context.Context, id string) error { 338 + showID, err := strconv.ParseInt(id, 10, 64) 339 + if err != nil { 340 + return fmt.Errorf("invalid TV show ID: %s", id) 341 + } 342 + return h.Remove(ctx, showID) 343 + }
+494
internal/handlers/tv_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "testing" 7 + "time" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/models" 10 + ) 11 + 12 + func createTestTVHandler(t *testing.T) *TVHandler { 13 + handler, err := NewTVHandler() 14 + if err != nil { 15 + t.Fatalf("Failed to create test TV handler: %v", err) 16 + } 17 + return handler 18 + } 19 + 20 + func createTestTVShow() *models.TVShow { 21 + now := time.Now() 22 + return &models.TVShow{ 23 + ID: 1, 24 + Title: "Test TV Show", 25 + Season: 1, 26 + Status: "queued", 27 + Rating: 4.5, 28 + Notes: "Test notes", 29 + Added: now, 30 + } 31 + } 32 + 33 + func TestTVHandler(t *testing.T) { 34 + t.Run("New", func(t *testing.T) { 35 + handler := createTestTVHandler(t) 36 + defer handler.Close() 37 + 38 + if handler.db == nil { 39 + t.Error("Expected database to be initialized") 40 + } 41 + if handler.config == nil { 42 + t.Error("Expected config to be initialized") 43 + } 44 + if handler.repos == nil { 45 + t.Error("Expected repositories to be initialized") 46 + } 47 + if handler.service == nil { 48 + t.Error("Expected service to be initialized") 49 + } 50 + }) 51 + 52 + t.Run("Close", func(t *testing.T) { 53 + handler := createTestTVHandler(t) 54 + 55 + err := handler.Close() 56 + if err != nil { 57 + t.Errorf("Expected no error when closing handler, got: %v", err) 58 + } 59 + }) 60 + 61 + t.Run("Search and Add", func(t *testing.T) { 62 + t.Run("Empty Query", func(t *testing.T) { 63 + handler := createTestTVHandler(t) 64 + defer handler.Close() 65 + 66 + err := handler.SearchAndAdd(context.Background(), "", false) 67 + if err == nil { 68 + t.Error("Expected error for empty query") 69 + } 70 + if err.Error() != "search query cannot be empty" { 71 + t.Errorf("Expected 'search query cannot be empty', got: %v", err) 72 + } 73 + }) 74 + 75 + t.Run("Search Error", func(t *testing.T) { 76 + handler := createTestTVHandler(t) 77 + defer handler.Close() 78 + 79 + err := handler.SearchAndAdd(context.Background(), "test show", false) 80 + if err != nil { 81 + t.Logf("Search failed as expected in test environment: %v", err) 82 + } 83 + }) 84 + 85 + t.Run("Network Error", func(t *testing.T) { 86 + handler := createTestTVHandler(t) 87 + defer handler.Close() 88 + 89 + err := handler.SearchAndAdd(context.Background(), "unlikely_to_find_this_show_12345", false) 90 + if err != nil { 91 + t.Logf("Network error encountered (expected in test environment): %v", err) 92 + } 93 + }) 94 + }) 95 + 96 + t.Run("List", func(t *testing.T) { 97 + t.Run("Invalid Status", func(t *testing.T) { 98 + handler := createTestTVHandler(t) 99 + defer handler.Close() 100 + 101 + err := handler.List(context.Background(), "invalid_status") 102 + if err == nil { 103 + t.Error("Expected error for invalid status") 104 + } 105 + if err.Error() != "invalid status: invalid_status (use: queued, watching, watched, or leave empty for all)" { 106 + t.Errorf("Expected invalid status error, got: %v", err) 107 + } 108 + }) 109 + 110 + t.Run("All Shows", func(t *testing.T) { 111 + handler := createTestTVHandler(t) 112 + defer handler.Close() 113 + 114 + err := handler.List(context.Background(), "") 115 + if err != nil { 116 + t.Errorf("Expected no error for listing all TV shows, got: %v", err) 117 + } 118 + }) 119 + 120 + t.Run("Queued Shows", func(t *testing.T) { 121 + handler := createTestTVHandler(t) 122 + defer handler.Close() 123 + 124 + err := handler.List(context.Background(), "queued") 125 + if err != nil { 126 + t.Errorf("Expected no error for listing queued TV shows, got: %v", err) 127 + } 128 + }) 129 + 130 + t.Run("Watching Shows", func(t *testing.T) { 131 + handler := createTestTVHandler(t) 132 + defer handler.Close() 133 + 134 + err := handler.List(context.Background(), "watching") 135 + if err != nil { 136 + t.Errorf("Expected no error for listing watching TV shows, got: %v", err) 137 + } 138 + }) 139 + 140 + t.Run("Watched Shows", func(t *testing.T) { 141 + handler := createTestTVHandler(t) 142 + defer handler.Close() 143 + 144 + err := handler.List(context.Background(), "watched") 145 + if err != nil { 146 + t.Errorf("Expected no error for listing watched TV shows, got: %v", err) 147 + } 148 + }) 149 + }) 150 + 151 + t.Run("View", func(t *testing.T) { 152 + t.Run("Show Not Found", func(t *testing.T) { 153 + handler := createTestTVHandler(t) 154 + defer handler.Close() 155 + 156 + err := handler.View(context.Background(), 999) 157 + if err == nil { 158 + t.Error("Expected error for non-existent TV show") 159 + } 160 + }) 161 + 162 + t.Run("Invalid ID", func(t *testing.T) { 163 + handler := createTestTVHandler(t) 164 + defer handler.Close() 165 + 166 + err := handler.ViewTVShow(context.Background(), "invalid") 167 + if err == nil { 168 + t.Error("Expected error for invalid TV show ID") 169 + } 170 + if err.Error() != "invalid TV show ID: invalid" { 171 + t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 172 + } 173 + }) 174 + }) 175 + 176 + t.Run("Update", func(t *testing.T) { 177 + t.Run("Update Status", func(t *testing.T) { 178 + t.Run("Invalid", func(t *testing.T) { 179 + handler := createTestTVHandler(t) 180 + defer handler.Close() 181 + 182 + err := handler.UpdateStatus(context.Background(), 1, "invalid") 183 + if err == nil { 184 + t.Error("Expected error for invalid status") 185 + } 186 + if err.Error() != "invalid status: invalid (valid: queued, watching, watched, removed)" { 187 + t.Errorf("Expected invalid status error, got: %v", err) 188 + } 189 + }) 190 + 191 + t.Run("Show Not Found", func(t *testing.T) { 192 + handler := createTestTVHandler(t) 193 + defer handler.Close() 194 + 195 + err := handler.UpdateStatus(context.Background(), 999, "watched") 196 + if err == nil { 197 + t.Error("Expected error for non-existent TV show") 198 + } 199 + }) 200 + }) 201 + }) 202 + 203 + t.Run("MarkWatching_ShowNotFound", func(t *testing.T) { 204 + handler := createTestTVHandler(t) 205 + defer handler.Close() 206 + 207 + err := handler.MarkWatching(context.Background(), 999) 208 + if err == nil { 209 + t.Error("Expected error for non-existent TV show") 210 + } 211 + }) 212 + 213 + t.Run("MarkWatched_ShowNotFound", func(t *testing.T) { 214 + handler := createTestTVHandler(t) 215 + defer handler.Close() 216 + 217 + err := handler.MarkWatched(context.Background(), 999) 218 + if err == nil { 219 + t.Error("Expected error for non-existent TV show") 220 + } 221 + }) 222 + 223 + t.Run("Remove_ShowNotFound", func(t *testing.T) { 224 + handler := createTestTVHandler(t) 225 + defer handler.Close() 226 + 227 + err := handler.Remove(context.Background(), 999) 228 + if err == nil { 229 + t.Error("Expected error for non-existent TV show") 230 + } 231 + }) 232 + 233 + t.Run("UpdateTVShowStatus_InvalidID", func(t *testing.T) { 234 + handler := createTestTVHandler(t) 235 + defer handler.Close() 236 + 237 + err := handler.UpdateTVShowStatus(context.Background(), "invalid", "watched") 238 + if err == nil { 239 + t.Error("Expected error for invalid TV show ID") 240 + } 241 + if err.Error() != "invalid TV show ID: invalid" { 242 + t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 243 + } 244 + }) 245 + 246 + t.Run("MarkTVShowWatching_InvalidID", func(t *testing.T) { 247 + handler := createTestTVHandler(t) 248 + defer handler.Close() 249 + 250 + err := handler.MarkTVShowWatching(context.Background(), "invalid") 251 + if err == nil { 252 + t.Error("Expected error for invalid TV show ID") 253 + } 254 + if err.Error() != "invalid TV show ID: invalid" { 255 + t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 256 + } 257 + }) 258 + 259 + t.Run("MarkTVShowWatched_InvalidID", func(t *testing.T) { 260 + handler := createTestTVHandler(t) 261 + defer handler.Close() 262 + 263 + err := handler.MarkTVShowWatched(context.Background(), "invalid") 264 + if err == nil { 265 + t.Error("Expected error for invalid TV show ID") 266 + } 267 + if err.Error() != "invalid TV show ID: invalid" { 268 + t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 269 + } 270 + }) 271 + 272 + t.Run("RemoveTVShow_InvalidID", func(t *testing.T) { 273 + handler := createTestTVHandler(t) 274 + defer handler.Close() 275 + 276 + err := handler.RemoveTVShow(context.Background(), "invalid") 277 + if err == nil { 278 + t.Error("Expected error for invalid TV show ID") 279 + } 280 + if err.Error() != "invalid TV show ID: invalid" { 281 + t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err) 282 + } 283 + }) 284 + 285 + t.Run("printTVShow", func(t *testing.T) { 286 + handler := createTestTVHandler(t) 287 + defer handler.Close() 288 + 289 + show := createTestTVShow() 290 + 291 + handler.printTVShow(show) 292 + 293 + minimalShow := &models.TVShow{ 294 + ID: 2, 295 + Title: "Minimal Show", 296 + } 297 + handler.printTVShow(minimalShow) 298 + 299 + watchedShow := &models.TVShow{ 300 + ID: 3, 301 + Title: "Watched Show", 302 + Season: 2, 303 + Episode: 5, 304 + Status: "watched", 305 + Rating: 3.5, 306 + } 307 + handler.printTVShow(watchedShow) 308 + }) 309 + 310 + t.Run("SearchAndAddTV", func(t *testing.T) { 311 + handler := createTestTVHandler(t) 312 + defer handler.Close() 313 + 314 + err := handler.SearchAndAddTV(context.Background(), "", false) 315 + if err == nil { 316 + t.Error("Expected error for empty query") 317 + } 318 + }) 319 + 320 + t.Run("List TV Shows", func(t *testing.T) { 321 + handler := createTestTVHandler(t) 322 + defer handler.Close() 323 + 324 + err := handler.ListTVShows(context.Background(), "") 325 + if err != nil { 326 + t.Errorf("Expected no error for listing all TV shows, got: %v", err) 327 + } 328 + 329 + err = handler.ListTVShows(context.Background(), "invalid") 330 + if err == nil { 331 + t.Error("Expected error for invalid status") 332 + } 333 + }) 334 + 335 + t.Run("Integration", func(t *testing.T) { 336 + t.Run("CreateAndRetrieve", func(t *testing.T) { 337 + handler := createTestTVHandler(t) 338 + defer handler.Close() 339 + 340 + show := createTestTVShow() 341 + show.ID = 0 342 + 343 + id, err := handler.repos.TV.Create(context.Background(), show) 344 + if err != nil { 345 + t.Errorf("Failed to create TV show: %v", err) 346 + return 347 + } 348 + 349 + err = handler.View(context.Background(), id) 350 + if err != nil { 351 + t.Errorf("Failed to view created TV show: %v", err) 352 + } 353 + 354 + err = handler.UpdateStatus(context.Background(), id, "watching") 355 + if err != nil { 356 + t.Errorf("Failed to update TV show status: %v", err) 357 + } 358 + 359 + err = handler.MarkWatched(context.Background(), id) 360 + if err != nil { 361 + t.Errorf("Failed to mark TV show as watched: %v", err) 362 + } 363 + 364 + err = handler.MarkWatching(context.Background(), id) 365 + if err != nil { 366 + t.Errorf("Failed to mark TV show as watching: %v", err) 367 + } 368 + 369 + err = handler.Remove(context.Background(), id) 370 + if err != nil { 371 + t.Errorf("Failed to remove TV show: %v", err) 372 + } 373 + }) 374 + 375 + t.Run("StatusFiltering", func(t *testing.T) { 376 + handler := createTestTVHandler(t) 377 + defer handler.Close() 378 + 379 + queuedShow := &models.TVShow{ 380 + Title: "Queued Show", 381 + Status: "queued", 382 + Added: time.Now(), 383 + } 384 + watchingShow := &models.TVShow{ 385 + Title: "Watching Show", 386 + Status: "watching", 387 + Added: time.Now(), 388 + } 389 + watchedShow := &models.TVShow{ 390 + Title: "Watched Show", 391 + Status: "watched", 392 + Added: time.Now(), 393 + } 394 + 395 + id1, err := handler.repos.TV.Create(context.Background(), queuedShow) 396 + if err != nil { 397 + t.Errorf("Failed to create queued show: %v", err) 398 + return 399 + } 400 + defer handler.repos.TV.Delete(context.Background(), id1) 401 + 402 + id2, err := handler.repos.TV.Create(context.Background(), watchingShow) 403 + if err != nil { 404 + t.Errorf("Failed to create watching show: %v", err) 405 + return 406 + } 407 + defer handler.repos.TV.Delete(context.Background(), id2) 408 + 409 + id3, err := handler.repos.TV.Create(context.Background(), watchedShow) 410 + if err != nil { 411 + t.Errorf("Failed to create watched show: %v", err) 412 + return 413 + } 414 + defer handler.repos.TV.Delete(context.Background(), id3) 415 + 416 + testCases := []string{"", "queued", "watching", "watched"} 417 + for _, status := range testCases { 418 + err = handler.List(context.Background(), status) 419 + if err != nil { 420 + t.Errorf("Failed to list TV shows with status '%s': %v", status, err) 421 + } 422 + } 423 + }) 424 + }) 425 + 426 + t.Run("ErrorPaths", func(t *testing.T) { 427 + handler := createTestTVHandler(t) 428 + defer handler.Close() 429 + 430 + ctx := context.Background() 431 + nonExistentID := int64(999999) 432 + 433 + testCases := []struct { 434 + name string 435 + fn func() error 436 + }{ 437 + { 438 + name: "View non-existent show", 439 + fn: func() error { return handler.View(ctx, nonExistentID) }, 440 + }, 441 + { 442 + name: "Update status of non-existent show", 443 + fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") }, 444 + }, 445 + { 446 + name: "Mark non-existent show as watching", 447 + fn: func() error { return handler.MarkWatching(ctx, nonExistentID) }, 448 + }, 449 + { 450 + name: "Mark non-existent show as watched", 451 + fn: func() error { return handler.MarkWatched(ctx, nonExistentID) }, 452 + }, 453 + { 454 + name: "Remove non-existent show", 455 + fn: func() error { return handler.Remove(ctx, nonExistentID) }, 456 + }, 457 + } 458 + 459 + for _, tc := range testCases { 460 + t.Run(tc.name, func(t *testing.T) { 461 + err := tc.fn() 462 + if err == nil { 463 + t.Errorf("Expected error for %s", tc.name) 464 + } 465 + }) 466 + } 467 + }) 468 + 469 + t.Run("ValidStatusValues", func(t *testing.T) { 470 + handler := createTestTVHandler(t) 471 + defer handler.Close() 472 + 473 + valid := []string{"queued", "watching", "watched", "removed"} 474 + invalid := []string{"invalid", "pending", "completed", ""} 475 + 476 + for _, status := range valid { 477 + if err := handler.UpdateStatus(context.Background(), 999, status); err != nil && 478 + err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watching, watched, removed)", status) { 479 + t.Errorf("Status '%s' should be valid but was rejected", status) 480 + } 481 + } 482 + 483 + for _, status := range invalid { 484 + err := handler.UpdateStatus(context.Background(), 1, status) 485 + if err == nil { 486 + t.Errorf("Status '%s' should be invalid but was accepted", status) 487 + } 488 + got := fmt.Sprintf("invalid status: %s (valid: queued, watching, watched, removed)", status) 489 + if err.Error() != got { 490 + t.Errorf("Expected '%s', got: %v", got, err) 491 + } 492 + } 493 + }) 494 + }
+78 -79
internal/services/media.go
··· 26 26 CertifiedFresh bool 27 27 } 28 28 29 + type Person struct { 30 + Name string `json:"name"` 31 + SameAs string `json:"sameAs"` 32 + Image string `json:"image"` 33 + } 34 + 35 + type AggregateRating struct { 36 + RatingValue string `json:"ratingValue"` 37 + RatingCount int `json:"ratingCount"` 38 + ReviewCount int `json:"reviewCount"` 39 + } 40 + 41 + type Season struct { 42 + Name string `json:"name"` 43 + URL string `json:"url"` 44 + } 45 + 46 + type PartOfSeries struct { 47 + Name string `json:"name"` 48 + URL string `json:"url"` 49 + } 50 + 51 + type TVSeries struct { 52 + Context string `json:"@context"` 53 + Type string `json:"@type"` 54 + Name string `json:"name"` 55 + URL string `json:"url"` 56 + Description string `json:"description"` 57 + Image string `json:"image"` 58 + Genre []string `json:"genre"` 59 + ContentRating string `json:"contentRating"` 60 + DateCreated string `json:"dateCreated"` 61 + NumberOfSeasons int `json:"numberOfSeasons"` 62 + Actors []Person `json:"actor"` 63 + Producers []Person `json:"producer"` 64 + AggregateRating AggregateRating `json:"aggregateRating"` 65 + Seasons []Season `json:"containsSeason"` 66 + } 67 + 68 + type Movie struct { 69 + Context string `json:"@context"` 70 + Type string `json:"@type"` 71 + Name string `json:"name"` 72 + URL string `json:"url"` 73 + Description string `json:"description"` 74 + Image string `json:"image"` 75 + Genre []string `json:"genre"` 76 + ContentRating string `json:"contentRating"` 77 + DateCreated string `json:"dateCreated"` 78 + Actors []Person `json:"actor"` 79 + Directors []Person `json:"director"` 80 + Producers []Person `json:"producer"` 81 + AggregateRating AggregateRating `json:"aggregateRating"` 82 + } 83 + 84 + type TVSeason struct { 85 + Context string `json:"@context"` 86 + Type string `json:"@type"` 87 + Name string `json:"name"` 88 + URL string `json:"url"` 89 + Description string `json:"description"` 90 + Image string `json:"image"` 91 + SeasonNumber int `json:"seasonNumber"` 92 + DatePublished string `json:"datePublished"` 93 + PartOfSeries PartOfSeries `json:"partOfSeries"` 94 + AggregateRating AggregateRating `json:"aggregateRating"` 95 + } 96 + 97 + type MovieService struct { 98 + client *http.Client 99 + limiter *rate.Limiter 100 + } 101 + 102 + type TVService struct { 103 + client *http.Client 104 + limiter *rate.Limiter 105 + } 106 + 29 107 // ParseSearch parses Rotten Tomatoes search results HTML into Media entries. 30 108 func ParseSearch(r io.Reader) ([]Media, error) { 31 109 doc, err := goquery.NewDocumentFromReader(r) ··· 95 173 return ParseSearch(strings.NewReader(html)) 96 174 } 97 175 98 - type Person struct { 99 - Name string `json:"name"` 100 - SameAs string `json:"sameAs"` 101 - Image string `json:"image"` 102 - } 103 - 104 - type AggregateRating struct { 105 - RatingValue string `json:"ratingValue"` 106 - RatingCount int `json:"ratingCount"` 107 - ReviewCount int `json:"reviewCount"` 108 - } 109 - 110 - type Season struct { 111 - Name string `json:"name"` 112 - URL string `json:"url"` 113 - } 114 - 115 - type PartOfSeries struct { 116 - Name string `json:"name"` 117 - URL string `json:"url"` 118 - } 119 - 120 - type TVSeries struct { 121 - Context string `json:"@context"` 122 - Type string `json:"@type"` 123 - Name string `json:"name"` 124 - URL string `json:"url"` 125 - Description string `json:"description"` 126 - Image string `json:"image"` 127 - Genre []string `json:"genre"` 128 - ContentRating string `json:"contentRating"` 129 - DateCreated string `json:"dateCreated"` 130 - NumberOfSeasons int `json:"numberOfSeasons"` 131 - Actors []Person `json:"actor"` 132 - Producers []Person `json:"producer"` 133 - AggregateRating AggregateRating `json:"aggregateRating"` 134 - Seasons []Season `json:"containsSeason"` 135 - } 136 - 137 - type Movie struct { 138 - Context string `json:"@context"` 139 - Type string `json:"@type"` 140 - Name string `json:"name"` 141 - URL string `json:"url"` 142 - Description string `json:"description"` 143 - Image string `json:"image"` 144 - Genre []string `json:"genre"` 145 - ContentRating string `json:"contentRating"` 146 - DateCreated string `json:"dateCreated"` 147 - Actors []Person `json:"actor"` 148 - Directors []Person `json:"director"` 149 - Producers []Person `json:"producer"` 150 - AggregateRating AggregateRating `json:"aggregateRating"` 151 - } 152 - 153 - type TVSeason struct { 154 - Context string `json:"@context"` 155 - Type string `json:"@type"` 156 - Name string `json:"name"` 157 - URL string `json:"url"` 158 - Description string `json:"description"` 159 - Image string `json:"image"` 160 - SeasonNumber int `json:"seasonNumber"` 161 - DatePublished string `json:"datePublished"` 162 - PartOfSeries PartOfSeries `json:"partOfSeries"` 163 - AggregateRating AggregateRating `json:"aggregateRating"` 164 - } 165 - 166 176 func ExtractTVSeriesMetadata(r io.Reader) (*TVSeries, error) { 167 177 doc, err := goquery.NewDocumentFromReader(r) 168 178 if err != nil { ··· 295 305 return ExtractTVSeasonMetadata(strings.NewReader(html)) 296 306 } 297 307 298 - type MovieService struct { 299 - client *http.Client 300 - limiter *rate.Limiter 301 - } 302 - 303 308 // NewMovieService creates a new movie service with rate limiting 304 309 func NewMovieService() *MovieService { 305 310 return &MovieService{ ··· 389 394 // Close cleans up the service resources 390 395 func (s *MovieService) Close() error { 391 396 return nil 392 - } 393 - 394 - // TVService implements APIService for Rotten Tomatoes TV shows 395 - type TVService struct { 396 - client *http.Client 397 - limiter *rate.Limiter 398 397 } 399 398 400 399 // NewTVService creates a new TV service with rate limiting
+7 -12
media.md
··· 20 20 21 21 ## Media Service Refactor 22 22 23 - ### Create MovieService that implement APIService 23 + ### Create MovieService that implement APIService (โœ“) 24 24 25 25 ```go 26 26 // MovieService implements APIService for Rotten Tomatoes movies ··· 30 30 } 31 31 ``` 32 32 33 - ### Create TVService that implement APIService 33 + ### Create TVService that implement APIService (โœ“) 34 34 35 35 ```go 36 36 // TVService implements APIService for Rotten Tomatoes TV shows ··· 40 40 } 41 41 ``` 42 42 43 - ### Implement APIService 43 + ### Implement APIService (โœ“) 44 44 45 45 - `Search(ctx, query, page, limit)` - Use existing SearchRottenTomatoes() and convert results to []*models.Model 46 46 - `Get(ctx, id)` - Use existing FetchMovie() / FetchTVSeries() with Rotten Tomatoes URLs 47 47 - `Check(ctx)` - Simple connectivity test to Rotten Tomatoes 48 48 - `Close()` - Cleanup resources 49 49 50 - ### Result Conversion 50 + ### Result Conversion (โœ“) 51 51 52 52 - Convert services.Media search results to models.Movie / models.TVShow 53 53 - Convert detailed metadata structs to models with proper status defaults 54 54 - Extract key information (title, year, rating, description) into notes field 55 55 56 - ## Handler Implementation 56 + ## Handler Implementation (โœ“) 57 57 58 58 ### Create MovieHandler similar to BookHandler 59 59 60 60 ```go 61 61 type MovieHandler struct { 62 62 db *store.Database 63 - config*store.Config 63 + config *store.Config 64 64 repos *repo.Repositories 65 - service*services.MovieService 65 + service *services.MovieService 66 66 } 67 67 ``` 68 68 69 - ### Implement search 70 - 71 69 - `SearchAndAddMovie(ctx, args, interactive)` - Mirror book search UX 72 70 - `SearchAndAddTV(ctx, args, interactive)` - Same pattern for TV shows 73 71 - Number-based selection interface identical to books 74 - 75 - ### Database Integration 76 - 77 72 - Add movie/TV repositories if not already present 78 73 - Ensure proper CRUD operations for queue management 79 74