package continuity import ( "fmt" "seanime/internal/database/db_bridge" "seanime/internal/hook" "seanime/internal/library/anime" "seanime/internal/util" "seanime/internal/util/filecache" "strconv" "strings" "time" ) const ( MaxWatchHistoryItems = 100 IgnoreRatioThreshold = 0.9 WatchHistoryBucketName = "watch_history" ) type ( // WatchHistory is a map of WatchHistoryItem. // The key is the WatchHistoryItem.MediaId. WatchHistory map[int]*WatchHistoryItem // WatchHistoryItem are stored in the file cache. // The history is used to resume playback from the last known position. // Item.MediaId and Item.EpisodeNumber are used to identify the media and episode. // Only one Item per MediaId should exist in the history. WatchHistoryItem struct { Kind Kind `json:"kind"` // Used for MediastreamKind and ExternalPlayerKind. Filepath string `json:"filepath"` MediaId int `json:"mediaId"` EpisodeNumber int `json:"episodeNumber"` // The current playback time in seconds. // Used to determine when to remove the item from the history. CurrentTime float64 `json:"currentTime"` // The duration of the media in seconds. Duration float64 `json:"duration"` // Timestamp of when the item was added to the history. TimeAdded time.Time `json:"timeAdded"` // TimeAdded is used in conjunction with TimeUpdated // Timestamp of when the item was last updated. // Used to determine when to remove the item from the history (First in, first out). TimeUpdated time.Time `json:"timeUpdated"` } WatchHistoryItemResponse struct { Item *WatchHistoryItem `json:"item"` Found bool `json:"found"` } UpdateWatchHistoryItemOptions struct { CurrentTime float64 `json:"currentTime"` Duration float64 `json:"duration"` MediaId int `json:"mediaId"` EpisodeNumber int `json:"episodeNumber"` Filepath string `json:"filepath,omitempty"` Kind Kind `json:"kind"` } ) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (m *Manager) GetWatchHistory() WatchHistory { defer util.HandlePanicInModuleThen("continuity/GetWatchHistory", func() {}) m.mu.RLock() defer m.mu.RUnlock() items, err := filecache.GetAll[*WatchHistoryItem](m.fileCacher, *m.watchHistoryFileCacheBucket) if err != nil { m.logger.Error().Err(err).Msg("continuity: Failed to get watch history") return nil } ret := make(WatchHistory) for _, item := range items { ret[item.MediaId] = item } return ret } func (m *Manager) GetWatchHistoryItem(mediaId int) *WatchHistoryItemResponse { defer util.HandlePanicInModuleThen("continuity/GetWatchHistoryItem", func() {}) m.mu.RLock() defer m.mu.RUnlock() i, found := m.getWatchHistory(mediaId) return &WatchHistoryItemResponse{ Item: i, Found: found, } } // UpdateWatchHistoryItem updates the WatchHistoryItem in the file cache. func (m *Manager) UpdateWatchHistoryItem(opts *UpdateWatchHistoryItemOptions) (err error) { defer util.HandlePanicInModuleWithError("continuity/UpdateWatchHistoryItem", &err) m.mu.Lock() defer m.mu.Unlock() added := false // Get the current history i, found := m.getWatchHistory(opts.MediaId) if !found { added = true i = &WatchHistoryItem{ Kind: opts.Kind, Filepath: opts.Filepath, MediaId: opts.MediaId, EpisodeNumber: opts.EpisodeNumber, CurrentTime: opts.CurrentTime, Duration: opts.Duration, TimeAdded: time.Now(), TimeUpdated: time.Now(), } } else { i.Kind = opts.Kind i.EpisodeNumber = opts.EpisodeNumber i.CurrentTime = opts.CurrentTime i.Duration = opts.Duration i.TimeUpdated = time.Now() } // Save the i err = m.fileCacher.Set(*m.watchHistoryFileCacheBucket, strconv.Itoa(opts.MediaId), i) if err != nil { return fmt.Errorf("continuity: Failed to save watch history item: %w", err) } _ = hook.GlobalHookManager.OnWatchHistoryItemUpdated().Trigger(&WatchHistoryItemUpdatedEvent{ WatchHistoryItem: i, }) // If the item was added, check if we need to remove the oldest item if added { _ = m.trimWatchHistoryItems() } return nil } func (m *Manager) DeleteWatchHistoryItem(mediaId int) (err error) { defer util.HandlePanicInModuleWithError("continuity/DeleteWatchHistoryItem", &err) m.mu.Lock() defer m.mu.Unlock() err = m.fileCacher.Delete(*m.watchHistoryFileCacheBucket, strconv.Itoa(mediaId)) if err != nil { return fmt.Errorf("continuity: Failed to delete watch history item: %w", err) } return nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // GetExternalPlayerEpisodeWatchHistoryItem is called before launching the external player to get the last known position. // Unlike GetWatchHistoryItem, this checks if the episode numbers match. func (m *Manager) GetExternalPlayerEpisodeWatchHistoryItem(path string, isStream bool, episode, mediaId int) (ret *WatchHistoryItemResponse) { defer util.HandlePanicInModuleThen("continuity/GetExternalPlayerEpisodeWatchHistoryItem", func() {}) m.mu.RLock() defer m.mu.RUnlock() if !m.settings.WatchContinuityEnabled { return &WatchHistoryItemResponse{ Item: nil, Found: false, } } ret = &WatchHistoryItemResponse{ Item: nil, Found: false, } m.logger.Debug(). Str("path", path). Bool("isStream", isStream). Int("episode", episode). Int("mediaId", mediaId). Msg("continuity: Retrieving watch history item") // Normalize path path = util.NormalizePath(path) if isStream { event := &WatchHistoryStreamEpisodeItemRequestedEvent{ WatchHistoryItem: &WatchHistoryItem{}, } hook.GlobalHookManager.OnWatchHistoryStreamEpisodeItemRequested().Trigger(event) if event.DefaultPrevented { return &WatchHistoryItemResponse{ Item: event.WatchHistoryItem, Found: event.WatchHistoryItem != nil, } } if episode == 0 || mediaId == 0 { m.logger.Debug(). Int("episode", episode). Int("mediaId", mediaId). Msg("continuity: No episode or media provided") return } i, found := m.getWatchHistory(mediaId) if !found || i.EpisodeNumber != episode { m.logger.Trace(). Interface("item", i). Msg("continuity: No watch history item found or episode number does not match") return } m.logger.Debug(). Interface("item", i). Msg("continuity: Watch history item found") return &WatchHistoryItemResponse{ Item: i, Found: found, } } else { // Find the local file from the path lfs, _, err := db_bridge.GetLocalFiles(m.db) if err != nil { return ret } event := &WatchHistoryLocalFileEpisodeItemRequestedEvent{ Path: path, LocalFiles: lfs, WatchHistoryItem: &WatchHistoryItem{}, } hook.GlobalHookManager.OnWatchHistoryLocalFileEpisodeItemRequested().Trigger(event) if event.DefaultPrevented { return &WatchHistoryItemResponse{ Item: event.WatchHistoryItem, Found: event.WatchHistoryItem != nil, } } var lf *anime.LocalFile // Find the local file from the path for _, l := range lfs { if l.GetNormalizedPath() == path { lf = l m.logger.Trace().Msg("continuity: Local file found from path") break } } // If the local file is not found, the path might be a filename (in the case of VLC) if lf == nil { for _, l := range lfs { if strings.ToLower(l.Name) == path { lf = l m.logger.Trace().Msg("continuity: Local file found from filename") break } } } if lf == nil || lf.MediaId == 0 || !lf.IsMain() { m.logger.Trace().Msg("continuity: Local file not found or not main") return } i, found := m.getWatchHistory(lf.MediaId) if !found || i.EpisodeNumber != lf.GetEpisodeNumber() { m.logger.Trace(). Interface("item", i). Msg("continuity: No watch history item found or episode number does not match") return } m.logger.Debug(). Interface("item", i). Msg("continuity: Watch history item found") return &WatchHistoryItemResponse{ Item: i, Found: found, } } } func (m *Manager) UpdateExternalPlayerEpisodeWatchHistoryItem(currentTime, duration float64) { defer util.HandlePanicInModuleThen("continuity/UpdateWatchHistoryItem", func() {}) m.mu.Lock() defer m.mu.Unlock() if !m.settings.WatchContinuityEnabled { return } if m.externalPlayerEpisodeDetails.IsAbsent() { return } added := false opts, ok := m.externalPlayerEpisodeDetails.Get() if !ok { return } // Get the current history i, found := m.getWatchHistory(opts.MediaId) if !found { added = true i = &WatchHistoryItem{ Kind: ExternalPlayerKind, Filepath: opts.Filepath, MediaId: opts.MediaId, EpisodeNumber: opts.EpisodeNumber, CurrentTime: currentTime, Duration: duration, TimeAdded: time.Now(), TimeUpdated: time.Now(), } } else { i.Kind = ExternalPlayerKind i.EpisodeNumber = opts.EpisodeNumber i.CurrentTime = currentTime i.Duration = duration i.TimeUpdated = time.Now() } // Save the i _ = m.fileCacher.Set(*m.watchHistoryFileCacheBucket, strconv.Itoa(opts.MediaId), i) // If the item was added, check if we need to remove the oldest item if added { _ = m.trimWatchHistoryItems() } return } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (m *Manager) getWatchHistory(mediaId int) (ret *WatchHistoryItem, exists bool) { defer util.HandlePanicInModuleThen("continuity/getWatchHistory", func() { ret = nil exists = false }) reqEvent := &WatchHistoryItemRequestedEvent{ MediaId: mediaId, WatchHistoryItem: ret, } hook.GlobalHookManager.OnWatchHistoryItemRequested().Trigger(reqEvent) ret = reqEvent.WatchHistoryItem if reqEvent.DefaultPrevented { return reqEvent.WatchHistoryItem, reqEvent.WatchHistoryItem != nil } exists, _ = m.fileCacher.Get(*m.watchHistoryFileCacheBucket, strconv.Itoa(mediaId), &ret) if exists && ret != nil && ret.Duration > 0 { // If the item completion ratio is equal or above IgnoreRatioThreshold, don't return anything ratio := ret.CurrentTime / ret.Duration if ratio >= IgnoreRatioThreshold { // Delete the item go func() { defer util.HandlePanicInModuleThen("continuity/getWatchHistory", func() {}) _ = m.fileCacher.Delete(*m.watchHistoryFileCacheBucket, strconv.Itoa(mediaId)) }() return nil, false } if ratio < 0.05 { return nil, false } } return } // removes the oldest WatchHistoryItem from the file cache. func (m *Manager) trimWatchHistoryItems() error { defer util.HandlePanicInModuleThen("continuity/TrimWatchHistoryItems", func() {}) // Get all the items items, err := filecache.GetAll[*WatchHistoryItem](m.fileCacher, *m.watchHistoryFileCacheBucket) if err != nil { return fmt.Errorf("continuity: Failed to get watch history items: %w", err) } // If there are too many items, remove the oldest one if len(items) > MaxWatchHistoryItems { var oldestKey string for key := range items { if oldestKey == "" || items[key].TimeUpdated.Before(items[oldestKey].TimeUpdated) { oldestKey = key } } err = m.fileCacher.Delete(*m.watchHistoryFileCacheBucket, oldestKey) if err != nil { return fmt.Errorf("continuity: Failed to remove oldest watch history item: %w", err) } } return nil } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////