Files
Watcharr/server/import_trakt.go
IRHM 451a5923d4 rating now float64
Seems to actually be accurate for what we need, I think we should just use float64 instead of importing a library for decimal values (if it continues working and i find no more issues)
2024-09-05 16:18:36 +01:00

457 lines
15 KiB
Go

// Trakt.tv importer.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"gorm.io/gorm"
)
type TraktImportRequest struct {
// Username of public trakt user to import from.
Username string `json:"username" binding:"required"`
}
type TraktUser struct {
Username string `json:"username"`
Private bool `json:"private"`
IDs struct {
Slug string `json:"slug"`
} `json:"ids"`
}
type TraktHistory struct {
ID int64 `json:"id"`
WatchedAt time.Time `json:"watched_at"`
Action string `json:"action"`
Type string `json:"type"`
Show TraktListShow `json:"show,omitempty"`
Episode TraktListEpisode `json:"episode,omitempty"`
Movie TraktListMovie `json:"movie,omitempty"`
}
type TraktWatchlist []struct {
Rank int `json:"rank"`
ID int `json:"id"`
ListedAt time.Time `json:"listed_at"`
Notes string `json:"notes"`
Type string `json:"type"`
Show TraktListShow `json:"show,omitempty"`
Episode TraktListEpisode `json:"episode,omitempty"`
Movie TraktListMovie `json:"movie,omitempty"`
}
type TraktRatings []struct {
Rating int `json:"rating"`
Type string `json:"type"`
Show TraktListShow `json:"show,omitempty"`
Episode TraktListEpisode `json:"episode,omitempty"`
Movie TraktListMovie `json:"movie,omitempty"`
}
type TraktListShow struct {
Title string `json:"title"`
Year int `json:"year"`
Ids struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
Tmdb int `json:"tmdb"`
} `json:"ids"`
}
type TraktListEpisode struct {
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
Ids struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
Tmdb int `json:"tmdb"`
} `json:"ids"`
}
type TraktListMovie struct {
Title string `json:"title"`
Year int `json:"year"`
Ids struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
Tmdb int `json:"tmdb"`
} `json:"ids"`
}
type TraktImportResponse struct {
JobId string `json:"jobId"`
}
// TODO we could support trakt list imports when we support a similar feature (tags will function as custom lists when done #199)
func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername string) {
// Get trakt user. We want to get their profile `slug` for use in
// next requests and we can check their profile isn't private while here.
var traktUser TraktUser
_, err := traktAPIRequest("users/"+traktUsername, map[string]string{}, &traktUser)
if err != nil {
slog.Error("startTraktImport: Failed to get users profile", "error", err, "trakt_user", traktUser)
addJobError(jobId, userId, "failed to request trakt profile from api")
updateJobStatus(jobId, userId, JOB_CANCELLED)
return
}
if traktUser.Private {
slog.Error("startTraktImport: Users profile is private. Cannot continue with import.")
addJobError(jobId, userId, "trakt profile is private")
updateJobStatus(jobId, userId, JOB_CANCELLED)
return
}
userSlug := traktUser.IDs.Slug
// Everything will be added to this map for importing at the end.
toImport := map[string]ImportRequest{}
// Process all history for this user (in chunks of 1000).
var history []TraktHistory
slog.Debug("startTraktImport: Getting first history page")
historyHeaders, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000"}, &history)
if err != nil {
// FATAL if we can't get the users history, we probably shouldn't continue (to ratings/watchlist below).
slog.Error("startTraktImport: Failed to get users history", "error", err)
addJobError(jobId, userId, "failed to get your history")
return
} else {
pageCount := historyHeaders.Get("x-pagination-page-count")
slog.Debug("startTraktImport: Got first history page", "page_count", pageCount)
if pageCount == "" {
slog.Error("startTraktImport: Failed to get history page count!", "page_count", pageCount)
addJobError(jobId, userId, "Failed to get history page count")
return
}
pageCountNum, err := strconv.Atoi(pageCount)
if err != nil {
slog.Error("startTraktImport: Failed to parse history page count into an int!", "error", err)
addJobError(jobId, userId, "Failed to parse history page count: "+pageCount)
return
}
rProc := func(v TraktHistory) {
var collectingText string
if v.Type == "episode" {
collectingText = fmt.Sprintf("%s S%dE%d", v.Show.Title, v.Episode.Season, v.Episode.Number)
} else if v.Type == "show" {
collectingText = v.Show.Title
} else if v.Type == "movie" {
collectingText = v.Movie.Title
}
if collectingText != "" {
updateJobCurrentTask(jobId, userId, "collecting "+collectingText)
}
err = processTraktHistoryItem(v, toImport)
if err != nil {
addJobError(jobId, userId, err.Error())
}
}
// Process first page of history (next pages processed below)
for _, v := range history {
rProc(v)
}
for i := range pageCountNum {
slog.Debug("startTraktImport: Getting history page", "page_num", i)
_, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000", "page": strconv.Itoa(i)}, &history)
if err != nil {
slog.Error("startTraktImport: Failed to get a history page", "page_num", i, "error", err)
addJobError(jobId, userId, "Failed to get history page: "+strconv.Itoa(i))
} else {
for _, v := range history {
rProc(v)
}
}
}
slog.Info("startTraktImport: Finished processing all history")
history = nil // clear whatever is lingering in the history slice
}
// Get watchlist for PLANNED items
slog.Info("startTraktImport: Getting whole watchlist")
var watchlist TraktWatchlist
_, err = traktAPIRequest("users/"+userSlug+"/watchlist", map[string]string{}, &watchlist)
if err != nil {
slog.Error("startTraktImport: Failed to get users watchlist! Cannot import planned content.", "error", err)
addJobError(jobId, userId, "failed to get your watchlist (planned items cannot be imported)")
} else {
slog.Debug("startTraktImport: Successfully got whole watchlist")
for _, v := range watchlist {
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
var (
title string
contentType ContentType
tmdbId int
)
if v.Type == "show" || v.Type == "episode" {
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
contentType = SHOW
if v.Type == "episode" {
title = v.Episode.Title
}
} else if v.Type == "movie" {
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = MOVIE
}
updateJobCurrentTask(jobId, userId, "setting status for "+title)
mapKey := makeTraktMapKey(contentType, tmdbId)
if mv, ok := toImport[mapKey]; ok {
// If item already exists in toImport, set its status to planned.
if v.Type == "episode" {
// For episode entries, we have to find the WatchedEpisode to set its status to planned.
weFound := false
for i, we := range mv.WatchedEpisodes {
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
we.Status = PLANNED
mv.WatchedEpisodes[i] = we
weFound = true
break
}
}
if !weFound {
mv.WatchedEpisodes = append(mv.WatchedEpisodes, WatchedEpisode{
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: PLANNED,
GormModel: GormModel{
CreatedAt: v.ListedAt,
},
})
}
toImport[mapKey] = mv
} else {
mv.Status = PLANNED
if v.Notes != "" {
// episodes dont support notes in watcharr
mv.Thoughts = v.Notes
}
toImport[mapKey] = mv
}
} else {
// If the item does not exist in toImport, create it and set it to planned.
ti := ImportRequest{
Type: contentType,
TmdbID: tmdbId,
Status: PLANNED,
}
if v.Type == "episode" {
ti.WatchedEpisodes = []WatchedEpisode{{
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: PLANNED,
GormModel: GormModel{
CreatedAt: v.ListedAt,
},
}}
} else {
// episodes dont support notes in watcharr
ti.Thoughts = v.Notes
}
toImport[mapKey] = ti
}
}
}
// Process ratings
slog.Info("startTraktImport: Getting all ratings")
var ratings TraktRatings
_, err = traktAPIRequest("users/"+userSlug+"/ratings", map[string]string{}, &ratings)
if err != nil {
slog.Error("startTraktImport: Failed to get users ratings!", "error", err)
addJobError(jobId, userId, "failed to get your ratings (content ratings cannot be imported)")
} else {
slog.Debug("startTraktImport: Successfully got all ratings")
for _, v := range ratings {
slog.Debug("startTraktImport: Processing rating item", "item", v)
var (
title string
contentType ContentType
tmdbId int
traktSlug string
)
if v.Type == "show" || v.Type == "episode" {
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
traktSlug = v.Show.Ids.Slug
contentType = SHOW
if v.Type == "episode" {
title = v.Episode.Title
traktSlug = v.Episode.Ids.Slug
}
} else if v.Type == "movie" {
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = MOVIE
traktSlug = v.Movie.Ids.Slug
}
updateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
mapKey := makeTraktMapKey(contentType, tmdbId)
if mv, ok := toImport[mapKey]; ok {
if v.Type == "episode" {
// For episode entries, we have to find the WatchedEpisode to set its rating.
epFound := false
for i, we := range mv.WatchedEpisodes {
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
we.Rating = int8(v.Rating)
mv.WatchedEpisodes[i] = we
epFound = true
break
}
}
toImport[mapKey] = mv
if !epFound {
addJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
}
} else {
mv.Rating = float64(v.Rating)
toImport[mapKey] = mv
}
} else {
// Item should be in toImport by now (from history or watchlist) if it has a rating, otherwise we won't import it
addJobError(jobId, userId, fmt.Sprintf("cannot import rating of %d for %s. The main content does not exist in your history or watchlist. type: %s traktSlug: %s", v.Rating, title, v.Type, traktSlug))
}
}
}
// Loop over `toImport` and finally import everything.
for _, v := range toImport {
_, err := importContent(db, userId, v)
if err != nil {
slog.Error("startTraktImport: Failed to do import on content!", "error", err, "import_obj", v)
addJobError(jobId, userId, fmt.Sprintf("Failed to import %s as %s. tmdbId: %d", v.Type, v.Status, v.TmdbID))
}
}
// We are donezo
updateJobStatus(jobId, userId, JOB_DONE)
}
func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest) error {
var (
title string
traktId int
tmdbId int
contentType ContentType
watchedEpisode WatchedEpisode
)
if v.Type == "show" || v.Type == "episode" {
title = v.Show.Title
traktId = v.Show.Ids.Trakt
tmdbId = v.Show.Ids.Tmdb
contentType = SHOW
if v.Type == "episode" {
traktId = v.Episode.Ids.Trakt
watchedEpisode = WatchedEpisode{
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: FINISHED,
// Rating: ,
GormModel: GormModel{
CreatedAt: v.WatchedAt,
},
}
slog.Debug("processTraktHistoryItem: Processing an episode.", "showTitle", title, "season", v.Episode.Season, "episode", v.Episode.Number)
} else {
slog.Debug("processTraktHistoryItem: Processing a show.", "contentTitle", title, "contentTmdbId", tmdbId)
}
} else if v.Type == "movie" {
title = v.Movie.Title
traktId = v.Movie.Ids.Trakt
tmdbId = v.Movie.Ids.Tmdb
contentType = MOVIE
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
}
if tmdbId == 0 {
slog.Debug("processTraktHistoryItem: Item had no tmdbId. Cannot process.")
return errors.New("Failed to process history: " + title + " type:" + v.Type + " trakt id:" + strconv.Itoa(traktId) + " tmdb id:" + strconv.Itoa(tmdbId) + " error:" + "item had no tmdb id")
}
mapKey := makeTraktMapKey(contentType, tmdbId)
if e, ok := toImport[mapKey]; ok {
e.WatchedEpisodes = append(toImport[mapKey].WatchedEpisodes, watchedEpisode)
toImport[mapKey] = e
} else {
toImport[mapKey] = ImportRequest{
Type: contentType,
TmdbID: tmdbId,
Status: FINISHED,
DatesWatched: []time.Time{v.WatchedAt},
WatchedEpisodes: []WatchedEpisode{watchedEpisode},
}
}
return nil
}
// `tmdbId` is for the movie or show (not for episodes).
func makeTraktMapKey(ct ContentType, tmdbId int) string {
return string(ct) + strconv.Itoa(tmdbId)
}
func traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Header, error) {
base, err := url.Parse("https://api.trakt.tv")
if err != nil {
return map[string][]string{}, errors.New("failed to parse api uri")
}
base.Path += ep
if len(p) > 0 {
params := url.Values{}
for k, v := range p {
params.Add(k, v)
}
base.RawQuery = params.Encode()
}
slog.Debug("traktAPIRequest", "request_url", base.String())
req, err := http.NewRequest("GET", base.String(), nil)
if err != nil {
return map[string][]string{}, err
}
req.Header.Add("trakt-api-key", "c481cb044dcd58d83f3fde113741d1e28d19c1bef1bcbfcb9acedee222f3a673")
req.Header.Add("trakt-api-version", "2")
req.Header.Add("Content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return map[string][]string{}, err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return map[string][]string{}, err
}
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
slog.Error("traktAPIRequest: non 2xx status code:", "status_code", res.StatusCode)
return map[string][]string{}, errors.New("non success status code")
}
err = json.Unmarshal([]byte(body), &resp)
if err != nil {
return map[string][]string{}, err
}
return res.Header, nil
}
func traktImportWatched(
db *gorm.DB,
userId uint,
traktUsername string,
) (TraktImportResponse, error) {
jobId, err := addUniqueJob("trakt_import", userId)
if err != nil {
slog.Error("traktSyncWatched: Failed to create a job", "error", err)
return TraktImportResponse{}, err
}
updateJobStatus(jobId, userId, JOB_RUNNING)
go startTraktImport(
db,
jobId,
userId,
traktUsername,
)
return TraktImportResponse{JobId: jobId}, nil
}