mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 23:32:09 +00:00
681212566b
and some formatting
351 lines
14 KiB
Go
351 lines
14 KiB
Go
package plex
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sbondCo/Watcharr/database/entity"
|
|
"github.com/sbondCo/Watcharr/domain"
|
|
"github.com/sbondCo/Watcharr/feature/watched/episode"
|
|
"github.com/sbondCo/Watcharr/feature/watched/season"
|
|
"github.com/sbondCo/Watcharr/job"
|
|
"github.com/sbondCo/Watcharr/util"
|
|
)
|
|
|
|
type PlexSyncResponse struct {
|
|
JobId string `json:"jobId"`
|
|
}
|
|
|
|
type WatchedProvider interface {
|
|
AddWatched(userId uint, ar domain.WatchedAddRequest, extraProps domain.WatchedAddExtraProps) (entity.Watched, error)
|
|
}
|
|
|
|
type WatchedSeasonProvider interface {
|
|
AddWatchedSeason(userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
|
|
}
|
|
|
|
type WatchedEpisodeProvider interface {
|
|
AddWatchedEpisodes(userId uint, ar episode.WatchedEpisodeAddRequest) (episode.WatchedEpisodeAddResponse, error)
|
|
}
|
|
|
|
type SyncService struct {
|
|
s *Service
|
|
wp WatchedProvider
|
|
wsp WatchedSeasonProvider
|
|
wep WatchedEpisodeProvider
|
|
activityProvider domain.ActivityAddProvider
|
|
}
|
|
|
|
func NewSyncService(
|
|
s *Service,
|
|
wp WatchedProvider,
|
|
wsp WatchedSeasonProvider,
|
|
wep WatchedEpisodeProvider,
|
|
activityProvider domain.ActivityAddProvider,
|
|
) *SyncService {
|
|
return &SyncService{
|
|
s,
|
|
wp,
|
|
wsp,
|
|
wep,
|
|
activityProvider,
|
|
}
|
|
}
|
|
|
|
// Perform a Plex sync.
|
|
// Errors are added silently to the job.
|
|
func (s *SyncService) startPlexSync(
|
|
jobId string,
|
|
userId uint,
|
|
userPlexLocalAuth string,
|
|
) {
|
|
job.UpdateJobCurrentTask(jobId, userId, "fetching libraries")
|
|
libraries, err := s.s.GetPlexLibraries(userPlexLocalAuth)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
|
|
job.AddJobError(jobId, userId, "failed to get plex libraries")
|
|
job.UpdateJobStatus(jobId, userId, job.JOB_DONE)
|
|
return
|
|
}
|
|
for _, library := range libraries.MediaContainer.Directory {
|
|
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
|
|
if library.Type == "movie" {
|
|
job.UpdateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
|
|
movies, err := s.s.GetPlexLibraryItems(userPlexLocalAuth, library.Key)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
|
|
job.AddJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
|
|
continue
|
|
}
|
|
for _, movie := range movies.MediaContainer.Metadata {
|
|
if movie.ViewCount == 0 {
|
|
// Not viewed and not rated, skip importing
|
|
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
|
|
continue
|
|
}
|
|
job.UpdateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
|
|
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
|
|
|
|
// Find tmdb id
|
|
if len(movie.Guid) <= 0 {
|
|
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
|
|
job.AddJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
|
|
continue
|
|
}
|
|
tmdbIdStr := ""
|
|
for _, v := range movie.Guid {
|
|
if strings.HasPrefix(v.ID, "tmdb://") {
|
|
tmdbIdStr = v.ID[7:]
|
|
break
|
|
}
|
|
}
|
|
if tmdbIdStr == "" {
|
|
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
|
job.AddJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
|
|
continue
|
|
}
|
|
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
|
job.AddJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
|
|
continue
|
|
}
|
|
|
|
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
|
|
w, err := s.wp.AddWatched(
|
|
userId,
|
|
domain.WatchedAddRequest{
|
|
Status: entity.FINISHED,
|
|
TMDBID: tmdbId,
|
|
ContentType: util.SupportedMediaMovie,
|
|
Rating: float64(movie.UserRating),
|
|
WatchedDate: lastViewedAt,
|
|
},
|
|
domain.WatchedAddExtraProps{
|
|
ActivityType: entity.IMPORTED_WATCHED_PLEX,
|
|
DontRestore: true,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWatchedExists) {
|
|
slog.Info("plexSyncWatched: Movie already exists on list.")
|
|
continue
|
|
}
|
|
if errors.Is(err, domain.ErrWatchedExistsSoftDeleted) {
|
|
// Entry was manually soft deleted by user at another point,
|
|
// we DontRestore these automatically.
|
|
slog.Warn("plexSyncWatched: Movie exists on list soft deleted.")
|
|
job.AddJobError(
|
|
jobId,
|
|
userId,
|
|
"failed to add movie "+
|
|
movie.Title+
|
|
". You have previously deleted it from your list!")
|
|
continue
|
|
}
|
|
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
|
|
job.AddJobError(jobId, userId, "failed to add movie "+movie.Title)
|
|
} else {
|
|
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
|
if !lastViewedAt.IsZero() {
|
|
_, err := s.activityProvider.AddActivity(
|
|
userId,
|
|
domain.ActivityAddProps{
|
|
WatchedID: w.ID,
|
|
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
|
CustomDate: &lastViewedAt,
|
|
},
|
|
false,
|
|
)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
|
|
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if library.Type == "show" {
|
|
job.UpdateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
|
|
shows, err := s.s.GetPlexLibraryItems(userPlexLocalAuth, library.Key)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
|
|
job.AddJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
|
|
continue
|
|
}
|
|
for _, show := range shows.MediaContainer.Metadata {
|
|
if show.ViewedLeafCount != show.LeafCount {
|
|
// Not viewed, skip importing
|
|
// (could be improved to set status as watching when viewedLeafCount is higher than 0)
|
|
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
|
|
continue
|
|
}
|
|
job.UpdateJobCurrentTask(jobId, userId, "importing show "+show.Title)
|
|
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
|
|
|
|
tmdbIdStr := ""
|
|
for _, v := range show.Guid {
|
|
if strings.HasPrefix(v.ID, "tmdb://") {
|
|
tmdbIdStr = v.ID[7:]
|
|
break
|
|
}
|
|
}
|
|
if tmdbIdStr == "" {
|
|
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
|
job.AddJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
|
|
continue
|
|
}
|
|
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
|
job.AddJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
|
|
continue
|
|
}
|
|
|
|
lastViewedAt := time.Unix(show.LastViewedAt, 0)
|
|
w, err := s.wp.AddWatched(
|
|
userId,
|
|
domain.WatchedAddRequest{
|
|
Status: entity.FINISHED,
|
|
ContentType: util.SupportedMediaShow,
|
|
TMDBID: tmdbId,
|
|
Rating: float64(show.UserRating),
|
|
WatchedDate: lastViewedAt,
|
|
},
|
|
domain.WatchedAddExtraProps{
|
|
ActivityType: entity.IMPORTED_WATCHED_PLEX,
|
|
DontRestore: true,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWatchedExists) {
|
|
slog.Info("plexSyncWatched: Show already exists on list.")
|
|
// In this case, we allow continuing below to start syncing seasons/episodes
|
|
} else if errors.Is(err, domain.ErrWatchedExistsSoftDeleted) {
|
|
// Entry was manually soft deleted by user at another point,
|
|
// we DontRestore these automatically. We also don't continue
|
|
// because eps/seasons cant be synced.
|
|
slog.Warn("plexSyncWatched: Show exists on list soft deleted.")
|
|
job.AddJobError(
|
|
jobId,
|
|
userId,
|
|
"failed to add show "+
|
|
show.Title+
|
|
". You have previously deleted it from your list!")
|
|
continue
|
|
} else {
|
|
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
|
|
job.AddJobError(jobId, userId, "failed to add show "+show.Title)
|
|
}
|
|
} else {
|
|
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
|
if !lastViewedAt.IsZero() {
|
|
_, err := s.activityProvider.AddActivity(
|
|
userId,
|
|
domain.ActivityAddProps{
|
|
WatchedID: w.ID,
|
|
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
|
CustomDate: &lastViewedAt,
|
|
},
|
|
false,
|
|
)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
|
|
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import watched seasons for this serie
|
|
seriesSeasons, err := s.s.GetPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
|
job.AddJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
|
|
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
|
|
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
|
|
} else {
|
|
for _, vs := range seriesSeasons.MediaContainer.Metadata {
|
|
slog.Debug("plexSyncWatched: Processing a season.", "full_item", vs, "user_id", userId)
|
|
if vs.ViewedLeafCount != vs.LeafCount {
|
|
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
|
|
continue
|
|
}
|
|
job.UpdateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
|
|
var seasonLastViewedAt time.Time
|
|
if vs.LastViewedAt != 0 {
|
|
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
|
}
|
|
_, err = s.wsp.AddWatchedSeason(userId, season.WatchedSeasonAddRequest{
|
|
WatchedID: w.ID,
|
|
SeasonNumber: vs.Index,
|
|
Status: entity.FINISHED,
|
|
AddActivity: entity.SEASON_ADDED_PLEX,
|
|
AddActivityDate: seasonLastViewedAt,
|
|
})
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
|
job.AddJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import watched episodes for this serie
|
|
seriesEpisodes, err := s.s.GetPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
|
job.AddJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
|
|
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
|
|
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
|
|
} else {
|
|
for _, vs := range seriesEpisodes.MediaContainer.Metadata {
|
|
slog.Debug("plexSyncWatched: Processing an episode.", "full_item", vs, "user_id", userId)
|
|
if vs.ViewCount <= 0 {
|
|
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
|
|
continue
|
|
}
|
|
job.UpdateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
|
|
var episodeLastViewedAt time.Time
|
|
if vs.LastViewedAt != 0 {
|
|
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
|
}
|
|
_, err = s.wep.AddWatchedEpisodes(userId, episode.WatchedEpisodeAddRequest{
|
|
WatchedID: w.ID,
|
|
SeasonNumber: vs.ParentIndex,
|
|
EpisodeNumber: vs.Index,
|
|
Status: entity.FINISHED,
|
|
AddActivity: entity.EPISODE_ADDED_PLEX,
|
|
AddActivityDate: episodeLastViewedAt,
|
|
})
|
|
if err != nil {
|
|
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
|
|
job.AddJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
job.UpdateJobStatus(jobId, userId, job.JOB_DONE)
|
|
}
|
|
|
|
func (s *SyncService) PlexSyncWatched(
|
|
userId uint,
|
|
userPlexLocalAuth string,
|
|
) (PlexSyncResponse, error) {
|
|
jobId, err := job.AddJob("plex_sync", userId)
|
|
if err != nil {
|
|
slog.Error("startPlexSync: Failed to create a job", "error", err)
|
|
return PlexSyncResponse{}, errors.New("failed to create job")
|
|
}
|
|
|
|
job.UpdateJobStatus(jobId, userId, job.JOB_RUNNING)
|
|
|
|
go s.startPlexSync(
|
|
jobId,
|
|
userId,
|
|
userPlexLocalAuth,
|
|
)
|
|
|
|
return PlexSyncResponse{JobId: jobId}, nil
|
|
}
|