Skip to content

Commit aaaaa8e

Browse files
committed
feat: Add TV Shows export support
1 parent 28a7027 commit aaaaa8e

File tree

9 files changed

+491
-2
lines changed

9 files changed

+491
-2
lines changed

cmd/export_trakt/main.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +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)")
18+
exportType := flag.String("export", "watched", "Type of export (watched, collection, shows, all)")
1919
flag.Parse()
2020

2121
// Initialize logger
@@ -63,12 +63,15 @@ func main() {
6363
exportWatchedMovies(traktClient, letterboxdExporter, log)
6464
case "collection":
6565
exportCollection(traktClient, letterboxdExporter, log)
66+
case "shows":
67+
exportShows(traktClient, letterboxdExporter, log)
6668
case "all":
6769
exportWatchedMovies(traktClient, letterboxdExporter, log)
6870
exportCollection(traktClient, letterboxdExporter, log)
71+
exportShows(traktClient, letterboxdExporter, log)
6972
default:
7073
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)
74+
fmt.Printf("Invalid export type: %s. Valid types are 'watched', 'collection', 'shows', or 'all'\n", *exportType)
7275
os.Exit(1)
7376
}
7477

@@ -111,4 +114,34 @@ func exportCollection(client *api.Client, exporter *export.LetterboxdExporter, l
111114
log.Error("export.export_failed", map[string]interface{}{"error": err.Error()})
112115
os.Exit(1)
113116
}
117+
}
118+
119+
func exportShows(client *api.Client, exporter *export.LetterboxdExporter, log logger.Logger) {
120+
// Get watched shows
121+
log.Info("export.retrieving_watched_shows", nil)
122+
shows, err := client.GetWatchedShows()
123+
if err != nil {
124+
log.Error("errors.api_request_failed", map[string]interface{}{"error": err.Error()})
125+
os.Exit(1)
126+
}
127+
128+
// Count total episodes
129+
episodeCount := 0
130+
for _, show := range shows {
131+
for _, season := range show.Seasons {
132+
episodeCount += len(season.Episodes)
133+
}
134+
}
135+
136+
log.Info("export.shows_retrieved", map[string]interface{}{
137+
"shows": len(shows),
138+
"episodes": episodeCount,
139+
})
140+
141+
// Export shows
142+
log.Info("export.exporting_shows", nil)
143+
if err := exporter.ExportShows(shows); err != nil {
144+
log.Error("export.export_failed", map[string]interface{}{"error": err.Error()})
145+
os.Exit(1)
146+
}
114147
}

config/config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ api_base_url = "https://api.trakt.tv"
1010
export_dir = "exports"
1111
watched_filename = "trakt-watched-films.csv"
1212
collection_filename = "trakt-collection-films.csv"
13+
shows_filename = "trakt-shows.csv"
1314

1415
# Export Settings
1516
[export]

