Files
seanime-docker/seanime-2.9.10/internal/continuity/history.go
2025-09-20 14:08:38 +01:00

419 lines
11 KiB
Go

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
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////