Skip to content

Commit 28a7027

Browse files
committed
Add support for exporting movie collections - Add GetCollectionMovies API function to retrieve user collections - Add ExportCollectionMovies function to export collections to CSV - Update configuration to support collection-specific settings - Add tests for collection export functionality
1 parent 942b5c6 commit 28a7027

File tree

7 files changed

+391
-10
lines changed

7 files changed

+391
-10
lines changed

cmd/export_trakt/main.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
func main() {
1616
// Parse command line flags
1717
configPath := flag.String("config", "config/config.toml", "Path to configuration file")
18+
exportType := flag.String("export", "watched", "Type of export (watched, collection, all)")
1819
flag.Parse()
1920

2021
// Initialize logger
@@ -53,25 +54,61 @@ func main() {
5354
// Initialize Trakt client
5455
traktClient := api.NewClient(cfg, log)
5556

57+
// Initialize Letterboxd exporter
58+
letterboxdExporter := export.NewLetterboxdExporter(cfg, log)
59+
60+
// Perform the export based on type
61+
switch *exportType {
62+
case "watched":
63+
exportWatchedMovies(traktClient, letterboxdExporter, log)
64+
case "collection":
65+
exportCollection(traktClient, letterboxdExporter, log)
66+
case "all":
67+
exportWatchedMovies(traktClient, letterboxdExporter, log)
68+
exportCollection(traktClient, letterboxdExporter, log)
69+
default:
70+
log.Error("errors.invalid_export_type", map[string]interface{}{"type": *exportType})
71+
fmt.Printf("Invalid export type: %s. Valid types are 'watched', 'collection', or 'all'\n", *exportType)
72+
os.Exit(1)
73+
}
74+
75+
fmt.Println(translator.Translate("app.description", nil))
76+
}
77+
78+
func exportWatchedMovies(client *api.Client, exporter *export.LetterboxdExporter, log logger.Logger) {
5679
// Get watched movies
57-
log.Info("export.retrieving_movies", nil)
58-
movies, err := traktClient.GetWatchedMovies()
80+
log.Info("export.retrieving_watched_movies", nil)
81+
movies, err := client.GetWatchedMovies()
5982
if err != nil {
6083
log.Error("errors.api_request_failed", map[string]interface{}{"error": err.Error()})
6184
os.Exit(1)
6285
}
6386

6487
log.Info("export.movies_retrieved", map[string]interface{}{"count": len(movies)})
6588

66-
// Initialize Letterboxd exporter
67-
letterboxdExporter := export.NewLetterboxdExporter(cfg, log)
68-
6989
// Export movies
70-
log.Info("export.exporting_movies", nil)
71-
if err := letterboxdExporter.ExportMovies(movies); err != nil {
90+
log.Info("export.exporting_watched_movies", nil)
91+
if err := exporter.ExportMovies(movies); err != nil {
7292
log.Error("export.export_failed", map[string]interface{}{"error": err.Error()})
7393
os.Exit(1)
7494
}
95+
}
7596

76-
fmt.Println(translator.Translate("app.description", nil))
97+
func exportCollection(client *api.Client, exporter *export.LetterboxdExporter, log logger.Logger) {
98+
// Get collection movies
99+
log.Info("export.retrieving_collection", nil)
100+
movies, err := client.GetCollectionMovies()
101+
if err != nil {
102+
log.Error("errors.api_request_failed", map[string]interface{}{"error": err.Error()})
103+
os.Exit(1)
104+
}
105+
106+
log.Info("export.collection_retrieved", map[string]interface{}{"count": len(movies)})
107+
108+
// Export collection
109+
log.Info("export.exporting_collection", nil)
110+
if err := exporter.ExportCollectionMovies(movies); err != nil {
111+
log.Error("export.export_failed", map[string]interface{}{"error": err.Error()})
112+
os.Exit(1)
113+
}
77114
}

config/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ api_base_url = "https://api.trakt.tv"
88
# Letterboxd Export Configuration
99
[letterboxd]
1010
export_dir = "exports"
11+
watched_filename = "trakt-watched-films.csv"
12+
collection_filename = "trakt-collection-films.csv"
1113

1214
# Export Settings
1315
[export]

pkg/api/trakt.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ type Movie struct {
3737
LastWatchedAt string `json:"last_watched_at"`
3838
}
3939

40+
// CollectionMovie represents a movie in a collection
41+
type CollectionMovie struct {
42+
Movie MovieInfo `json:"movie"`
43+
CollectedAt string `json:"collected_at"`
44+
}
45+
4046
// Client represents a Trakt API client
4147
type Client struct {
4248
config *config.Config
@@ -143,5 +149,68 @@ func (c *Client) GetWatchedMovies() ([]Movie, error) {
143149
return nil, fmt.Errorf("failed to parse response: %w", err)
144150
}
145151

152+
return movies, nil
153+
}
154+
155+
// GetCollectionMovies retrieves the list of movies in the user's collection from Trakt
156+
func (c *Client) GetCollectionMovies() ([]CollectionMovie, error) {
157+
req, err := http.NewRequest("GET", c.config.Trakt.APIBaseURL+"/sync/collection/movies", nil)
158+
if err != nil {
159+
c.logger.Error("errors.api_request_failed", map[string]interface{}{
160+
"error": err.Error(),
161+
})
162+
return nil, fmt.Errorf("failed to create request: %w", err)
163+
}
164+
165+
// Add required headers
166+
req.Header.Set("Content-Type", "application/json")
167+
req.Header.Set("trakt-api-version", "2")
168+
req.Header.Set("trakt-api-key", c.config.Trakt.ClientID)
169+
req.Header.Set("Authorization", "Bearer "+c.config.Trakt.AccessToken)
170+
171+
resp, err := c.makeRequest(req)
172+
if err != nil {
173+
c.logger.Error("errors.api_request_failed", map[string]interface{}{
174+
"error": err.Error(),
175+
})
176+
return nil, fmt.Errorf("failed to execute request: %w", err)
177+
}
178+
defer resp.Body.Close()
179+
180+
// Handle rate limiting
181+
if limit := resp.Header.Get("X-Ratelimit-Remaining"); limit != "" {
182+
remaining, _ := strconv.Atoi(limit)
183+
if remaining < 100 {
184+
c.logger.Warn("api.rate_limit_warning", map[string]interface{}{
185+
"remaining": remaining,
186+
})
187+
}
188+
}
189+
190+
// Check response status
191+
if resp.StatusCode != http.StatusOK {
192+
var errorResp map[string]string
193+
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
194+
errorResp = map[string]string{"error": "unknown error"}
195+
}
196+
c.logger.Error("errors.api_request_failed", map[string]interface{}{
197+
"status": resp.StatusCode,
198+
"error": errorResp["error"],
199+
})
200+
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, errorResp["error"])
201+
}
202+
203+
// Parse response
204+
var movies []CollectionMovie
205+
if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil {
206+
c.logger.Error("errors.api_response_parse_failed", map[string]interface{}{
207+
"error": err.Error(),
208+
})
209+
return nil, fmt.Errorf("failed to parse response: %w", err)
210+
}
211+
212+
c.logger.Info("api.collection_movies_fetched", map[string]interface{}{
213+
"count": len(movies),
214+
})
146215
return movies, nil
147216
}

