package local import ( "seanime/internal/api/anilist" "seanime/internal/api/metadata" "seanime/internal/events" "seanime/internal/library/anime" "seanime/internal/manga" "seanime/internal/util" "seanime/internal/util/result" "sync" "github.com/samber/lo" ) // DEVNOTE: The synchronization process is split into 3 parts: // 1. ManagerImpl.synchronize removes outdated tracked anime & manga, runs Syncer.runDiffs and adds changed tracked anime & manga to the queue. // 2. The Syncer processes the queue, calling Syncer.synchronizeAnime and Syncer.synchronizeManga for each job. // 3. Syncer.synchronizeCollections creates a local collection that mirrors the remote collection, containing only the tracked anime & manga. Only called when the queue is emptied. ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// type ( // Syncer will synchronize the anime and manga snapshots in the local database. // Anytime Manager.Synchronize is called, tracked anime and manga will be added to the queue. // The queue will synchronize one anime and one manga every X minutes, until it's empty. // // Synchronization can fail due to network issues. When it does, the anime or manga will be added to the failed queue. Syncer struct { animeJobQueue chan AnimeTask mangaJobQueue chan MangaTask failedAnimeQueue *result.Cache[int, *anilist.AnimeListEntry] failedMangaQueue *result.Cache[int, *anilist.MangaListEntry] trackedAnimeMap map[int]*TrackedMedia trackedMangaMap map[int]*TrackedMedia manager *ManagerImpl mu sync.RWMutex shouldUpdateLocalCollections bool doneUpdatingLocalCollections chan struct{} queueState QueueState queueStateMu sync.RWMutex } QueueState struct { AnimeTasks map[int]*QueueMediaTask `json:"animeTasks"` MangaTasks map[int]*QueueMediaTask `json:"mangaTasks"` } QueueMediaTask struct { MediaId int `json:"mediaId"` Image string `json:"image"` Title string `json:"title"` Type string `json:"type"` } AnimeTask struct { Diff *AnimeDiffResult } MangaTask struct { Diff *MangaDiffResult } ) func NewQueue(manager *ManagerImpl) *Syncer { ret := &Syncer{ animeJobQueue: make(chan AnimeTask, 100), mangaJobQueue: make(chan MangaTask, 100), failedAnimeQueue: result.NewCache[int, *anilist.AnimeListEntry](), failedMangaQueue: result.NewCache[int, *anilist.MangaListEntry](), shouldUpdateLocalCollections: false, doneUpdatingLocalCollections: make(chan struct{}, 1), manager: manager, mu: sync.RWMutex{}, queueState: QueueState{ AnimeTasks: make(map[int]*QueueMediaTask), MangaTasks: make(map[int]*QueueMediaTask), }, queueStateMu: sync.RWMutex{}, } go ret.processAnimeJobs() go ret.processMangaJobs() return ret } func (q *Syncer) processAnimeJobs() { for job := range q.animeJobQueue { q.queueStateMu.Lock() q.queueState.AnimeTasks[job.Diff.AnimeEntry.Media.ID] = &QueueMediaTask{ MediaId: job.Diff.AnimeEntry.Media.ID, Image: job.Diff.AnimeEntry.Media.GetCoverImageSafe(), Title: job.Diff.AnimeEntry.Media.GetPreferredTitle(), Type: "anime", } q.SendQueueStateToClient() q.queueStateMu.Unlock() q.shouldUpdateLocalCollections = true q.synchronizeAnime(job.Diff) q.queueStateMu.Lock() delete(q.queueState.AnimeTasks, job.Diff.AnimeEntry.Media.ID) q.SendQueueStateToClient() q.queueStateMu.Unlock() q.checkAndUpdateLocalCollections() } } func (q *Syncer) processMangaJobs() { for job := range q.mangaJobQueue { q.queueStateMu.Lock() q.queueState.MangaTasks[job.Diff.MangaEntry.Media.ID] = &QueueMediaTask{ MediaId: job.Diff.MangaEntry.Media.ID, Image: job.Diff.MangaEntry.Media.GetCoverImageSafe(), Title: job.Diff.MangaEntry.Media.GetPreferredTitle(), Type: "manga", } q.SendQueueStateToClient() q.queueStateMu.Unlock() q.shouldUpdateLocalCollections = true q.synchronizeManga(job.Diff) q.queueStateMu.Lock() delete(q.queueState.MangaTasks, job.Diff.MangaEntry.Media.ID) q.SendQueueStateToClient() q.queueStateMu.Unlock() q.checkAndUpdateLocalCollections() } } // checkAndUpdateLocalCollections will synchronize the local collections once the job queue is emptied. func (q *Syncer) checkAndUpdateLocalCollections() { q.mu.Lock() defer q.mu.Unlock() // Check if we need to update the local collections if q.shouldUpdateLocalCollections { // Check if both queues are empty if len(q.animeJobQueue) == 0 && len(q.mangaJobQueue) == 0 { // Update the local collections err := q.synchronizeCollections() if err != nil { q.manager.logger.Error().Err(err).Msg("local manager: Failed to synchronize collections") } q.SendQueueStateToClient() q.manager.wsEventManager.SendEvent(events.SyncLocalFinished, nil) q.shouldUpdateLocalCollections = false select { case q.doneUpdatingLocalCollections <- struct{}{}: default: } } } } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (q *Syncer) GetQueueState() QueueState { return q.queueState } func (q *Syncer) SendQueueStateToClient() { q.manager.wsEventManager.SendEvent(events.SyncLocalQueueState, q.GetQueueState()) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // synchronizeCollections should be called after the tracked anime & manga snapshots have been updated. // The ManagerImpl.animeCollection and ManagerImpl.mangaCollection should be set & up-to-date. // Instead of modifying the local collections directly, we create new collections that mirror the remote collections, but with up-to-date data. func (q *Syncer) synchronizeCollections() (err error) { defer util.HandlePanicInModuleWithError("sync/synchronizeCollections", &err) q.manager.loadTrackedMedia() // DEVNOTE: "_" prefix = original/remote collection // We shouldn't modify the remote collection, so making sure we get new pointers q.manager.logger.Trace().Msg("local manager: Synchronizing local collections") _animeCollection := q.manager.animeCollection.MustGet() _mangaCollection := q.manager.mangaCollection.MustGet() // Get up-to-date snapshots animeSnapshots, _ := q.manager.localDb.GetAnimeSnapshots() mangaSnapshots, _ := q.manager.localDb.GetMangaSnapshots() animeSnapshotMap := make(map[int]*AnimeSnapshot) for _, snapshot := range animeSnapshots { animeSnapshotMap[snapshot.MediaId] = snapshot } mangaSnapshotMap := make(map[int]*MangaSnapshot) for _, snapshot := range mangaSnapshots { mangaSnapshotMap[snapshot.MediaId] = snapshot } localAnimeCollection := &anilist.AnimeCollection{ MediaListCollection: &anilist.AnimeCollection_MediaListCollection{ Lists: []*anilist.AnimeCollection_MediaListCollection_Lists{}, }, } localMangaCollection := &anilist.MangaCollection{ MediaListCollection: &anilist.MangaCollection_MediaListCollection{ Lists: []*anilist.MangaCollection_MediaListCollection_Lists{}, }, } // Re-create all anime collection lists, without entries for _, _animeList := range _animeCollection.MediaListCollection.GetLists() { if _animeList.GetStatus() == nil { continue } list := &anilist.AnimeCollection_MediaListCollection_Lists{ Status: ToNewPointer(_animeList.Status), Name: ToNewPointer(_animeList.Name), IsCustomList: ToNewPointer(_animeList.IsCustomList), Entries: []*anilist.AnimeListEntry{}, } localAnimeCollection.MediaListCollection.Lists = append(localAnimeCollection.MediaListCollection.Lists, list) } // Re-create all manga collection lists, without entries for _, _mangaList := range _mangaCollection.MediaListCollection.GetLists() { if _mangaList.GetStatus() == nil { continue } list := &anilist.MangaCollection_MediaListCollection_Lists{ Status: ToNewPointer(_mangaList.Status), Name: ToNewPointer(_mangaList.Name), IsCustomList: ToNewPointer(_mangaList.IsCustomList), Entries: []*anilist.MangaListEntry{}, } localMangaCollection.MediaListCollection.Lists = append(localMangaCollection.MediaListCollection.Lists, list) } //visited := make(map[int]struct{}) if len(animeSnapshots) > 0 { // Create local anime collection for _, _animeList := range _animeCollection.MediaListCollection.GetLists() { if _animeList.GetStatus() == nil { continue } for _, _animeEntry := range _animeList.GetEntries() { // Check if the anime is tracked _, found := q.trackedAnimeMap[_animeEntry.GetMedia().GetID()] if !found { continue } // Get the anime snapshot snapshot, found := animeSnapshotMap[_animeEntry.GetMedia().GetID()] if !found { continue } // Add the anime to the right list for _, list := range localAnimeCollection.MediaListCollection.GetLists() { if list.GetStatus() == nil { continue } if *list.GetStatus() != *_animeList.GetStatus() { continue } editedAnime := BaseAnimeDeepCopy(_animeEntry.GetMedia()) editedAnime.BannerImage = FormatAssetUrl(snapshot.MediaId, snapshot.BannerImagePath) editedAnime.CoverImage = &anilist.BaseAnime_CoverImage{ ExtraLarge: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), Large: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), Medium: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), Color: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), } var startedAt *anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt if _animeEntry.GetStartedAt() != nil { startedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{ Year: ToNewPointer(_animeEntry.GetStartedAt().GetYear()), Month: ToNewPointer(_animeEntry.GetStartedAt().GetMonth()), Day: ToNewPointer(_animeEntry.GetStartedAt().GetDay()), } } var completedAt *anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt if _animeEntry.GetCompletedAt() != nil { completedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{ Year: ToNewPointer(_animeEntry.GetCompletedAt().GetYear()), Month: ToNewPointer(_animeEntry.GetCompletedAt().GetMonth()), Day: ToNewPointer(_animeEntry.GetCompletedAt().GetDay()), } } entry := &anilist.AnimeListEntry{ ID: _animeEntry.GetID(), Score: ToNewPointer(_animeEntry.GetScore()), Progress: ToNewPointer(_animeEntry.GetProgress()), Status: ToNewPointer(_animeEntry.GetStatus()), Notes: ToNewPointer(_animeEntry.GetNotes()), Repeat: ToNewPointer(_animeEntry.GetRepeat()), Private: ToNewPointer(_animeEntry.GetPrivate()), StartedAt: startedAt, CompletedAt: completedAt, Media: editedAnime, } list.Entries = append(list.Entries, entry) break } } } } if len(mangaSnapshots) > 0 { // Create local manga collection for _, _mangaList := range _mangaCollection.MediaListCollection.GetLists() { if _mangaList.GetStatus() == nil { continue } for _, _mangaEntry := range _mangaList.GetEntries() { // Check if the manga is tracked _, found := q.trackedMangaMap[_mangaEntry.GetMedia().GetID()] if !found { continue } // Get the manga snapshot snapshot, found := mangaSnapshotMap[_mangaEntry.GetMedia().GetID()] if !found { continue } // Add the manga to the right list for _, list := range localMangaCollection.MediaListCollection.GetLists() { if list.GetStatus() == nil { continue } if *list.GetStatus() != *_mangaList.GetStatus() { continue } editedManga := BaseMangaDeepCopy(_mangaEntry.GetMedia()) editedManga.BannerImage = FormatAssetUrl(snapshot.MediaId, snapshot.BannerImagePath) editedManga.CoverImage = &anilist.BaseManga_CoverImage{ ExtraLarge: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), Large: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), Medium: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), Color: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), } var startedAt *anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt if _mangaEntry.GetStartedAt() != nil { startedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt{ Year: ToNewPointer(_mangaEntry.GetStartedAt().GetYear()), Month: ToNewPointer(_mangaEntry.GetStartedAt().GetMonth()), Day: ToNewPointer(_mangaEntry.GetStartedAt().GetDay()), } } var completedAt *anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt if _mangaEntry.GetCompletedAt() != nil { completedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{ Year: ToNewPointer(_mangaEntry.GetCompletedAt().GetYear()), Month: ToNewPointer(_mangaEntry.GetCompletedAt().GetMonth()), Day: ToNewPointer(_mangaEntry.GetCompletedAt().GetDay()), } } entry := &anilist.MangaListEntry{ ID: _mangaEntry.GetID(), Score: ToNewPointer(_mangaEntry.GetScore()), Progress: ToNewPointer(_mangaEntry.GetProgress()), Status: ToNewPointer(_mangaEntry.GetStatus()), Notes: ToNewPointer(_mangaEntry.GetNotes()), Repeat: ToNewPointer(_mangaEntry.GetRepeat()), Private: ToNewPointer(_mangaEntry.GetPrivate()), StartedAt: startedAt, CompletedAt: completedAt, Media: editedManga, } list.Entries = append(list.Entries, entry) break } } } } // Save the local collections err = q.manager.localDb.SaveAnimeCollection(localAnimeCollection) if err != nil { return err } err = q.manager.localDb.SaveMangaCollection(localMangaCollection) if err != nil { return err } q.manager.loadLocalAnimeCollection() q.manager.loadLocalMangaCollection() q.manager.logger.Debug().Msg("local manager: Synchronized local collections") return nil } //---------------------------------------------------------------------------------------------------------------------------------------------------- func (q *Syncer) sendAnimeToFailedQueue(entry *anilist.AnimeListEntry) { q.failedAnimeQueue.Set(entry.Media.ID, entry) } func (q *Syncer) sendMangaToFailedQueue(entry *anilist.MangaListEntry) { q.failedMangaQueue.Set(entry.Media.ID, entry) } //---------------------------------------------------------------------------------------------------------------------------------------------------- func (q *Syncer) refreshCollections() { q.manager.logger.Trace().Msg("local manager: Refreshing collections") if len(q.animeJobQueue) > 0 || len(q.mangaJobQueue) > 0 { q.manager.logger.Trace().Msg("local manager: Skipping refreshCollections, job queues are not empty") return } q.shouldUpdateLocalCollections = true q.checkAndUpdateLocalCollections() } // runDiffs runs the diffing process to find outdated anime & manga. // The diffs are then added to the job queues for synchronization. func (q *Syncer) runDiffs( trackedAnimeMap map[int]*TrackedMedia, trackedAnimeSnapshotMap map[int]*AnimeSnapshot, trackedMangaMap map[int]*TrackedMedia, trackedMangaSnapshotMap map[int]*MangaSnapshot, localFiles []*anime.LocalFile, downloadedChapterContainers []*manga.ChapterContainer, ) { q.mu.Lock() defer q.mu.Unlock() q.manager.logger.Trace().Msg("local manager: Running diffs") if q.manager.animeCollection.IsAbsent() { q.manager.logger.Error().Msg("local manager: Cannot get diffs, anime collection is absent") return } if q.manager.mangaCollection.IsAbsent() { q.manager.logger.Error().Msg("local manager: Cannot get diffs, manga collection is absent") return } if len(q.animeJobQueue) > 0 || len(q.mangaJobQueue) > 0 { q.manager.logger.Trace().Msg("local manager: Skipping diffs, job queues are not empty") return } diff := &Diff{ Logger: q.manager.logger, } wg := sync.WaitGroup{} wg.Add(2) var animeDiffs map[int]*AnimeDiffResult go func() { animeDiffs = diff.GetAnimeDiffs(GetAnimeDiffOptions{ Collection: q.manager.animeCollection.MustGet(), LocalCollection: q.manager.localAnimeCollection, LocalFiles: localFiles, TrackedAnime: trackedAnimeMap, Snapshots: trackedAnimeSnapshotMap, }) wg.Done() //q.manager.logger.Trace().Msg("local manager: Finished getting anime diffs") }() var mangaDiffs map[int]*MangaDiffResult go func() { mangaDiffs = diff.GetMangaDiffs(GetMangaDiffOptions{ Collection: q.manager.mangaCollection.MustGet(), LocalCollection: q.manager.localMangaCollection, DownloadedChapterContainers: downloadedChapterContainers, TrackedManga: trackedMangaMap, Snapshots: trackedMangaSnapshotMap, }) wg.Done() //q.manager.logger.Trace().Msg("local manager: Finished getting manga diffs") }() wg.Wait() // Add the diffs to be synced asynchronously go func() { q.manager.logger.Trace().Int("animeJobs", len(animeDiffs)).Int("mangaJobs", len(mangaDiffs)).Msg("local manager: Adding diffs to the job queues") for _, i := range animeDiffs { q.animeJobQueue <- AnimeTask{Diff: i} } for _, i := range mangaDiffs { q.mangaJobQueue <- MangaTask{Diff: i} } if len(animeDiffs) == 0 && len(mangaDiffs) == 0 { q.manager.logger.Trace().Msg("local manager: No diffs found") //q.refreshCollections() } }() // Done q.manager.logger.Trace().Msg("local manager: Done running diffs") } //---------------------------------------------------------------------------------------------------------------------------------------------------- // synchronizeAnime creates or updates the anime snapshot in the local database. // The anime should be tracked. // - If the anime has no local files, it will be removed entirely from the local database. // - If the anime has local files, we create or update the snapshot. func (q *Syncer) synchronizeAnime(diff *AnimeDiffResult) { defer util.HandlePanicInModuleThen("sync/synchronizeAnime", func() {}) entry := diff.AnimeEntry if entry == nil { return } q.manager.logger.Trace().Msgf("local manager: Starting synchronization of anime %d, diff type: %+v", entry.Media.ID, diff.DiffType) lfs := lo.Filter(q.manager.localFiles, func(f *anime.LocalFile, _ int) bool { return f.MediaId == entry.Media.ID }) // If the anime (which is tracked) has no local files, remove it entirely from the local database if len(lfs) == 0 { q.manager.logger.Warn().Msgf("local manager: No local files found for anime %d, removing from the local database", entry.Media.ID) _ = q.manager.removeAnime(entry.Media.ID) return } var animeMetadata *metadata.AnimeMetadata var metadataWrapper metadata.AnimeMetadataWrapper if diff.DiffType == DiffTypeMissing || diff.DiffType == DiffTypeMetadata { // Get the anime metadata var err error animeMetadata, err = q.manager.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, entry.Media.ID) if err != nil { q.sendAnimeToFailedQueue(entry) q.manager.logger.Error().Err(err).Msgf("local manager: Failed to get metadata for anime %d", entry.Media.ID) return } metadataWrapper = q.manager.metadataProvider.GetAnimeMetadataWrapper(diff.AnimeEntry.Media, animeMetadata) } // // The snapshot is missing // if diff.DiffType == DiffTypeMissing && animeMetadata != nil { bannerImage, coverImage, episodeImagePaths, ok := DownloadAnimeImages(q.manager.logger, q.manager.localAssetsDir, entry, animeMetadata, metadataWrapper, lfs) if !ok { q.sendAnimeToFailedQueue(entry) return } // Create a new snapshot snapshot := &AnimeSnapshot{ MediaId: entry.GetMedia().GetID(), AnimeMetadata: LocalAnimeMetadata(*animeMetadata), BannerImagePath: bannerImage, CoverImagePath: coverImage, EpisodeImagePaths: episodeImagePaths, ReferenceKey: GetAnimeReferenceKey(entry.GetMedia(), q.manager.localFiles), } // Save the snapshot err := q.manager.localDb.SaveAnimeSnapshot(snapshot) if err != nil { q.sendAnimeToFailedQueue(entry) q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save anime snapshot for anime %d", entry.GetMedia().GetID()) } return } // // The snapshot metadata is outdated (local files have changed) // Update the anime metadata & download the new episode images if needed // if diff.DiffType == DiffTypeMetadata && diff.AnimeSnapshot != nil && animeMetadata != nil { snapshot := *diff.AnimeSnapshot snapshot.AnimeMetadata = LocalAnimeMetadata(*animeMetadata) snapshot.ReferenceKey = GetAnimeReferenceKey(entry.GetMedia(), q.manager.localFiles) // Get the current episode image URLs currentEpisodeImageUrls := make(map[string]string) for episodeNum, episode := range animeMetadata.Episodes { if episode.Image == "" { continue } currentEpisodeImageUrls[episodeNum] = episode.Image } // Get the episode image URLs that we need to download (i.e. the ones that are not in the snapshot) episodeImageUrlsToDownload := make(map[string]string) // For each current episode image URL, check if the key (episode number) is in the snapshot for episodeNum, episodeImageUrl := range currentEpisodeImageUrls { if _, found := snapshot.EpisodeImagePaths[episodeNum]; !found { episodeImageUrlsToDownload[episodeNum] = episodeImageUrl } } // Download the episode images if needed if len(episodeImageUrlsToDownload) > 0 { // Download only the episode images that we need to download episodeImagePaths, ok := DownloadAnimeEpisodeImages(q.manager.logger, q.manager.localAssetsDir, entry.GetMedia().GetID(), episodeImageUrlsToDownload) if !ok { // DownloadAnimeEpisodeImages will log the error q.sendAnimeToFailedQueue(entry) return } // Update the snapshot by adding the new episode images for episodeNum, episodeImagePath := range episodeImagePaths { snapshot.EpisodeImagePaths[episodeNum] = episodeImagePath } } // Save the snapshot err := q.manager.localDb.SaveAnimeSnapshot(&snapshot) if err != nil { q.sendAnimeToFailedQueue(entry) q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save anime snapshot for anime %d", entry.GetMedia().GetID()) } return } // The snapshot is up-to-date return } // synchronizeManga creates or updates the manga snapshot in the local database. // We know that the manga is tracked. // - If the manga has no chapter containers, it will be removed entirely from the local database. // - If the manga has chapter containers, we create or update the snapshot. func (q *Syncer) synchronizeManga(diff *MangaDiffResult) { defer util.HandlePanicInModuleThen("sync/synchronizeManga", func() {}) entry := diff.MangaEntry if entry == nil { return } q.manager.logger.Trace().Msgf("local manager: Starting synchronization of manga %d, diff type: %+v", entry.GetMedia().GetID(), diff.DiffType) if q.manager.mangaCollection.IsAbsent() { return } eContainers := make([]*manga.ChapterContainer, 0) // Get the manga listEntry, ok := q.manager.mangaCollection.MustGet().GetListEntryFromMangaId(entry.GetMedia().GetID()) if !ok { q.manager.logger.Error().Msgf("local manager: Failed to get manga") return } if listEntry.GetStatus() == nil { return } // Get all chapter containers for this manga // A manga entry can have multiple chapter containers due to different sources for _, c := range q.manager.downloadedChapterContainers { if c.MediaId == entry.GetMedia().GetID() { eContainers = append(eContainers, c) } } // If there are no chapter containers (they may have been deleted), remove the manga from the local database if len(eContainers) == 0 { _ = q.manager.removeManga(entry.GetMedia().GetID()) return } if diff.DiffType == DiffTypeMissing { bannerImage, coverImage, ok := DownloadMangaImages(q.manager.logger, q.manager.localAssetsDir, entry) if !ok { q.sendMangaToFailedQueue(entry) return } // Create a new snapshot snapshot := &MangaSnapshot{ MediaId: entry.GetMedia().GetID(), ChapterContainers: eContainers, BannerImagePath: bannerImage, CoverImagePath: coverImage, ReferenceKey: GetMangaReferenceKey(entry.GetMedia(), eContainers), } // Save the snapshot err := q.manager.localDb.SaveMangaSnapshot(snapshot) if err != nil { q.sendMangaToFailedQueue(entry) q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save manga snapshot for manga %d", entry.GetMedia().GetID()) } return } if diff.DiffType == DiffTypeMetadata && diff.MangaSnapshot != nil { snapshot := *diff.MangaSnapshot // Update the snapshot snapshot.ChapterContainers = eContainers snapshot.ReferenceKey = GetMangaReferenceKey(entry.GetMedia(), eContainers) // Save the snapshot err := q.manager.localDb.SaveMangaSnapshot(&snapshot) if err != nil { q.sendMangaToFailedQueue(entry) q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save manga snapshot for manga %d", entry.GetMedia().GetID()) } return } // The snapshot is up-to-date return }