import: Support games importing

Fixing watcharr import from watcharr export not importing users games.

Also works if user edits the table in import/process to change the type of something to a game (and multi results works too).

Added warning message if user tries importing a watcharr export that has games when `!serverFeatures.games`
This commit is contained in:
IRHM
2026-03-09 01:59:56 +00:00
committed by momi
parent 7a7bd4f566
commit ac8d8769af
11 changed files with 441 additions and 207 deletions
+49 -3
View File
@@ -1,11 +1,35 @@
package domain
import (
"errors"
"time"
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/util"
)
type ImportContentType string
const (
ImportContentTypeMovie ImportContentType = "movie"
ImportContentTypeShow ImportContentType = "tv"
ImportContentTypeShowEpisode ImportContentType = "tv_episode"
ImportContentTypeGame ImportContentType = "game"
)
func ImportContentTypeToSearchType(t ImportContentType) SearchType {
switch t {
case ImportContentTypeMovie:
return SearchTypeMovie
case ImportContentTypeShow:
return SearchTypeShow
case ImportContentTypeGame:
return SearchTypeGame
}
// Empty string should be caught as an error.
return ""
}
type ImportResponseType string
var (
@@ -22,10 +46,13 @@ var (
)
type ImportRequest struct {
TmdbID int `json:"tmdbId"`
ImdbID string `json:"imdbId"`
IgdbID int `json:"igdbId"`
Name string `json:"name"`
Year int `json:"year"`
TmdbID int `json:"tmdbId"`
Type entity.ContentType `json:"type"`
Type ImportContentType `json:"type"`
Rating float64 `json:"rating" binding:"max=10"`
RatingCustomDate *time.Time `json:"ratingCustomDate"`
Status entity.WatchedStatus `json:"status"`
@@ -35,7 +62,26 @@ type ImportRequest struct {
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes"`
WatchedSeason []entity.WatchedSeason `json:"watchedSeasons"`
Tags []TagAddRequest `json:"tags"`
ImdbID string `json:"imdbId"`
}
// Internal struct given to the SuccessfulImport function.
type SuccessfulImportProps struct {
TmdbID int
IgdbID int
ContentType util.SupportedMedia
}
func NewSuccessfulImportPropsFromMedia(m *Media) (SuccessfulImportProps, error) {
p := SuccessfulImportProps{ContentType: m.GetMediaType()}
switch p.ContentType {
case util.SupportedMediaMovie, util.SupportedMediaShow:
p.TmdbID = m.IDs.TMDB
case util.SupportedMediaGame:
p.IgdbID = m.IDs.IGDB
default:
return p, errors.New("unsupported content type on media")
}
return p, nil
}
type ImportResponse struct {
+1
View File
@@ -98,6 +98,7 @@ func (t Media) GetId() int {
return -99
}
// If this changes, verify all use cases still make sense!
func (t Media) GetMediaType() util.SupportedMedia {
switch t.Type {
case MediaTypeTMDBMovie:
+70 -158
View File
@@ -5,7 +5,6 @@ import (
"errors"
"log/slog"
"strconv"
"strings"
"time"
"github.com/sbondCo/Watcharr/database/entity"
@@ -32,10 +31,7 @@ type WatchedEpisodeProvider interface {
}
type ContentProvider interface {
SearchContent(query string, pageNum int) (tmdb.TMDBSearchMultiResponse, error)
SearchByExternalId(id string, source string) (tmdb.TMDBSearchMultiResponse, error)
MovieDetails(id string, country string, rParams map[string]string) (tmdb.TMDBMovieDetails, error)
TvDetails(id string, country string, rParams map[string]string) (tmdb.TMDBShowDetails, error)
}
type TagProvider interface {
@@ -43,6 +39,10 @@ type TagProvider interface {
GetTagByNameAndColor(userId uint, tagName string, tagColor string, tagBgColor string) (entity.Tag, error)
}
type SearchProvider interface {
Search(r domain.SearchRequest, pp util.PaginationParams, userId uint) (domain.SearchResponse, error)
}
type Service struct {
db *gorm.DB
wp WatchedProvider
@@ -51,6 +51,7 @@ type Service struct {
cp ContentProvider
activityProvider domain.ActivityAddProvider
tagProvider TagProvider
searchProvider SearchProvider
}
func NewService(
@@ -61,6 +62,7 @@ func NewService(
cp ContentProvider,
activityProvider domain.ActivityAddProvider,
tagProvider TagProvider,
searchProvider SearchProvider,
) *Service {
return &Service{
db,
@@ -70,166 +72,64 @@ func NewService(
cp,
activityProvider,
tagProvider,
searchProvider,
}
}
// TODO Support game importing
func (s *Service) ImportContent(
userId uint,
ar domain.ImportRequest,
) (domain.ImportResponse, error) {
slog.Debug("import: Processing request:", "request", ar)
// If tmdbId and type passed in request body
// we dont need to use a search tmdb request.
// Retrieve the details directly.
if ar.TmdbID != 0 && (ar.Type == entity.MOVIE || ar.Type == entity.SHOW) {
tid := strconv.Itoa(ar.TmdbID)
if ar.Type == entity.MOVIE {
cr, err := s.cp.MovieDetails(tid, "", map[string]string{})
if err != nil {
return domain.ImportResponse{}, errors.New("movie details request failed")
}
slog.Debug("import: by tmdbid of movie", "cr", cr)
return s.SuccessfulImport(userId, cr.ID, util.SupportedMediaMovie, ar)
} else if ar.Type == entity.SHOW {
cr, err := s.cp.TvDetails(tid, "", map[string]string{})
if err != nil {
return domain.ImportResponse{}, errors.New("tv details request failed")
}
slog.Debug("import: by tmdbid of tv", "cr", cr)
return s.SuccessfulImport(userId, cr.ID, util.SupportedMediaShow, ar)
// If we have a TMDB ID given to us, we can go directly to
// `SuccessfulImport` and let AddWatched fail if it doesn't exist.
if ar.TmdbID != 0 {
switch ar.Type {
case domain.ImportContentTypeMovie:
return s.SuccessfulImport(userId, &ar, domain.SuccessfulImportProps{
TmdbID: ar.TmdbID,
ContentType: util.SupportedMediaMovie,
}), nil
case domain.ImportContentTypeShow:
return s.SuccessfulImport(userId, &ar, domain.SuccessfulImportProps{
TmdbID: ar.TmdbID,
ContentType: util.SupportedMediaShow,
}), nil
}
// Unsupported types will continue to below...
}
// If imdb id passed, attempt to get content with it
if ar.ImdbID != "" && (ar.Type == entity.MOVIE || ar.Type == entity.SHOW || ar.Type == entity.SHOW_EPISODE) {
if imdbResp, err := s.cp.SearchByExternalId(ar.ImdbID, "imdb"); err == nil {
if len(imdbResp.Results) == 1 {
onlyResult := imdbResp.Results[0]
if onlyResult.MediaType == string(entity.MOVIE) || onlyResult.MediaType == string(entity.SHOW) {
// Will only be one result
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
return s.SuccessfulImport(userId, onlyResult.ID, util.SupportedMedia(onlyResult.MediaType), ar)
} else if onlyResult.MediaType == string(entity.SHOW_EPISODE) {
// Handle episodes differently.
// Clients must import tv episodes last so that the actual show can be imported first
// will fail if watched entry isn't imported first or already exists (we won't make it here).
w, e := s.wp.GetWatchedItemByTmdbId(userId, uint(onlyResult.ShowId), "tv")
if e != nil {
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
ws, err := s.wep.AddWatchedEpisodes(userId, episode.WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: onlyResult.SeasonNumber,
EpisodeNumber: onlyResult.EpisodeNumber,
Status: ar.Status,
Rating: int8(ar.Rating),
AddActivityDate: *ar.RatingCustomDate,
})
if err != nil {
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
} else {
w.WatchedEpisodes = ws.WatchedEpisodes
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}, nil
}
} else {
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
} else {
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
}
} else {
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
if ar.ImdbID != "" && (ar.Type == domain.ImportContentTypeMovie ||
ar.Type == domain.ImportContentTypeShow ||
ar.Type == domain.ImportContentTypeShowEpisode) {
// Try import with imdb id.
resp, err := s.importWithIMDBID(userId, &ar)
if err == nil || !errors.Is(err, ErrNoResult) {
return resp, err
}
// If ErrNoResult then we allow going below to search by name.
}
// tmdbId not passed.. search for the content by name.
sr, err := s.cp.SearchContent(ar.Name, 1)
if err != nil {
slog.Error("import: content search failed", "error", err)
return domain.ImportResponse{}, errors.New("content search failed")
}
// potential matches
pMatches := []domain.Media{}
for _, r := range sr.Results {
if r.MediaType != "person" {
pMatches = append(pMatches, r.AsMedia())
}
}
resLen := len(pMatches)
slog.Debug("import: potential matches", "num_found", resLen)
if resLen <= 0 {
slog.Debug("import: returning IMPORT_NOTFOUND")
return domain.ImportResponse{Type: domain.IMPORT_NOTFOUND}, nil
} else if resLen > 1 {
slog.Debug("import: multiple results found")
// If there are multiple responses, but only one item
// from the results is a 100% match for the imported
// items name, then consider successful match with that.
perfectMatches := []domain.Media{}
for _, r := range pMatches {
itemReleaseYear := 0
// Only parse dates to find year if the import request has provided
// a year to comparisons.. otherwise don't do it to save some performance juice.
if ar.Year != 0 {
if !r.ReleaseDate.IsZero() {
itemReleaseYear = r.ReleaseDate.Year()
} else {
slog.Error("import: failed to check item release year, it can't be used for matching",
"error", err, "item", r)
}
}
if strings.EqualFold(r.Name, ar.Name) {
slog.Debug("import: multiple results processing: found a perfect name match", "itemReleaseYear", itemReleaseYear, "ar.Year", ar.Year, "match", r)
// If we have a year for comparison, force a check to compare them for a
// match to be deemed perfect.
// `itemReleaseYear` can only ever have a value if `ar.Year` has one, so this
// check is safe as is.
if itemReleaseYear != 0 || ar.Year != 0 {
if itemReleaseYear == ar.Year {
perfectMatches = append(perfectMatches, r)
slog.Debug("import: multiple results processing: name match also matched year")
} else {
slog.Debug("import: multiple results processing: name match didnt match year")
}
continue
}
// Otherwise, if we don't have valid dates to compare, append the perfect name match anyways.
slog.Debug("import: multiple results processing: name match didn't have valid release year, adding to matches anyways")
perfectMatches = append(perfectMatches, r)
}
}
// If one perfect match found, import it
pmLen := len(perfectMatches)
if pmLen == 1 && perfectMatches[0].IDs.TMDB != 0 {
slog.Debug("import: importing from perfect match")
return s.SuccessfulImport(
userId,
perfectMatches[0].IDs.TMDB,
perfectMatches[0].GetMediaType(),
ar)
}
slog.Debug("import: returning all potential matches")
return domain.ImportResponse{Type: domain.IMPORT_MULTI, Results: pMatches}, nil
} else {
slog.Debug("import: success.. only found one result")
return s.SuccessfulImport(
userId,
pMatches[0].IDs.TMDB,
pMatches[0].GetMediaType(),
ar)
// If igdb provided, go straight to SuccessfulImport with it.
if ar.IgdbID != 0 && (ar.Type == domain.ImportContentTypeGame) {
return s.SuccessfulImport(userId, &ar, domain.SuccessfulImportProps{
IgdbID: ar.IgdbID,
ContentType: util.SupportedMediaGame,
}), nil
}
// If we have no IDs, run importWithName, which searches for content
// by name.
return s.importWithName(userId, &ar)
}
func (s *Service) SuccessfulImport(
userId uint,
contentId int,
contentType util.SupportedMedia,
ar domain.ImportRequest,
) (domain.ImportResponse, error) {
ar *domain.ImportRequest,
props domain.SuccessfulImportProps,
) domain.ImportResponse {
status := entity.FINISHED
if ar.Status != "" {
status = ar.Status
@@ -243,33 +143,45 @@ func (s *Service) SuccessfulImport(
}
}
}
// Build WatchedAddRequest
wAddReq := domain.WatchedAddRequest{
ContentType: props.ContentType,
Status: status,
Rating: ar.Rating,
Thoughts: ar.Thoughts,
WatchedDate: wDate,
}
switch props.ContentType {
case util.SupportedMediaMovie, util.SupportedMediaShow:
wAddReq.TMDBID = props.TmdbID
case util.SupportedMediaGame:
wAddReq.IGDBID = props.IgdbID
default:
slog.Error("successfulImport: Invalid contentType provided!",
"content_type", props.ContentType)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}
}
// Running add watched
w, err := s.wp.AddWatched(
userId,
domain.WatchedAddRequest{
Status: status,
TMDBID: contentId,
ContentType: contentType,
Rating: ar.Rating,
Thoughts: ar.Thoughts,
WatchedDate: wDate,
},
wAddReq,
domain.WatchedAddExtraProps{
ActivityType: entity.IMPORTED_WATCHED,
})
if err != nil {
if errors.Is(err, domain.ErrWatchedExists) {
slog.Error("successfulImport: Must already be on watch list", "error", err)
return domain.ImportResponse{Type: domain.IMPORT_EXISTS}, nil
return domain.ImportResponse{Type: domain.IMPORT_EXISTS}
}
slog.Error("successfulImport: Failed to add content as watched", "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
return domain.ImportResponse{Type: domain.IMPORT_FAILED}
}
// Add activity of the original time the show was added to the users
// watchlist on whichever platform they are coming from.
if ar.RatingCustomDate != nil {
var addedActivity entity.Activity
if len(w.Activity) > 0 {
activityJson, _ := json.Marshal(map[string]interface{}{
activityJson, _ := json.Marshal(map[string]any{
"rating": ar.Rating,
"linkedActivity": w.Activity[0].ID,
})
@@ -379,5 +291,5 @@ func (s *Service) SuccessfulImport(
w.Tags = append(w.Tags, t)
}
}
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}, nil
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}
}
+23 -19
View File
@@ -165,11 +165,12 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
}
rProc := func(v TraktHistory) {
var collectingText string
if v.Type == "episode" {
switch v.Type {
case "episode":
collectingText = fmt.Sprintf("%s S%dE%d", v.Show.Title, v.Episode.Season, v.Episode.Number)
} else if v.Type == "show" {
case "show":
collectingText = v.Show.Title
} else if v.Type == "movie" {
case "movie":
collectingText = v.Movie.Title
}
if collectingText != "" {
@@ -220,20 +221,21 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
var (
title string
contentType entity.ContentType
contentType domain.ImportContentType
tmdbId int
)
if v.Type == "show" || v.Type == "episode" {
switch v.Type {
case "show", "episode":
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
contentType = entity.SHOW
contentType = domain.ImportContentTypeShow
if v.Type == "episode" {
title = v.Episode.Title
}
} else if v.Type == "movie" {
case "movie":
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = entity.MOVIE
contentType = domain.ImportContentTypeMovie
}
job.UpdateJobCurrentTask(jobId, userId, "setting status for "+title)
mapKey := t.makeTraktMapKey(contentType, tmdbId)
@@ -310,23 +312,24 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
slog.Debug("startTraktImport: Processing rating item", "item", v)
var (
title string
contentType entity.ContentType
contentType domain.ImportContentType
tmdbId int
traktSlug string
)
if v.Type == "show" || v.Type == "episode" {
switch v.Type {
case "show", "episode":
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
traktSlug = v.Show.Ids.Slug
contentType = entity.SHOW
contentType = domain.ImportContentTypeShow
if v.Type == "episode" {
title = v.Episode.Title
traktSlug = v.Episode.Ids.Slug
}
} else if v.Type == "movie" {
case "movie":
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = entity.MOVIE
contentType = domain.ImportContentTypeMovie
traktSlug = v.Movie.Ids.Slug
}
job.UpdateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
@@ -374,14 +377,15 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
title string
traktId int
tmdbId int
contentType entity.ContentType
contentType domain.ImportContentType
watchedEpisode entity.WatchedEpisode
)
if v.Type == "show" || v.Type == "episode" {
switch v.Type {
case "show", "episode":
title = v.Show.Title
traktId = v.Show.Ids.Trakt
tmdbId = v.Show.Ids.Tmdb
contentType = entity.SHOW
contentType = domain.ImportContentTypeShow
if v.Type == "episode" {
traktId = v.Episode.Ids.Trakt
watchedEpisode = entity.WatchedEpisode{
@@ -397,11 +401,11 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
} else {
slog.Debug("processTraktHistoryItem: Processing a show.", "contentTitle", title, "contentTmdbId", tmdbId)
}
} else if v.Type == "movie" {
case "movie":
title = v.Movie.Title
traktId = v.Movie.Ids.Trakt
tmdbId = v.Movie.Ids.Tmdb
contentType = entity.MOVIE
contentType = domain.ImportContentTypeMovie
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
}
if tmdbId == 0 {
@@ -425,7 +429,7 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
}
// `tmdbId` is for the movie or show (not for episodes).
func (t *TraktService) makeTraktMapKey(ct entity.ContentType, tmdbId int) string {
func (t *TraktService) makeTraktMapKey(ct domain.ImportContentType, tmdbId int) string {
return string(ct) + strconv.Itoa(tmdbId)
}
+204
View File
@@ -0,0 +1,204 @@
package imprt
import (
"errors"
"log/slog"
"strings"
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/domain"
"github.com/sbondCo/Watcharr/feature/watched/episode"
"github.com/sbondCo/Watcharr/util"
)
// Same Service as import.go but here so that one file doesn't get too hugemongus.
var (
ErrNoResult = errors.New("no result from specific data request")
)
// Import by name (and or type). We do a content search to find a result,
// then try to import. Multiple results can end in returning them all for the
// user to decide.
func (s *Service) importWithName(
userId uint,
ar *domain.ImportRequest,
) (domain.ImportResponse, error) {
// Try to search for results by name (and or type).
searchReq := domain.SearchRequest{
Query: ar.Name,
// Default to multi search.
Type: domain.SearchTypeMulti,
}
if ar.Type != "" {
searchType := domain.ImportContentTypeToSearchType(ar.Type)
if searchType == "" {
// Invalid.. `ar.Type` is unsupported.
slog.Error("importWithName: Invalid ImportContentType provided!",
"type", ar.Type)
return domain.ImportResponse{},
errors.New("invalid import content type provided")
}
searchReq.Type = searchType
}
searchResp, err := s.searchProvider.Search(
searchReq,
util.PaginationParams{Page: 1},
userId,
)
if err != nil {
slog.Error("importWithName: Search failed", "error", err)
return domain.ImportResponse{}, errors.New("search failed")
}
slog.Debug("importWithName: Potential matches",
"num_found", searchResp.TotalResults)
// If no results at all, return IMPORT_NOTFOUND
if searchResp.TotalResults <= 0 {
// No results found...
slog.Debug("importWithName: returning IMPORT_NOTFOUND")
return domain.ImportResponse{Type: domain.IMPORT_NOTFOUND}, nil
}
// If we did a multi search, remove people
results := []domain.Media{}
if searchReq.Type == domain.SearchTypeMulti {
for _, v := range searchResp.Results {
if v.Type != domain.MediaTypeTMDBPerson {
results = append(results, v)
}
}
} else {
results = searchResp.Results
}
// Process results
if len(results) > 1 {
slog.Debug("importWithName: Multiple results found")
return s.importWithNameHandleMultipleResultsFound(userId, ar, results)
} else {
slog.Debug("importWithName: success.. only found one result")
props, err := domain.NewSuccessfulImportPropsFromMedia(&results[0])
if err != nil {
slog.Error("importWithName: Couldn't create props!", "error", err)
return domain.ImportResponse{}, err
}
return s.SuccessfulImport(userId, ar, props), nil
}
}
// If the importWithName func ends up with multiple results found in the
// search response, this will handle the case by trying to find a perfectMatch,
// otherwise returning an IMPORT_MULTI response.
func (s *Service) importWithNameHandleMultipleResultsFound(
userId uint,
ar *domain.ImportRequest,
results []domain.Media,
) (domain.ImportResponse, error) {
perfectMatches := []domain.Media{}
for _, r := range results {
itemReleaseYear := 0
// Only parse dates to find year if the import request has provided
// a year (and to keep below matching logic working properly).
if ar.Year != 0 {
if !r.ReleaseDate.IsZero() {
itemReleaseYear = r.ReleaseDate.Year()
} else {
slog.Error("importWithName: Item has no ReleaseDate to use in comparison.")
}
}
if strings.EqualFold(r.Name, ar.Name) {
slog.Debug("importWithName: Found a perfect name match",
"itemReleaseYear", itemReleaseYear,
"ar.Year", ar.Year,
"match", r)
// If we have a year for comparison, force a check to compare them for a
// match to be deemed perfect.
// `itemReleaseYear` can only ever have a value if `ar.Year` has one, so this
// check is safe as is.
if itemReleaseYear != 0 || ar.Year != 0 {
if itemReleaseYear == ar.Year {
perfectMatches = append(perfectMatches, r)
slog.Debug("importWithName: Name match also matched year")
} else {
slog.Debug("importWithName: Name match didn't match year")
}
continue
}
// Otherwise, if we don't have valid dates to compare, append the perfect name match anyways.
slog.Debug("importWithName: Name match didn't have release years to compare, adding to matches anyways")
perfectMatches = append(perfectMatches, r)
}
}
// If one perfect match found, import it
if len(perfectMatches) == 1 && perfectMatches[0].IDs.TMDB != 0 {
slog.Debug("importWithName: importing from perfect match")
props, err := domain.NewSuccessfulImportPropsFromMedia(&perfectMatches[0])
if err != nil {
slog.Error("importWithName: Couldn't create props!", "error", err)
return domain.ImportResponse{}, err
}
return s.SuccessfulImport(userId, ar, props), nil
}
slog.Debug("importWithName: returning all potential matches")
return domain.ImportResponse{Type: domain.IMPORT_MULTI, Results: results}, nil
}
// Import with an IMDb ID.
func (s *Service) importWithIMDBID(
userId uint,
ar *domain.ImportRequest,
) (domain.ImportResponse, error) {
if imdbResp, err := s.cp.SearchByExternalId(ar.ImdbID, "imdb"); err == nil {
if len(imdbResp.Results) == 1 {
onlyResult := imdbResp.Results[0]
if onlyResult.MediaType == string(entity.MOVIE) || onlyResult.MediaType == string(entity.SHOW) {
// Will only be one result
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
return s.SuccessfulImport(
userId,
ar,
domain.SuccessfulImportProps{
TmdbID: onlyResult.ID,
ContentType: util.SupportedMedia(onlyResult.MediaType),
}), nil
} else if onlyResult.MediaType == string(entity.SHOW_EPISODE) {
// Handle episodes differently.
// Clients must import tv episodes last so that the actual show can be imported first
// will fail if watched entry isn't imported first or already exists (we won't make it here).
w, e := s.wp.GetWatchedItemByTmdbId(userId, uint(onlyResult.ShowId), "tv")
if e != nil {
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
ws, err := s.wep.AddWatchedEpisodes(userId, episode.WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: onlyResult.SeasonNumber,
EpisodeNumber: onlyResult.EpisodeNumber,
Status: ar.Status,
Rating: int8(ar.Rating),
AddActivityDate: *ar.RatingCustomDate,
})
if err != nil {
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
} else {
w.WatchedEpisodes = ws.WatchedEpisodes
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}, nil
}
} else {
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
} else {
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
}
} else {
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
}
// ErrNoResult should be caught from caller and let fall through to search by name.
return domain.ImportResponse{}, ErrNoResult
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (r *Router) ImportContent(c *gin.Context) {
if err == nil {
response, err := r.service.ImportContent(userId, ar)
if err != nil {
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
+11
View File
@@ -59,6 +59,8 @@ func (s *Service) Search(
pp util.PaginationParams,
userId uint,
) (domain.SearchResponse, error) {
slog.Debug("Search: Running.", "request", r, "user_id", userId)
resp := domain.SearchResponse{}
if r.Query == "" {
@@ -114,6 +116,7 @@ func (s *Service) searchMulti(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchMulti: Running.", "query", query, "page", page)
// TMDB
tmdbRes, err := s.contentProvider.SearchContent(query, page)
if err != nil {
@@ -151,6 +154,7 @@ func (s *Service) searchMovie(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchMovie: Running.", "query", query, "page", page)
tmdbRes, err := s.contentProvider.SearchMovies(query, page)
if err != nil {
slog.Error("SearchMovie: Failed to search tmdb!", "error", err)
@@ -172,6 +176,7 @@ func (s *Service) searchMovieById(
id string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchMovieById: Running.", "id", id)
details, err := s.contentProvider.MovieDetails(id, "", map[string]string{})
if err != nil {
slog.Error("searchMovieById: Failed to search tmdb!", "error", err)
@@ -192,6 +197,7 @@ func (s *Service) searchTv(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchTv: Running.", "query", query, "page", page)
tmdbRes, err := s.contentProvider.SearchTv(query, page)
if err != nil {
slog.Error("searchTv: Failed to search tmdb!", "error", err)
@@ -213,6 +219,7 @@ func (s *Service) searchTvById(
id string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchTvById: Running.", "id", id)
details, err := s.contentProvider.TvDetails(id, "", map[string]string{})
if err != nil {
slog.Error("searchTvById: Failed to search tmdb!", "error", err)
@@ -233,6 +240,7 @@ func (s *Service) searchPeople(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchPeople: Running.", "query", query, "page", page)
tmdbRes, err := s.contentProvider.SearchPeople(query, page)
if err != nil {
slog.Error("searchPeople: Failed to search tmdb!", "error", err)
@@ -255,6 +263,7 @@ func (s *Service) searchGame(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchGame: Running.", "query", query, "page", page)
igdbRes, err := s.cfg.TWITCH.Search(query)
if err != nil {
slog.Error("searchGame: Failed to search igdb!", "error", err)
@@ -276,6 +285,7 @@ func (s *Service) searchGameById(
id string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchGameById: Running.", "id", id)
igdbRes, err := s.cfg.TWITCH.SearchById(id)
if err != nil {
slog.Error("searchGameById: Failed to search igdb!", "error", err)
@@ -297,6 +307,7 @@ func (s *Service) searchGameBySlug(
slug string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchGameBySlug: Running.", "slug", slug)
igdbRes, err := s.cfg.TWITCH.SearchBySlug(slug)
if err != nil {
slog.Error("searchGameBySlug: Failed to search igdb!", "error", err)
+4 -3
View File
@@ -223,6 +223,8 @@ func main() {
profileService := profile.NewService(db)
followService := follow.NewService(db)
tagService := tag.NewService(db, watchedService)
searchService := search.NewService(db, br.Cfg, contentService, watchedService)
discoverService := discover.NewService(db, br.Cfg, contentService)
importService := imprt.NewService(
db,
watchedService,
@@ -230,10 +232,9 @@ func main() {
watchedEpisodeService,
contentService,
activityService,
tagService)
tagService,
searchService)
importTraktService := imprt.NewTraktService(importService)
searchService := search.NewService(db, br.Cfg, contentService, watchedService)
discoverService := discover.NewService(db, br.Cfg, contentService)
auth.NewRouter(br, authService, authTrustedHeaderService).AddRoutes()
content.NewRouter(br, contentService, watchedService).AddRoutes()
+42 -16
View File
@@ -298,33 +298,59 @@
const toImport: ImportedList[] = [];
const fileText = await readFile(new FileReader(), file);
const jsonData = JSON.parse(fileText) as any[];
let invalidStructureErrorOccurred = false;
let processedGame = false;
for (const v of jsonData) {
if (!v.content || !v.content.title) {
notify({
type: "error",
text: "Item in export has no content or a missing title! Look in console for more details.",
});
console.error(
"Can't add export item to import table! It has no content or a missing content.title! Item:",
v,
);
continue;
}
const t: ImportedList = {
tmdbId: v.content.tmdbId,
name: v.content.title,
year: new Date(v.content.release_date)?.getFullYear(),
type: v.content.type,
rating: v.rating,
status: v.status,
thoughts: v.thoughts,
// datesWatched: [new Date(v.createdAt)], // Shouldn't need this, all activity will be imported, including ADDED_WATCHED activity
activity: v.activity,
watchedEpisodes: v.watchedEpisodes,
watchedSeasons: v.watchedSeasons,
};
if (v.content) {
t.tmdbId = v.content.tmdbId;
t.name = v.content.title;
t.year = new Date(v.content.release_date)?.getFullYear();
t.type = v.content.type;
} else if (v.game) {
t.igdbId = v.game.igdbId;
t.name = v.game.name;
t.year = new Date(v.game.releaseDate)?.getFullYear();
t.type = "game";
processedGame = true;
} else {
console.error("processWatcharrFile: Went over invalid item.", v);
if (!invalidStructureErrorOccurred) {
notify({
type: "error",
text: "Item(s) in export has an invalid structure! Look in console for more details.",
});
// So we don't spam it...
invalidStructureErrorOccurred = true;
}
continue;
}
toImport.push(t);
}
if (processedGame && !store.serverFeatures?.games) {
// We know the server hasnt told us it supports games yet
// for this session, so alert the user of this before they start
// importing.
notify({
type: "error",
text:
"It looks like this server doesn't support 'Games'! " +
"The server should be configured properly before " +
"proceeding otherwise none of your games will be imported!",
time: Infinity,
});
// We still allow the user to continue (BECAUSE EIN CUSTOMER ISH ALWAYS REIGHT!!!!
// or maybe they don't care about games, or maybe they configured
// it in another tab and know for a fact that it should work.)
}
console.log("toImport:", toImport);
store.importedList = {
data: JSON.stringify(toImport),
+30 -3
View File
@@ -45,6 +45,15 @@
cancelled = true;
});
// This isn't reactive to avoid bugs (hopefully this isn't a bug)
let dropDownSupportedTypes = (() => {
let t = ["movie", "tv"];
if (store.serverFeatures?.games) {
t.push("game");
}
return t;
})();
// Set when current item being imported gets an IMPORT_MULTI
// response, which then shows the modal for user to pick correct item.
let importMultiItem: ImportedListItemMultiProblem | undefined = $state();
@@ -75,7 +84,7 @@
const year = el.match(yearRegex);
if (year && year.length > 0) {
l.year = Number(year[0].replaceAll(/\(|\)/g, ""));
l.name = l.name.replace(yearRegex, "").trim();
l.name = l.name?.replace(yearRegex, "").trim();
}
rList.push(l);
}
@@ -631,7 +640,7 @@
</td>
<td class="type">
<DropDown
options={["movie", "tv"]}
options={dropDownSupportedTypes}
bind:active={l.type}
placeholder="Type"
blendIn={true}
@@ -743,9 +752,27 @@
const item = rList.find(
(i) => i.name === importMultiItem?.original.name,
);
console.log(
"MultipleResultsFound: Poster clicked. Item in rList:",
item,
);
if (item) {
item.tmdbId = r.ids.tmdb;
// We found the item in our import list, update it
// to match the selected choice and do the import with it.
item.type = getContentTypeFromMedia(r);
if (item.type === "game") {
item.igdbId = r.ids.igdb;
} else if (item.type === "movie" || item.type === "tv") {
item.tmdbId = r.ids.tmdb;
} else {
item.state = ImportResponseType.IMPORT_FAILED;
notify({
type: "error",
text: "Can't import selected result because it has an unsupported type associated with it!",
time: 10000,
});
return;
}
try {
await doImport(item);
importMultiItem?.callback(undefined);
+6 -4
View File
@@ -538,10 +538,13 @@ export interface ImportResponse {
export interface ImportedList {
tmdbId?: number;
name: string;
imdbId?: string;
igdbId?: number;
name?: string;
year?: number;
type?: ContentType;
state?: string;
type?: "movie" | "tv" | "tv_episode" | "game";
state?: ImportResponseType;
rating?: number;
ratingCustomDate?: Date;
status?: WatchedStatus;
@@ -551,7 +554,6 @@ export interface ImportedList {
watchedEpisodes?: WatchedEpisode[];
watchedSeasons?: WatchedSeason[];
tags?: TagAddRequest[];
imdbId?: string;
}
export interface Filters {