pkg/api/trakt_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/JohanDevl/Export_Trakt_4_Letterboxd/pkg/config"
1212
"github.com/JohanDevl/Export_Trakt_4_Letterboxd/pkg/logger"
13+
"github.com/stretchr/testify/assert"
1314
)
1415

1516
// MockLogger implements the logger.Logger interface for testing
@@ -377,4 +378,116 @@ func TestResponseParsing(t *testing.T) {
377378
tc.validate(t, movies)
378379
})
379380
}
381+
}
382+
383+
func TestGetCollectionMovies(t *testing.T) {
384+
// Set up mock HTTP server
385+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
386+
// Check request method and path
387+
assert.Equal(t, "GET", r.Method)
388+
assert.Equal(t, "/sync/collection/movies", r.URL.Path)
389+
390+
// Check required headers
391+
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
392+
assert.Equal(t, "2", r.Header.Get("trakt-api-version"))
393+
assert.Equal(t, "test-client-id", r.Header.Get("trakt-api-key"))
394+
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
395+
396+
// Set rate limiting headers
397+
w.Header().Set("X-Ratelimit-Remaining", "150")
398+
399+
// Return mock response
400+
w.WriteHeader(http.StatusOK)
401+
w.Write([]byte(`[
402+
{
403+
"movie": {
404+
"title": "The Dark Knight",
405+
"year": 2008,
406+
"ids": {
407+
"trakt": 16,
408+
"slug": "the-dark-knight-2008",
409+
"imdb": "tt0468569",
410+
"tmdb": 155
411+
}
412+
},
413+
"collected_at": "2023-01-15T23:40:30.000Z"
414+
},
415+
{
416+
"movie": {
417+
"title": "Inception",
418+
"year": 2010,
419+
"ids": {
420+
"trakt": 417,
421+
"slug": "inception-2010",
422+
"imdb": "tt1375666",
423+
"tmdb": 27205
424+
}
425+
},
426+
"collected_at": "2023-03-20T18:25:43.000Z"
427+
}
428+
]`))
429+
}))
430+
defer mockServer.Close()
431+
432+
// Create client with mock server URL
433+
mockConfig := &config.Config{
434+
Trakt: config.TraktConfig{
435+
ClientID: "test-client-id",
436+
AccessToken: "test-token",
437+
APIBaseURL: mockServer.URL,
438+
},
439+
}
440+
mockLogger := &MockLogger{}
441+
client := NewClient(mockConfig, mockLogger)
442+
443+
// Call the method to test
444+
movies, err := client.GetCollectionMovies()
445+
446+
// Assert no error
447+
assert.NoError(t, err)
448+
449+
// Assert movies were correctly parsed
450+
assert.Equal(t, 2, len(movies))
451+
452+
// Assert first movie details
453+
assert.Equal(t, "The Dark Knight", movies[0].Movie.Title)
454+
assert.Equal(t, 2008, movies[0].Movie.Year)
455+
assert.Equal(t, 16, movies[0].Movie.IDs.Trakt)
456+
assert.Equal(t, "tt0468569", movies[0].Movie.IDs.IMDB)
457+
assert.Equal(t, "2023-01-15T23:40:30.000Z", movies[0].CollectedAt)
458+
459+
// Assert second movie details
460+
assert.Equal(t, "Inception", movies[1].Movie.Title)
461+
assert.Equal(t, 2010, movies[1].Movie.Year)
462+
assert.Equal(t, 417, movies[1].Movie.IDs.Trakt)
463+
assert.Equal(t, "tt1375666", movies[1].Movie.IDs.IMDB)
464+
assert.Equal(t, "2023-03-20T18:25:43.000Z", movies[1].CollectedAt)
465+
}
466+
467+
func TestGetCollectionMoviesError(t *testing.T) {
468+
// Set up mock server that returns an error
469+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
470+
w.WriteHeader(http.StatusUnauthorized)
471+
w.Write([]byte(`{"error": "Invalid OAuth token"}`))
472+
}))
473+
defer mockServer.Close()
474+
475+
// Create client with mock server URL
476+
mockConfig := &config.Config{
477+
Trakt: config.TraktConfig{
478+
ClientID: "test-client-id",
479+
AccessToken: "invalid-token",
480+
APIBaseURL: mockServer.URL,
481+
},
482+
}
483+
mockLogger := &MockLogger{}
484+
client := NewClient(mockConfig, mockLogger)
485+
486+
// Call the method to test
487+
movies, err := client.GetCollectionMovies()
488+
489+
// Assert error
490+
assert.Error(t, err)
491+
assert.Contains(t, err.Error(), "API request failed with status 401")
492+
assert.Nil(t, movies)
380493
}

