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