locales/en.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"app": {
3+
"name": "Export Trakt 4 Letterboxd",
4+
"description": "Export your Trakt.tv history to Letterboxd format"
5+
},
6+
"startup": {
7+
"loading_config": "Loading configuration from {{.path}}",
8+
"starting": "Starting Export Trakt 4 Letterboxd",
9+
"config_loaded": "Configuration loaded successfully"
10+
},
11+
"export": {
12+
"retrieving_movies": "Retrieving movies from Trakt.tv",
13+
"movies_retrieved": "Retrieved {{.count}} movies from Trakt.tv",
14+
"exporting_movies": "Exporting movies to Letterboxd format",
15+
"export_complete": "Successfully exported {{.count}} movies to {{.path}}",
16+
"export_failed": "Failed to export movies: {{.error}}",
17+
"retrieving_watched_movies": "Retrieving watched movies from Trakt.tv",
18+
"exporting_watched_movies": "Exporting watched movies to Letterboxd format",
19+
"retrieving_collection": "Retrieving collection movies from Trakt.tv",
20+
"collection_retrieved": "Retrieved {{.count}} movies from your Trakt.tv collection",
21+
"exporting_collection": "Exporting collection movies to Letterboxd format",
22+
"collection_export_complete": "Successfully exported {{.count}} collection movies to {{.path}}",
23+
"retrieving_watched_shows": "Retrieving watched shows from Trakt.tv",
24+
"shows_retrieved": "Retrieved {{.shows}} shows with {{.episodes}} episodes from Trakt.tv",
25+
"exporting_shows": "Exporting TV shows to CSV format",
26+
"shows_export_complete": "Successfully exported {{.shows}} shows with {{.episodes}} episodes to {{.path}}"
27+
},
28+
"errors": {
29+
"config_load_failed": "Failed to load configuration: {{.error}}",
30+
"api_request_failed": "API request failed: {{.error}}",
31+
"export_dir_create_failed": "Failed to create export directory: {{.error}}",
32+
"file_create_failed": "Failed to create file: {{.error}}",
33+
"log_file_failed": "Failed to set log file: {{.error}}",
34+
"translator_failed": "Failed to initialize translator: {{.error}}",
35+
"invalid_export_type": "Invalid export type: {{.type}}"
36+
}
37+
}

locales/fr.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"app": {
3+
"name": "Export Trakt 4 Letterboxd",
4+
"description": "Exportez votre historique Trakt.tv au format Letterboxd"
5+
},
6+
"startup": {
7+
"loading_config": "Chargement de la configuration depuis {{.path}}",
8+
"starting": "Démarrage de Export Trakt 4 Letterboxd",
9+
"config_loaded": "Configuration chargée avec succès"
10+
},
11+
"export": {
12+
"retrieving_movies": "Récupération des films depuis Trakt.tv",
13+
"movies_retrieved": "{{.count}} films récupérés depuis Trakt.tv",
14+
"exporting_movies": "Exportation des films au format Letterboxd",
15+
"export_complete": "{{.count}} films exportés avec succès vers {{.path}}",
16+
"export_failed": "Échec de l'exportation des films : {{.error}}",
17+
"retrieving_watched_movies": "Récupération des films vus depuis Trakt.tv",
18+
"exporting_watched_movies": "Exportation des films vus au format Letterboxd",
19+
"retrieving_collection": "Récupération des films de votre collection depuis Trakt.tv",
20+
"collection_retrieved": "{{.count}} films récupérés depuis votre collection Trakt.tv",
21+
"exporting_collection": "Exportation des films de votre collection au format Letterboxd",
22+
"collection_export_complete": "{{.count}} films de collection exportés avec succès vers {{.path}}",
23+
"retrieving_watched_shows": "Récupération des séries TV vues depuis Trakt.tv",
24+
"shows_retrieved": "{{.shows}} séries avec {{.episodes}} épisodes récupérés depuis Trakt.tv",
25+
"exporting_shows": "Exportation des séries TV au format CSV",
26+
"shows_export_complete": "{{.shows}} séries avec {{.episodes}} épisodes exportés avec succès vers {{.path}}"
27+
},
28+
"errors": {
29+
"config_load_failed": "Échec du chargement de la configuration : {{.error}}",
30+
"api_request_failed": "Échec de la requête API : {{.error}}",
31+
"export_dir_create_failed": "Échec de la création du répertoire d'exportation : {{.error}}",
32+
"file_create_failed": "Échec de la création du fichier : {{.error}}",
33+
"log_file_failed": "Échec de la définition du fichier journal : {{.error}}",
34+
"translator_failed": "Échec de l'initialisation du traducteur : {{.error}}",
35+
"invalid_export_type": "Type d'exportation invalide : {{.type}}"
36+
}
37+
}

pkg/api/trakt.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,50 @@ type CollectionMovie struct {
4343
CollectedAt string `json:"collected_at"`
4444
}
4545