pkg/config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ type TraktConfig struct {
2424

2525
// LetterboxdConfig holds Letterboxd export configuration
2626
type LetterboxdConfig struct {
27-
ExportDir string `toml:"export_dir"`
27+
ExportDir string `toml:"export_dir"`
28+
WatchedFilename string `toml:"watched_filename"`
29+
CollectionFilename string `toml:"collection_filename"`
2830
}
2931

3032
// ExportConfig holds export settings

pkg/export/letterboxd.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ func (e *LetterboxdExporter) ExportMovies(movies []api.Movie) error {
3636
return fmt.Errorf("failed to create export directory: %w", err)
3737
}
3838

39-
filename := fmt.Sprintf("letterboxd-export-%s.csv", time.Now().Format("2006-01-02"))
39+
// Use configured filename, or generate one with timestamp if not specified
40+
var filename string
41+
if e.config.Letterboxd.WatchedFilename != "" {
42+
filename = e.config.Letterboxd.WatchedFilename
43+
} else {
44+
filename = fmt.Sprintf("letterboxd-export-%s.csv", time.Now().Format("2006-01-02"))
45+
}
4046
filePath := filepath.Join(e.config.Letterboxd.ExportDir, filename)
4147

4248
file, err := os.Create(filePath)
@@ -85,4 +91,69 @@ func (e *LetterboxdExporter) ExportMovies(movies []api.Movie) error {
8591
"path": filePath,
8692
})
8793
return nil
94+
}
95+
96+
// ExportCollectionMovies exports the user's movie collection to a CSV file in Letterboxd format
97+
func (e *LetterboxdExporter) ExportCollectionMovies(movies []api.CollectionMovie) error {
98+
if err := os.MkdirAll(e.config.Letterboxd.ExportDir, 0755); err != nil {
99+
e.log.Error("errors.export_dir_create_failed", map[string]interface{}{
100+
"error": err.Error(),
101+
})
102+
return fmt.Errorf("failed to create export directory: %w", err)
103+
}
104+
105+
// Use configured filename, or generate one with timestamp if not specified
106+
var filename string
107+
if e.config.Letterboxd.CollectionFilename != "" {
108+
filename = e.config.Letterboxd.CollectionFilename
109+
} else {
110+
filename = fmt.Sprintf("collection-export-%s.csv", time.Now().Format("2006-01-02"))
111+
}
112+
filePath := filepath.Join(e.config.Letterboxd.ExportDir, filename)
113+
114+
file, err := os.Create(filePath)
115+
if err != nil {
116+
e.log.Error("errors.file_create_failed", map[string]interface{}{
117+
"error": err.Error(),
118+
})
119+
return fmt.Errorf("failed to create export file: %w", err)
120+
}
121+
defer file.Close()
122+
123+
writer := csv.NewWriter(file)
124+
defer writer.Flush()
125+
126+
// Write header
127+
header := []string{"Title", "Year", "CollectedDate", "IMDb ID"}
128+
if err := writer.Write(header); err != nil {
129+
return fmt.Errorf("failed to write header: %w", err)
130+
}
131+
132+
// Write movies
133+
for _, movie := range movies {
134+
// Parse collected date
135+
collectedDate := ""
136+
if movie.CollectedAt != "" {
137+
if parsedTime, err := time.Parse(time.RFC3339, movie.CollectedAt); err == nil {
138+
collectedDate = parsedTime.Format(e.config.Export.DateFormat)
139+
}
140+
}
141+
142+
record := []string{
143+
movie.Movie.Title,
144+
strconv.Itoa(movie.Movie.Year),
145+
collectedDate,
146+
movie.Movie.IDs.IMDB,
147+
}
148+
149+
if err := writer.Write(record); err != nil {
150+
return fmt.Errorf("failed to write movie record: %w", err)
151+
}
152+
}
153+
154+
e.log.Info("export.collection_export_complete", map[string]interface{}{
155+
"count": len(movies),
156+
"path": filePath,
157+
})
158+
return nil
88159
}

0 commit comments

Comments
 (0)