mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-06-23 04:10:07 +00:00
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:
+49
-3
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user