···197197- [ ] Complete README/documentation
198198- [ ] Installation instructions
199199- [ ] Usage examples
200200+201201+## Tech Debt
202202+203203+### Signatures
204204+205205+We've got inconsistent argument parsing and sanitization leading to calls to strconv.Atoi in tests & handler funcs.
206206+This is only done correctly in the note command -> handler sequence
207207+208208+### Movie Commands - Missing Tests
209209+210210+- movie watched [id] - marks movie as watched
211211+212212+### TV Commands - Missing Tests
213213+214214+- tv watching [id] - marks TV show as watching
215215+- tv watched [id] - marks TV show as watched
216216+217217+### Book Commands - Missing Tests
218218+219219+- book add [search query...] - search and add book
220220+- book reading `<id>` - marks book as reading
221221+- book finished `<id>` - marks book as finished
222222+- book progress `<id>` `<percentage>` - updates reading progress
223223+
···21212222const (
2323 // Open Library API endpoints
2424- openLibraryBaseURL = "https://openlibrary.org"
2525- openLibrarySearch = openLibraryBaseURL + "/search.json"
2424+ OpenLibraryBaseURL string = "https://openlibrary.org"
2525+ openLibrarySearch string = OpenLibraryBaseURL + "/search.json"
26262727 // Rate limiting: 180 requests per minute = 3 requests per second
2828- requestsPerSecond = 3
2929- burstLimit = 5
2828+ requestsPerSecond int = 3
2929+ burstLimit int = 5
30303131 // User agent
3232 // TODO: See https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications
···4545type BookService struct {
4646 client *http.Client
4747 limiter *rate.Limiter
4848- baseURL string // Allow configurable base URL for testing
4848+ baseURL string // Allows configurable base URL for testing
4949}
50505151// NewBookService creates a new book service with rate limiting
5252-func NewBookService() *BookService {
5353- return &BookService{
5454- client: &http.Client{
5555- Timeout: 30 * time.Second,
5656- },
5757- limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
5858- baseURL: openLibraryBaseURL,
5959- }
6060-}
6161-6262-// NewBookServiceWithBaseURL creates a book service with custom base URL (for testing)
6363-func NewBookServiceWithBaseURL(baseURL string) *BookService {
5252+func NewBookService(baseURL string) *BookService {
6453 return &BookService{
6554 client: &http.Client{
6655 Timeout: 30 * time.Second,
···125114 Key string `json:"key"`
126115}
127116117117+func (bs *BookService) buildSearchURL(query string, page, limit int) string {
118118+ params := url.Values{}
119119+ params.Add("q", query)
120120+ params.Add("offset", strconv.Itoa((page-1)*limit))
121121+ params.Add("limit", strconv.Itoa(limit))
122122+ params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext")
123123+ return bs.baseURL + "/search.json?" + params.Encode()
124124+}
125125+128126// Search searches for books using the Open Library API
129127func (bs *BookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) {
130128 if err := bs.limiter.Wait(ctx); err != nil {
131129 return nil, fmt.Errorf("rate limit wait failed: %w", err)
132130 }
133131134134- // Build search URL
135135- params := url.Values{}
136136- params.Add("q", query)
137137- params.Add("offset", strconv.Itoa((page-1)*limit))
138138- params.Add("limit", strconv.Itoa(limit))
139139- params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext")
140140-141141- searchURL := bs.baseURL + "/search.json?" + params.Encode()
132132+ searchURL := bs.buildSearchURL(query, page, limit)
142133143134 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
144135 if err != nil {
···163154 return nil, fmt.Errorf("failed to decode response: %w", err)
164155 }
165156166166- // Convert to models
167157 var books []*models.Model
168158 for _, doc := range searchResp.Docs {
169159 book := bs.searchDocToBook(doc)
···180170 return nil, fmt.Errorf("rate limit wait failed: %w", err)
181171 }
182172183183- // Ensure id starts with /works/
184173 workKey := id
185174 if !strings.HasPrefix(workKey, "/works/") {
186175 workKey = "/works/" + id
···252241func (bs *BookService) Close() error {
253242 return nil
254243}
255255-256256-// Helper functions
257244258245func (bs *BookService) searchDocToBook(doc OpenLibrarySearchDoc) *models.Book {
259246 book := &models.Book{
···266253 book.Author = strings.Join(doc.AuthorName, ", ")
267254 }
268255269269- // Set publication year as pages (approximation)
270256 if doc.FirstPublishYear > 0 {
271257 // We don't have page count, so we'll leave it as 0
272272- // Could potentially estimate based on edition count or other factors
258258+ // TODO: Could potentially estimate based on edition count or other factors
273259 }
274260275261 var notes []string
···297283 Added: time.Now(),
298284 }
299285300300- // Extract author names (would need additional API calls to get full names)
286286+ // TODO: Extract author names (would need additional API calls to get full names)
301287 if len(work.Authors) > 0 {
302302- // For now, just use the keys
303288 var authorKeys []string
304289 for _, author := range work.Authors {
305290 key := strings.TrimPrefix(author.Author.Key, "/authors/")
+30-41
internal/services/services_test.go
···88 "strings"
99 "testing"
1010 "time"
1111+1212+ "golang.org/x/time/rate"
1113)
12141315func TestBookService(t *testing.T) {
1416 t.Run("NewBookService", func(t *testing.T) {
1515- service := NewBookService()
1717+ service := NewBookService(OpenLibraryBaseURL)
16181719 if service == nil {
1820 t.Fatal("NewBookService should return a non-nil service")
···2628 t.Error("BookService should have a non-nil rate limiter")
2729 }
28302929- if service.limiter.Limit() != requestsPerSecond {
3131+ if service.limiter.Limit() != rate.Limit(requestsPerSecond) {
3032 t.Errorf("Expected rate limit of %v, got %v", requestsPerSecond, service.limiter.Limit())
3133 }
3234 })
···8486 }))
8587 defer server.Close()
86888787- service := NewBookServiceWithBaseURL(server.URL)
8989+ service := NewBookService(server.URL)
8890 ctx := context.Background()
8991 results, err := service.Search(ctx, "roald dahl", 1, 10)
9092···107109 }))
108110 defer server.Close()
109111110110- service := NewBookServiceWithBaseURL(server.URL)
112112+ service := NewBookService(server.URL)
111113 ctx := context.Background()
112114113115 _, err := service.Search(ctx, "test", 1, 10)
···115117 t.Error("Search should return error for API failure")
116118 }
117119118118- if !strings.Contains(err.Error(), "API returned status 500") {
119119- t.Errorf("Error should mention status code, got: %v", err)
120120- }
120120+ AssertErrorContains(t, err, "API returned status 500")
121121 })
122122123123 t.Run("handles malformed JSON", func(t *testing.T) {
···127127 }))
128128 defer server.Close()
129129130130- service := NewBookServiceWithBaseURL(server.URL)
130130+ service := NewBookService(server.URL)
131131 ctx := context.Background()
132132133133 _, err := service.Search(ctx, "test", 1, 10)
···135135 t.Error("Search should return error for malformed JSON")
136136 }
137137138138- if !strings.Contains(err.Error(), "failed to decode response") {
139139- t.Errorf("Error should mention decode failure, got: %v", err)
140140- }
138138+ AssertErrorContains(t, err, "failed to decode response")
141139 })
142140143141 t.Run("handles context cancellation", func(t *testing.T) {
144144- service := NewBookService()
142142+ service := NewBookService(OpenLibraryBaseURL)
145143 ctx, cancel := context.WithCancel(context.Background())
146144 cancel()
147145···152150 })
153151154152 t.Run("respects pagination", func(t *testing.T) {
155155- service := NewBookService()
153153+ service := NewBookService(OpenLibraryBaseURL)
156154 ctx := context.Background()
157155158156 _, err := service.Search(ctx, "test", 2, 5)
···194192 }))
195193 defer server.Close()
196194197197- service := NewBookServiceWithBaseURL(server.URL)
195195+ service := NewBookService(server.URL)
198196 ctx := context.Background()
199197200198 result, err := service.Get(ctx, "OL45804W")
···208206 })
209207210208 t.Run("handles work key with /works/ prefix", func(t *testing.T) {
211211- service := NewBookService()
209209+ service := NewBookService(OpenLibraryBaseURL)
212210 ctx := context.Background()
213211214212 _, err1 := service.Get(ctx, "OL45804W")
···225223 }))
226224 defer server.Close()
227225228228- service := NewBookServiceWithBaseURL(server.URL)
226226+ service := NewBookService(server.URL)
229227 ctx := context.Background()
230228231229 _, err := service.Get(ctx, "nonexistent")
···233231 t.Error("Get should return error for non-existent work")
234232 }
235233236236- if !strings.Contains(err.Error(), "book not found") {
237237- t.Errorf("Error should mention book not found, got: %v", err)
238238- }
234234+ AssertErrorContains(t, err, "book not found")
239235 })
240236241237 t.Run("handles API error", func(t *testing.T) {
···244240 }))
245241 defer server.Close()
246242247247- service := NewBookServiceWithBaseURL(server.URL)
243243+ service := NewBookService(server.URL)
248244 ctx := context.Background()
249245250246 _, err := service.Get(ctx, "test")
···252248 t.Error("Get should return error for API failure")
253249 }
254250255255- if !strings.Contains(err.Error(), "API returned status 500") {
256256- t.Errorf("Error should mention status code, got: %v", err)
257257- }
251251+ AssertErrorContains(t, err, "API returned status 500")
258252 })
259253 })
260254261255 t.Run("Check", func(t *testing.T) {
262256 t.Run("successful check", func(t *testing.T) {
263257 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
264264- // Verify it's a search request with test query
265258 if r.URL.Path != "/search.json" {
266259 t.Errorf("Expected path /search.json, got %s", r.URL.Path)
267260 }
···274267 t.Errorf("Expected limit '1', got %s", query.Get("limit"))
275268 }
276269277277- // Verify User-Agent
278270 if r.Header.Get("User-Agent") != userAgent {
279271 t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent"))
280272 }
···284276 }))
285277 defer server.Close()
286278287287- service := NewBookServiceWithBaseURL(server.URL)
279279+ service := NewBookService(server.URL)
288280 ctx := context.Background()
289281290290- // Test with mock server
291282 err := service.Check(ctx)
292283 if err != nil {
293284 t.Errorf("Check should not return error for healthy API: %v", err)
···300291 }))
301292 defer server.Close()
302293303303- service := NewBookServiceWithBaseURL(server.URL)
294294+ service := NewBookService(server.URL)
304295 ctx := context.Background()
305296306297 err := service.Check(ctx)
···308299 t.Error("Check should return error for API failure")
309300 }
310301311311- if !strings.Contains(err.Error(), "open Library API returned status 503") {
312312- t.Errorf("Error should mention API status, got: %v", err)
313313- }
302302+ AssertErrorContains(t, err, "open Library API returned status 503")
314303 })
315304316305 t.Run("handles network error", func(t *testing.T) {
317317- service := NewBookService()
306306+ service := NewBookService(OpenLibraryBaseURL)
318307 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
319308 defer cancel()
320309···326315 })
327316328317 t.Run("Close", func(t *testing.T) {
329329- service := NewBookService()
318318+ service := NewBookService(OpenLibraryBaseURL)
330319 err := service.Close()
331320 if err != nil {
332321 t.Errorf("Close should not return error: %v", err)
···335324336325 t.Run("RateLimiting", func(t *testing.T) {
337326 t.Run("respects rate limits", func(t *testing.T) {
338338- service := NewBookService()
327327+ service := NewBookService(OpenLibraryBaseURL)
339328 ctx := context.Background()
340329341330 start := time.Now()
···368357369358 t.Run("Conversion Functions", func(t *testing.T) {
370359 t.Run("searchDocToBook conversion", func(t *testing.T) {
371371- service := NewBookService()
360360+ service := NewBookService(OpenLibraryBaseURL)
372361 doc := OpenLibrarySearchDoc{
373362 Key: "/works/OL45804W",
374363 Title: "Test Book",
···403392 })
404393405394 t.Run("workToBook conversion with string description", func(t *testing.T) {
406406- service := NewBookService()
395395+ service := NewBookService(OpenLibraryBaseURL)
407396 work := OpenLibraryWork{
408397 Key: "/works/OL45804W",
409398 Title: "Test Work",
···431420 })
432421433422 t.Run("workToBook conversion with object description", func(t *testing.T) {
434434- service := NewBookService()
423423+ service := NewBookService(OpenLibraryBaseURL)
435424 work := OpenLibraryWork{
436425 Title: "Test Work",
437426 Description: map[string]any{
···448437 })
449438450439 t.Run("workToBook uses subjects when no description", func(t *testing.T) {
451451- service := NewBookService()
440440+ service := NewBookService(OpenLibraryBaseURL)
452441 work := OpenLibraryWork{
453442 Title: "Test Work",
454443 Subjects: []string{"Fiction", "Adventure", "Classic", "Literature", "Drama", "Extra"},
···474463 t.Run("Interface Compliance", func(t *testing.T) {
475464 t.Run("implements APIService interface", func(t *testing.T) {
476465 var _ APIService = &BookService{}
477477- var _ APIService = NewBookService()
466466+ var _ APIService = NewBookService(OpenLibraryBaseURL)
478467 })
479468 })
480469···487476488477 t.Run("Constants", func(t *testing.T) {
489478 t.Run("API endpoints are correct", func(t *testing.T) {
490490- if openLibraryBaseURL != "https://openlibrary.org" {
491491- t.Errorf("Base URL should be https://openlibrary.org, got %s", openLibraryBaseURL)
479479+ if OpenLibraryBaseURL != "https://openlibrary.org" {
480480+ t.Errorf("Base URL should be https://openlibrary.org, got %s", OpenLibraryBaseURL)
492481 }
493482494483 if openLibrarySearch != "https://openlibrary.org/search.json" {
+273
internal/services/test_utilities.go
···11+package services
22+33+import (
44+ "bytes"
55+ "context"
66+ _ "embed"
77+ "errors"
88+ "strings"
99+ "testing"
1010+1111+ "github.com/stormlightlabs/noteleaf/internal/models"
1212+)
1313+1414+// From: https://www.rottentomatoes.com/m/the_fantastic_four_first_steps
1515+//
1616+//go:embed samples/movie.html
1717+var MovieSample []byte
1818+1919+// From: https://www.rottentomatoes.com/search?search=peacemaker
2020+//
2121+//go:embed samples/search.html
2222+var SearchSample []byte
2323+2424+// From: https://www.rottentomatoes.com/tv/peacemaker_2022
2525+//
2626+//go:embed samples/series_overview.html
2727+var SeriesSample []byte
2828+2929+// From: https://www.rottentomatoes.com/tv/peacemaker_2022/s02
3030+//
3131+//go:embed samples/series_season.html
3232+var SeasonSample []byte
3333+3434+// From: https://www.rottentomatoes.com/search?search=Fantastic%20Four
3535+//
3636+//go:embed samples/movie_search.html
3737+var MovieSearchSample []byte
3838+3939+// MockConfig holds configuration for mocking media services
4040+type MockConfig struct {
4141+ SearchResults []Media
4242+ SearchError error
4343+ MovieResult *Movie
4444+ MovieError error
4545+ TVSeriesResult *TVSeries
4646+ TVSeriesError error
4747+ TVSeasonResult *TVSeason
4848+ TVSeasonError error
4949+ HTMLResult string
5050+ HTMLError error
5151+}
5252+5353+// MockSetup contains the original function variables for restoration
5454+type MockSetup struct {
5555+ originalSearchRottenTomatoes func(string) ([]Media, error)
5656+ originalFetchMovie func(string) (*Movie, error)
5757+ originalFetchTVSeries func(string) (*TVSeries, error)
5858+ originalFetchTVSeason func(string) (*TVSeason, error)
5959+ originalFetchHTML func(string) (string, error)
6060+}
6161+6262+// SetupMediaMocks configures mock functions for media services testing
6363+func SetupMediaMocks(t *testing.T, config MockConfig) func() {
6464+ t.Helper()
6565+6666+ setup := &MockSetup{
6767+ originalSearchRottenTomatoes: SearchRottenTomatoes,
6868+ originalFetchMovie: FetchMovie,
6969+ originalFetchTVSeries: FetchTVSeries,
7070+ originalFetchTVSeason: FetchTVSeason,
7171+ originalFetchHTML: FetchHTML,
7272+ }
7373+7474+ SearchRottenTomatoes = func(q string) ([]Media, error) {
7575+ if config.SearchError != nil {
7676+ return nil, config.SearchError
7777+ }
7878+ return config.SearchResults, nil
7979+ }
8080+8181+ FetchMovie = func(url string) (*Movie, error) {
8282+ if config.MovieError != nil {
8383+ return nil, config.MovieError
8484+ }
8585+ return config.MovieResult, nil
8686+ }
8787+8888+ FetchTVSeries = func(url string) (*TVSeries, error) {
8989+ if config.TVSeriesError != nil {
9090+ return nil, config.TVSeriesError
9191+ }
9292+ return config.TVSeriesResult, nil
9393+ }
9494+9595+ FetchTVSeason = func(url string) (*TVSeason, error) {
9696+ if config.TVSeasonError != nil {
9797+ return nil, config.TVSeasonError
9898+ }
9999+ return config.TVSeasonResult, nil
100100+ }
101101+102102+ FetchHTML = func(url string) (string, error) {
103103+ if config.HTMLError != nil {
104104+ return "", config.HTMLError
105105+ }
106106+ return config.HTMLResult, nil
107107+ }
108108+109109+ return func() {
110110+ SearchRottenTomatoes = setup.originalSearchRottenTomatoes
111111+ FetchMovie = setup.originalFetchMovie
112112+ FetchTVSeries = setup.originalFetchTVSeries
113113+ FetchTVSeason = setup.originalFetchTVSeason
114114+ FetchHTML = setup.originalFetchHTML
115115+ }
116116+}
117117+118118+// Sample data access helpers - these use the embedded samples
119119+func GetSampleMovieSearchResults() ([]Media, error) {
120120+ return ParseSearch(bytes.NewReader(MovieSearchSample))
121121+}
122122+123123+func GetSampleSearchResults() ([]Media, error) {
124124+ return ParseSearch(bytes.NewReader(SearchSample))
125125+}
126126+127127+func GetSampleMovie() (*Movie, error) {
128128+ return ExtractMovieMetadata(bytes.NewReader(MovieSample))
129129+}
130130+131131+func GetSampleTVSeries() (*TVSeries, error) {
132132+ return ExtractTVSeriesMetadata(bytes.NewReader(SeriesSample))
133133+}
134134+135135+func GetSampleTVSeason() (*TVSeason, error) {
136136+ return ExtractTVSeasonMetadata(bytes.NewReader(SeasonSample))
137137+}
138138+139139+// SetupSuccessfulMovieMocks configures mocks for successful movie operations
140140+func SetupSuccessfulMovieMocks(t *testing.T) func() {
141141+ t.Helper()
142142+143143+ movieResults, err := GetSampleMovieSearchResults()
144144+ if err != nil {
145145+ t.Fatalf("failed to get sample movie results: %v", err)
146146+ }
147147+148148+ movie, err := GetSampleMovie()
149149+ if err != nil {
150150+ t.Fatalf("failed to get sample movie: %v", err)
151151+ }
152152+153153+ return SetupMediaMocks(t, MockConfig{
154154+ SearchResults: movieResults,
155155+ MovieResult: movie,
156156+ HTMLResult: "ok",
157157+ })
158158+}
159159+160160+// SetupSuccessfulTVMocks configures mocks for successful TV operations
161161+func SetupSuccessfulTVMocks(t *testing.T) func() {
162162+ t.Helper()
163163+164164+ searchResults, err := GetSampleSearchResults()
165165+ if err != nil {
166166+ t.Fatalf("failed to get sample search results: %v", err)
167167+ }
168168+169169+ series, err := GetSampleTVSeries()
170170+ if err != nil {
171171+ t.Fatalf("failed to get sample TV series: %v", err)
172172+ }
173173+174174+ return SetupMediaMocks(t, MockConfig{
175175+ SearchResults: searchResults,
176176+ TVSeriesResult: series,
177177+ HTMLResult: "ok",
178178+ })
179179+}
180180+181181+// SetupFailureMocks configures mocks that return errors
182182+func SetupFailureMocks(t *testing.T, errorMsg string) func() {
183183+ t.Helper()
184184+185185+ err := errors.New(errorMsg)
186186+ return SetupMediaMocks(t, MockConfig{
187187+ SearchError: err,
188188+ MovieError: err,
189189+ TVSeriesError: err,
190190+ TVSeasonError: err,
191191+ HTMLError: err,
192192+ })
193193+}
194194+195195+// AssertMovieInResults checks if a movie with the given title exists in results
196196+func AssertMovieInResults(t *testing.T, results []*models.Model, expectedTitle string) {
197197+ t.Helper()
198198+199199+ for _, result := range results {
200200+ if movie, ok := (*result).(*models.Movie); ok {
201201+ if strings.Contains(movie.Title, expectedTitle) {
202202+ return
203203+ }
204204+ }
205205+ }
206206+ t.Errorf("expected to find movie containing '%s' in results", expectedTitle)
207207+}
208208+209209+// AssertTVShowInResults checks if a TV show with the given title exists in results
210210+func AssertTVShowInResults(t *testing.T, results []*models.Model, expectedTitle string) {
211211+ t.Helper()
212212+213213+ for _, result := range results {
214214+ if show, ok := (*result).(*models.TVShow); ok {
215215+ if strings.Contains(show.Title, expectedTitle) {
216216+ return // Found it
217217+ }
218218+ }
219219+ }
220220+ t.Errorf("expected to find TV show containing '%s' in results", expectedTitle)
221221+}
222222+223223+// AssertErrorContains checks that an error contains the expected message
224224+func AssertErrorContains(t *testing.T, err error, expectedMsg string) {
225225+ t.Helper()
226226+227227+ if err == nil {
228228+ t.Fatalf("expected error containing '%s', got nil", expectedMsg)
229229+ }
230230+ if !strings.Contains(err.Error(), expectedMsg) {
231231+ t.Errorf("expected error to contain '%s', got '%v'", expectedMsg, err)
232232+ }
233233+}
234234+235235+// CreateMovieService returns a new movie service for testing
236236+func CreateMovieService() *MovieService {
237237+ return NewMovieService()
238238+}
239239+240240+// CreateTVService returns a new TV service for testing
241241+func CreateTVService() *TVService {
242242+ return NewTVService()
243243+}
244244+245245+// TestMovieSearch runs a standard movie search test
246246+func TestMovieSearch(t *testing.T, service *MovieService, query string, expectedTitleFragment string) {
247247+ t.Helper()
248248+249249+ results, err := service.Search(context.Background(), query, 1, 10)
250250+ if err != nil {
251251+ t.Fatalf("Search failed: %v", err)
252252+ }
253253+ if len(results) == 0 {
254254+ t.Fatal("expected search results, got none")
255255+ }
256256+257257+ AssertMovieInResults(t, results, expectedTitleFragment)
258258+}
259259+260260+// TestTVSearch runs a standard TV search test
261261+func TestTVSearch(t *testing.T, service *TVService, query string, expectedTitleFragment string) {
262262+ t.Helper()
263263+264264+ results, err := service.Search(context.Background(), query, 1, 10)
265265+ if err != nil {
266266+ t.Fatalf("Search failed: %v", err)
267267+ }
268268+ if len(results) == 0 {
269269+ t.Fatal("expected search results, got none")
270270+ }
271271+272272+ AssertTVShowInResults(t, results, expectedTitleFragment)
273273+}
-131
media.md
···11-# MEDIA management feature
22-33-## Current State Analysis
44-55-- Existing Media Service (`/internal/services/media.go`):
66- - Rotten Tomatoes scraping with colly for movies/TV search
77- - Rich metadata extraction (Movie, TVSeries, TVSeason structs)
88- - Search functionality via SearchRottenTomatoes()
99- - Detailed metadata fetching via FetchMovie(), FetchTVSeries(), FetchTVSeason()
1010-1111-- Book Search Pattern (`/internal/handlers/books.go`):
1212- - Uses APIService interface with `Search()`, `Get()`, `Check()`, `Close()` methods
1313- - Interactive and static search modes
1414- - Number-based selection UX
1515- - Converts API results to models.Book via interface
1616-1717-- Models (`/internal/models/models.go`):
1818- - Movie and TVShow structs already implement Model interface
1919- - Both have proper status tracking (queued, watched, etc.)
2020-2121-## Media Service Refactor
2222-2323-### Create MovieService that implement APIService (โ)
2424-2525-```go
2626-// MovieService implements APIService for Rotten Tomatoes movies
2727-type MovieService struct {
2828- client *http.Client
2929- limiter *rate.Limiter
3030-}
3131-```
3232-3333-### Create TVService that implement APIService (โ)
3434-3535-```go
3636-// TVService implements APIService for Rotten Tomatoes TV shows
3737-type TVService struct {
3838- client *http.Client
3939- limiter *rate.Limiter
4040-}
4141-```
4242-4343-### Implement APIService (โ)
4444-4545-- `Search(ctx, query, page, limit)` - Use existing SearchRottenTomatoes() and convert results to []*models.Model
4646-- `Get(ctx, id)` - Use existing FetchMovie() / FetchTVSeries() with Rotten Tomatoes URLs
4747-- `Check(ctx)` - Simple connectivity test to Rotten Tomatoes
4848-- `Close()` - Cleanup resources
4949-5050-### Result Conversion (โ)
5151-5252-- Convert services.Media search results to models.Movie / models.TVShow
5353-- Convert detailed metadata structs to models with proper status defaults
5454-- Extract key information (title, year, rating, description) into notes field
5555-5656-## Handler Implementation (โ)
5757-5858-### Create MovieHandler similar to BookHandler
5959-6060-```go
6161-type MovieHandler struct {
6262- db *store.Database
6363- config *store.Config
6464- repos *repo.Repositories
6565- service *services.MovieService
6666-}
6767-```
6868-6969-- `SearchAndAddMovie(ctx, args, interactive)` - Mirror book search UX
7070-- `SearchAndAddTV(ctx, args, interactive)` - Same pattern for TV shows
7171-- Number-based selection interface identical to books
7272-- Add movie/TV repositories if not already present
7373-- Ensure proper CRUD operations for queue management
7474-7575-## Commands
7676-7777-### Update definitions
7878-7979-- Replace stubbed movie commands with real implementations
8080-- Replace stubbed TV commands with real implementations
8181-- Connect to new handlers with proper error handling
8282-8383-### Structure
8484-8585-```sh
8686-# Movies
8787-8888-media movie add [search query...] [-i for interactive]
8989-media movie list [--all|--watched|--queued]
9090-media movie watched <id>
9191-media movie remove <id>
9292-9393-# TV Shows
9494-9595-media tv add [search query...] [-i for interactive]
9696-media tv list [--all|--watched|--queued]
9797-media tv watched <id>
9898-media tv remove <id>
9999-```
100100-101101-## UX Consistency
102102-103103-### Search
104104-105105-1. Parse search query from args
106106-2. Show "Loading..." progress indicator
107107-3. Display numbered results with title, year, rating
108108-4. Prompt for selection (1-N or 0 to cancel)
109109-5. Add selected item to queue with "queued" status
110110-6. Confirm addition to user
111111-112112-### Interactivity
113113-114114-- Use existing TUI patterns from book/task lists
115115-- Browse search results with keyboard navigation
116116-- Preview detailed metadata before adding
117117-118118-## Key Implementation Details
119119-120120-_Rate Limiting_: Add rate limiter to media services (Rotten Tomatoes likely has limits)
121121-122122-_Error Handling_: Robust handling of scraping failures, network issues, parsing errors
123123-124124-_Data Mapping_:
125125- - Map Rotten Tomatoes critic scores to model rating fields
126126- - Extract genres, cast, descriptions into notes field
127127- - Handle missing or incomplete metadata gracefully
128128-129129-_Caching_: Consider caching search results to reduce API calls during selection
130130-131131-_Status_: Default new items to "queued" status, provide commands to update