46+
// ShowIDs represents the various IDs associated with a show
47+
type ShowIDs struct {
48+
Trakt int `json:"trakt"`
49+
TMDB int `json:"tmdb"`
50+
IMDB string `json:"imdb"`
51+
Slug string `json:"slug"`
52+
TVDB int `json:"tvdb"`
53+
}
54+
55+
// ShowInfo represents the basic show information
56+
type ShowInfo struct {
57+
Title string `json:"title"`
58+
Year int `json:"year"`
59+
IDs ShowIDs `json:"ids"`
60+
}
61+
62+
// EpisodeIDs represents the various IDs associated with an episode
63+
type EpisodeIDs struct {
64+
Trakt int `json:"trakt"`
65+
TMDB int `json:"tmdb"`
66+
TVDB int `json:"tvdb"`
67+
}
68+
69+
// EpisodeInfo represents the basic episode information
70+
type EpisodeInfo struct {
71+
Season int `json:"season"`
72+
Number int `json:"number"`
73+
Title string `json:"title"`
74+
IDs EpisodeIDs `json:"ids"`
75+
}
76+
77+
// WatchedShow represents a watched show with its metadata
78+
type WatchedShow struct {
79+
Show ShowInfo `json:"show"`
80+
Seasons []ShowSeason `json:"seasons"`
81+
LastWatchedAt string `json:"last_watched_at"`
82+
}
83+
84+
// ShowSeason represents a season of a show
85+
type ShowSeason struct {
86+
Number int `json:"number"`
87+
Episodes []EpisodeInfo `json:"episodes"`
88+
}
89+
4690
// Client represents a Trakt API client
4791
type Client struct {
4892
config *config.Config
@@ -213,4 +257,67 @@ func (c *Client) GetCollectionMovies() ([]CollectionMovie, error) {
213257
"count": len(movies),
214258
})
215259
return movies, nil
260+
}
261+
262+
// GetWatchedShows retrieves the list of watched shows from Trakt
263+
func (c *Client) GetWatchedShows() ([]WatchedShow, error) {
264+
req, err := http.NewRequest("GET", c.config.Trakt.APIBaseURL+"/sync/watched/shows", nil)
265+
if err != nil {
266+
c.logger.Error("errors.api_request_failed", map[string]interface{}{
267+
"error": err.Error(),
268+
})
269+
return nil, fmt.Errorf("failed to create request: %w", err)
270+
}
271+
272+
// Add required headers
273+
req.Header.Set("Content-Type", "application/json")
274+
req.Header.Set("trakt-api-version", "2")
275+
req.Header.Set("trakt-api-key", c.config.Trakt.ClientID)
276+
req.Header.Set("Authorization", "Bearer "+c.config.Trakt.AccessToken)
277+
278+
resp, err := c.makeRequest(req)
279+
if err != nil {
280+
c.logger.Error("errors.api_request_failed", map[string]interface{}{
281+
"error": err.Error(),
282+
})
283+
return nil, fmt.Errorf("failed to execute request: %w", err)
284+
}
285+
defer resp.Body.Close()
286+
287+
// Handle rate limiting
288+
if limit := resp.Header.Get("X-Ratelimit-Remaining"); limit != "" {
289+
remaining, _ := strconv.Atoi(limit)
290+
if remaining < 100 {
291+
c.logger.Warn("api.rate_limit_warning", map[string]interface{}{
292+
"remaining": remaining,
293+
})
294+
}
295+
}
296+
297+
// Check response status
298+
if resp.StatusCode != http.StatusOK {
299+
var errorResp map[string]string
300+
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
301+
errorResp = map[string]string{"error": "unknown error"}
302+
}
303+
c.logger.Error("errors.api_request_failed", map[string]interface{}{
304+
"status": resp.StatusCode,
305+
"error": errorResp["error"],
306+
})
307+
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, errorResp["error"])
308+
}
309+
310+
// Parse response
311+
var shows []WatchedShow
312+
if err := json.NewDecoder(resp.Body).Decode(&shows); err != nil {
313+
c.logger.Error("errors.api_response_parse_failed", map[string]interface{}{
314+
"error": err.Error(),
315+
})
316+
return nil, fmt.Errorf("failed to parse response: %w", err)
317+
}
318+
319+
c.logger.Info("api.watched_shows_fetched", map[string]interface{}{
320+
"count": len(shows),
321+
})
322+
return shows, nil
216323
}

pkg/api/trakt_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,4 +490,86 @@ func TestGetCollectionMoviesError(t *testing.T) {
490490
assert.Error(t, err)
491491
assert.Contains(t, err.Error(), "API request failed with status 401")
492492
assert.Nil(t, movies)
493+
}
494+
495+
func TestGetWatchedShows(t *testing.T) {
496+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
497+
w.Header().Set("Content-Type", "application/json")
498+
w.Header().Set("X-Ratelimit-Remaining", "120")
499+
500+
// Check if valid authorization header is present
501+
authHeader := r.Header.Get("Authorization")
502+
if authHeader != "Bearer test-token" {
503+
w.WriteHeader(http.StatusUnauthorized)
504+
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid token"})
505+
return
506+
}
507+
508+
// Return successful response
509+
w.WriteHeader(http.StatusOK)
510+
fmt.Fprintf(w, `[
511+
{
512+
"show": {
513+
"title": "Game of Thrones",
514+
"year": 2011,
515+
"ids": {
516+
"trakt": 1,
517+
"slug": "game-of-thrones",
518+
"tvdb": 121361,
519+
"imdb": "tt0944947",
520+
"tmdb": 1399
521+
}
522+
},
523+
"seasons": [
524+
{
525+
"number": 1,
526+
"episodes": [
527+
{
528+
"number": 1,
529+
"title": "Winter Is Coming",
530+
"ids": {
531+
"trakt": 73640,
532+
"tvdb": 3254641,
533+
"imdb": "tt1480055",
534+
"tmdb": 63056
535+
}
536+
}
537+
]
538+
}
539+
],
540+
"last_watched_at": "2022-01-01T12:00:00Z"
541+
}
542+
]`)
543+
}))
544+
defer mockServer.Close()
545+
546+
// Create a test config
547+
cfg := &config.Config{
548+
Trakt: config.TraktConfig{
549+
ClientID: "test-client-id",
550+
ClientSecret: "test-client-secret",
551+
AccessToken: "test-token",
552+
APIBaseURL: mockServer.URL,
553+
},
554+
}
555+
556+
// Create a test logger
557+
log := &MockLogger{}
558+
559+
// Create a client with the mock server
560+
client := NewClient(cfg, log)
561+
562+
// Test the GetWatchedShows method
563+
shows, err := client.GetWatchedShows()
564+
assert.NoError(t, err)
565+
assert.NotNil(t, shows)
566+
assert.Len(t, shows, 1)
567+
assert.Equal(t, "Game of Thrones", shows[0].Show.Title)
568+
assert.Equal(t, 2011, shows[0].Show.Year)
569+
assert.Equal(t, "tt0944947", shows[0].Show.IDs.IMDB)
570+
assert.Len(t, shows[0].Seasons, 1)
571+
assert.Equal(t, 1, shows[0].Seasons[0].Number)
572+
assert.Len(t, shows[0].Seasons[0].Episodes, 1)
573+
assert.Equal(t, 1, shows[0].Seasons[0].Episodes[0].Number)
574+
assert.Equal(t, "Winter Is Coming", shows[0].Seasons[0].Episodes[0].Title)
493575
}

pkg/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type LetterboxdConfig struct {
2727
ExportDir string `toml:"export_dir"`
2828
WatchedFilename string `toml:"watched_filename"`
2929
CollectionFilename string `toml:"collection_filename"`
30+
ShowsFilename string `toml:"shows_filename"`
3031
}
3132

3233
// ExportConfig holds export settings

0 commit comments

Comments
 (0)