node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,78 @@
package local
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"github.com/rs/zerolog"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type Database struct {
gormdb *gorm.DB
logger *zerolog.Logger
}
func newLocalSyncDatabase(appDataDir, dbName string, logger *zerolog.Logger) (*Database, error) {
// Set the SQLite database path
var sqlitePath string
if os.Getenv("TEST_ENV") == "true" {
sqlitePath = ":memory:"
} else {
sqlitePath = filepath.Join(appDataDir, dbName+".db")
}
// Connect to the SQLite database
db, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{
Logger: gormlogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormlogger.Config{
SlowThreshold: time.Second,
LogLevel: gormlogger.Error,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: false,
Colorful: true,
},
),
})
if err != nil {
return nil, err
}
// Migrate tables
err = migrateTables(db)
if err != nil {
logger.Fatal().Err(err).Msg("local platform: Failed to perform auto migration")
return nil, err
}
logger.Info().Str("name", fmt.Sprintf("%s.db", dbName)).Msg("local platform: Database instantiated")
return &Database{
gormdb: db,
logger: logger,
}, nil
}
// MigrateTables performs auto migration on the database
func migrateTables(db *gorm.DB) error {
err := db.AutoMigrate(
&Settings{},
&LocalCollection{},
&SimulatedCollection{},
&AnimeSnapshot{},
&MangaSnapshot{},
&TrackedMedia{},
)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,242 @@
package local
import (
"seanime/internal/api/anilist"
"github.com/goccy/go-json"
)
var CurrSettings *Settings
func (ldb *Database) SaveSettings(s *Settings) error {
s.BaseModel.ID = 1
CurrSettings = nil
return ldb.gormdb.Save(s).Error
}
func (ldb *Database) GetSettings() *Settings {
if CurrSettings != nil {
return CurrSettings
}
var s Settings
err := ldb.gormdb.First(&s).Error
if err != nil {
_ = ldb.SaveSettings(&Settings{
BaseModel: BaseModel{
ID: 1,
},
Updated: false,
})
return &Settings{
BaseModel: BaseModel{
ID: 1,
},
Updated: false,
}
}
return &s
}
func (ldb *Database) SetTrackedMedia(sm *TrackedMedia) error {
return ldb.gormdb.Save(sm).Error
}
// GetTrackedMedia returns the tracked media with the given mediaId and kind.
// This should only be used when adding/removing tracked media.
func (ldb *Database) GetTrackedMedia(mediaId int, kind string) (*TrackedMedia, bool) {
var sm TrackedMedia
err := ldb.gormdb.Where("media_id = ? AND type = ?", mediaId, kind).First(&sm).Error
return &sm, err == nil
}
func (ldb *Database) GetAllTrackedMediaByType(kind string) ([]*TrackedMedia, bool) {
var sm []*TrackedMedia
err := ldb.gormdb.Where("type = ?", kind).Find(&sm).Error
return sm, err == nil
}
func (ldb *Database) GetAllTrackedMedia() ([]*TrackedMedia, bool) {
var sm []*TrackedMedia
err := ldb.gormdb.Find(&sm).Error
return sm, err == nil
}
func (ldb *Database) RemoveTrackedMedia(mediaId int, kind string) error {
return ldb.gormdb.Where("media_id = ? AND type = ?", mediaId, kind).Delete(&TrackedMedia{}).Error
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
//----------------------------------------------------------------------------------------------------------------------------------------------------
func (ldb *Database) SaveAnimeSnapshot(as *AnimeSnapshot) error {
return ldb.gormdb.Save(as).Error
}
func (ldb *Database) GetAnimeSnapshot(mediaId int) (*AnimeSnapshot, bool) {
var as AnimeSnapshot
err := ldb.gormdb.Where("media_id = ?", mediaId).First(&as).Error
return &as, err == nil
}
func (ldb *Database) RemoveAnimeSnapshot(mediaId int) error {
return ldb.gormdb.Where("media_id = ?", mediaId).Delete(&AnimeSnapshot{}).Error
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
func (ldb *Database) SaveMangaSnapshot(ms *MangaSnapshot) error {
return ldb.gormdb.Save(ms).Error
}
func (ldb *Database) GetMangaSnapshot(mediaId int) (*MangaSnapshot, bool) {
var ms MangaSnapshot
err := ldb.gormdb.Where("media_id = ?", mediaId).First(&ms).Error
return &ms, err == nil
}
func (ldb *Database) RemoveMangaSnapshot(mediaId int) error {
return ldb.gormdb.Where("media_id = ?", mediaId).Delete(&MangaSnapshot{}).Error
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
//----------------------------------------------------------------------------------------------------------------------------------------------------
func (ldb *Database) GetAnimeSnapshots() ([]*AnimeSnapshot, bool) {
var as []*AnimeSnapshot
err := ldb.gormdb.Find(&as).Error
return as, err == nil
}
func (ldb *Database) GetMangaSnapshots() ([]*MangaSnapshot, bool) {
var ms []*MangaSnapshot
err := ldb.gormdb.Find(&ms).Error
return ms, err == nil
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
func (ldb *Database) SaveAnimeCollection(ac *anilist.AnimeCollection) error {
return ldb._saveLocalCollection(AnimeType, ac)
}
func (ldb *Database) SaveMangaCollection(mc *anilist.MangaCollection) error {
return ldb._saveLocalCollection(MangaType, mc)
}
func (ldb *Database) GetLocalAnimeCollection() (*anilist.AnimeCollection, bool) {
lc, ok := ldb._getLocalCollection(AnimeType)
if !ok {
return nil, false
}
var ac anilist.AnimeCollection
err := json.Unmarshal(lc.Value, &ac)
return &ac, err == nil
}
func (ldb *Database) GetLocalMangaCollection() (*anilist.MangaCollection, bool) {
lc, ok := ldb._getLocalCollection(MangaType)
if !ok {
return nil, false
}
var mc anilist.MangaCollection
err := json.Unmarshal(lc.Value, &mc)
return &mc, err == nil
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
func (ldb *Database) _getLocalCollection(collectionType string) (*LocalCollection, bool) {
var lc LocalCollection
err := ldb.gormdb.Where("type = ?", collectionType).First(&lc).Error
return &lc, err == nil
}
func (ldb *Database) _saveLocalCollection(collectionType string, value interface{}) error {
marshalledValue, err := json.Marshal(value)
if err != nil {
return err
}
// Check if collection already exists
lc, ok := ldb._getLocalCollection(collectionType)
if ok {
lc.Value = marshalledValue
return ldb.gormdb.Save(&lc).Error
}
lcN := LocalCollection{
Type: collectionType,
Value: marshalledValue,
}
return ldb.gormdb.Save(&lcN).Error
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Simulated collections
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (ldb *Database) _getSimulatedCollection(collectionType string) (*SimulatedCollection, bool) {
var lc SimulatedCollection
err := ldb.gormdb.Where("type = ?", collectionType).First(&lc).Error
return &lc, err == nil
}
func (ldb *Database) _saveSimulatedCollection(collectionType string, value interface{}) error {
marshalledValue, err := json.Marshal(value)
if err != nil {
return err
}
// Check if collection already exists
lc, ok := ldb._getSimulatedCollection(collectionType)
if ok {
lc.Value = marshalledValue
return ldb.gormdb.Save(&lc).Error
}
lcN := SimulatedCollection{
Type: collectionType,
Value: marshalledValue,
}
return ldb.gormdb.Save(&lcN).Error
}
func (ldb *Database) SaveSimulatedAnimeCollection(ac *anilist.AnimeCollection) error {
return ldb._saveSimulatedCollection(AnimeType, ac)
}
func (ldb *Database) SaveSimulatedMangaCollection(mc *anilist.MangaCollection) error {
return ldb._saveSimulatedCollection(MangaType, mc)
}
func (ldb *Database) GetSimulatedAnimeCollection() (*anilist.AnimeCollection, bool) {
lc, ok := ldb._getSimulatedCollection(AnimeType)
if !ok {
return nil, false
}
var ac anilist.AnimeCollection
err := json.Unmarshal(lc.Value, &ac)
return &ac, err == nil
}
func (ldb *Database) GetSimulatedMangaCollection() (*anilist.MangaCollection, bool) {
lc, ok := ldb._getSimulatedCollection(MangaType)
if !ok {
return nil, false
}
var mc anilist.MangaCollection
err := json.Unmarshal(lc.Value, &mc)
return &mc, err == nil
}

View File

@@ -0,0 +1,198 @@
package local
import (
"database/sql/driver"
"errors"
"seanime/internal/api/metadata"
"seanime/internal/manga"
"time"
"github.com/goccy/go-json"
)
type BaseModel struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type Settings struct {
BaseModel
// Flag to determine if there are local changes that need to be synced with AniList.
Updated bool `gorm:"column:updated" json:"updated"`
}
// +---------------------+
// | Offline |
// +---------------------+
// LocalCollection is an anilist collection that is stored locally for offline use.
// It is meant to be kept in sync with the real AniList collection when online.
type LocalCollection struct {
BaseModel
Type string `gorm:"column:type" json:"type"` // "anime" or "manga"
Value []byte `gorm:"column:value" json:"value"` // Marshalled struct
}
// TrackedMedia tracks media that should be stored locally.
type TrackedMedia struct {
BaseModel
MediaId int `gorm:"column:media_id" json:"mediaId"`
Type string `gorm:"column:type" json:"type"` // "anime" or "manga"
}
type AnimeSnapshot struct {
BaseModel
MediaId int `gorm:"column:media_id" json:"mediaId"`
//ListEntry LocalAnimeListEntry `gorm:"column:list_entry" json:"listEntry"`
AnimeMetadata LocalAnimeMetadata `gorm:"column:anime_metadata" json:"animeMetadata"`
BannerImagePath string `gorm:"column:banner_image_path" json:"bannerImagePath"`
CoverImagePath string `gorm:"column:cover_image_path" json:"coverImagePath"`
EpisodeImagePaths StringMap `gorm:"column:episode_image_paths" json:"episodeImagePaths"`
// ReferenceKey is used to compare the snapshot with the current data.
ReferenceKey string `gorm:"column:reference_key" json:"referenceKey"`
}
type MangaSnapshot struct {
BaseModel
MediaId int `gorm:"column:media_id" json:"mediaId"`
//ListEntry LocalMangaListEntry `gorm:"column:list_entry" json:"listEntry"`
ChapterContainers LocalMangaChapterContainers `gorm:"column:chapter_Containers" json:"chapterContainers"`
BannerImagePath string `gorm:"column:banner_image_path" json:"bannerImagePath"`
CoverImagePath string `gorm:"column:cover_image_path" json:"coverImagePath"`
// ReferenceKey is used to compare the snapshot with the current data.
ReferenceKey string `gorm:"column:reference_key" json:"referenceKey"`
}
// +---------------------+
// | Simulated |
// +---------------------+
// SimulatedCollection is used for users without an account.
type SimulatedCollection struct {
BaseModel
Type string `gorm:"column:type" json:"type"` // "anime" or "manga"
Value []byte `gorm:"column:value" json:"value"` // Marshalled struct
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type StringMap map[string]string
func (o *StringMap) Scan(src interface{}) error {
bytes, ok := src.([]byte)
if !ok {
return errors.New("src value cannot cast to []byte")
}
var ret map[string]string
err := json.Unmarshal(bytes, &ret)
if err != nil {
return err
}
*o = ret
return nil
}
func (o StringMap) Value() (driver.Value, error) {
return json.Marshal(o)
}
type LocalAnimeMetadata metadata.AnimeMetadata
//----------------------------------------------------------------------------------------------------------------------------------------------------
func (o *LocalAnimeMetadata) Scan(src interface{}) error {
bytes, ok := src.([]byte)
if !ok {
return errors.New("src value cannot cast to []byte")
}
var ret metadata.AnimeMetadata
err := json.Unmarshal(bytes, &ret)
if err != nil {
return err
}
*o = LocalAnimeMetadata(ret)
return nil
}
func (o LocalAnimeMetadata) Value() (driver.Value, error) {
return json.Marshal(o)
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
type LocalMangaChapterContainers []*manga.ChapterContainer
func (o *LocalMangaChapterContainers) Scan(src interface{}) error {
bytes, ok := src.([]byte)
if !ok {
return errors.New("src value cannot cast to []byte")
}
var ret []*manga.ChapterContainer
err := json.Unmarshal(bytes, &ret)
if err != nil {
return err
}
*o = LocalMangaChapterContainers(ret)
return nil
}
func (o LocalMangaChapterContainers) Value() (driver.Value, error) {
return json.Marshal(o)
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
//type LocalMangaListEntry anilist.MangaListEntry
//
//func (o *LocalMangaListEntry) Scan(src interface{}) error {
// bytes, ok := src.([]byte)
// if !ok {
// return errors.New("src value cannot cast to []byte")
// }
// var ret anilist.MangaListEntry
// err := json.Unmarshal(bytes, &ret)
// if err != nil {
// return err
// }
// *o = LocalMangaListEntry(ret)
// return nil
//}
//
//func (o LocalMangaListEntry) Value() (driver.Value, error) {
// if o.ID == 0 {
// return nil, nil
// }
// return json.Marshal(o)
//}
//----------------------------------------------------------------------------------------------------------------------------------------------------
//type LocalAnimeListEntry anilist.AnimeListEntry
//
//func (o *LocalAnimeListEntry) Scan(src interface{}) error {
// bytes, ok := src.([]byte)
// if !ok {
// return errors.New("src value cannot cast to []byte")
// }
// var ret anilist.AnimeListEntry
// err := json.Unmarshal(bytes, &ret)
// if err != nil {
// return err
// }
// *o = LocalAnimeListEntry(ret)
// return nil
//}
//
//func (o LocalAnimeListEntry) Value() (driver.Value, error) {
// if o.ID == 0 {
// return nil, nil
// }
// return json.Marshal(o)
//}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Local account
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -0,0 +1,327 @@
package local
import (
"fmt"
"seanime/internal/api/anilist"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/library/anime"
"seanime/internal/manga"
"slices"
"strings"
"github.com/rs/zerolog"
"github.com/samber/lo"
"github.com/samber/mo"
)
// DEVNOTE: Here we compare the media data from the current up-to-date collections with the local data.
// Outdated media are added to the Syncer to be updated.
// If the media doesn't have a snapshot -> a new snapshot is created.
// If the reference key is different -> the metadata is re-fetched and the snapshot is updated.
// If the list data is different -> the list data is updated.
const (
DiffTypeMissing DiffType = iota // We need to add a new snapshot
DiffTypeMetadata // We need to re-fetch the snapshot metadata (episode metadata / chapter containers), list data will be updated as well
DiffTypeListData // We need to update the list data
)
type (
Diff struct {
Logger *zerolog.Logger
}
DiffType int
)
//----------------------------------------------------------------------------------------------------------------------------------------------------
type GetAnimeDiffOptions struct {
Collection *anilist.AnimeCollection
LocalCollection mo.Option[*anilist.AnimeCollection]
LocalFiles []*anime.LocalFile
TrackedAnime map[int]*TrackedMedia
Snapshots map[int]*AnimeSnapshot
}
type AnimeDiffResult struct {
AnimeEntry *anilist.AnimeListEntry
AnimeSnapshot *AnimeSnapshot
DiffType DiffType
}
// GetAnimeDiffs returns the anime that have changed.
// The anime is considered changed if:
// - It doesn't have a snapshot
// - The reference key is different (e.g. the number of local files has changed), meaning we need to update the snapshot.
func (d *Diff) GetAnimeDiffs(opts GetAnimeDiffOptions) map[int]*AnimeDiffResult {
collection := opts.Collection
localCollection := opts.LocalCollection
trackedAnimeMap := opts.TrackedAnime
snapshotMap := opts.Snapshots
changedMap := make(map[int]*AnimeDiffResult)
if len(collection.MediaListCollection.Lists) == 0 || len(trackedAnimeMap) == 0 {
return changedMap
}
for _, _list := range collection.MediaListCollection.Lists {
if _list.GetStatus() == nil || _list.GetEntries() == nil {
continue
}
for _, _entry := range _list.GetEntries() {
// Check if the anime is tracked
_, isTracked := trackedAnimeMap[_entry.GetMedia().GetID()]
if !isTracked {
continue
}
if localCollection.IsAbsent() {
d.Logger.Trace().Msgf("local manager: Diff > Anime %d, local collection is missing", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{
AnimeEntry: _entry,
DiffType: DiffTypeMissing,
}
continue // Go to the next anime
}
// Check if the anime has a snapshot
snapshot, hasSnapshot := snapshotMap[_entry.GetMedia().GetID()]
if !hasSnapshot {
d.Logger.Trace().Msgf("local manager: Diff > Anime %d is missing a snapshot", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{
AnimeEntry: _entry,
DiffType: DiffTypeMissing,
}
continue // Go to the next anime
}
_lfs := lo.Filter(opts.LocalFiles, func(lf *anime.LocalFile, _ int) bool {
return lf.MediaId == _entry.GetMedia().GetID()
})
// Check if the anime has changed
_referenceKey := GetAnimeReferenceKey(_entry.Media, _lfs)
// Check if the reference key is different
if snapshotMap[_entry.GetMedia().GetID()].ReferenceKey != _referenceKey {
d.Logger.Trace().Str("localReferenceKey", snapshotMap[_entry.GetMedia().GetID()].ReferenceKey).Str("currentReferenceKey", _referenceKey).Msgf("local manager: Diff > Anime %d has an outdated snapshot", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{
AnimeEntry: _entry,
AnimeSnapshot: snapshot,
DiffType: DiffTypeMetadata,
}
continue // Go to the next anime
}
localEntry, found := localCollection.MustGet().GetListEntryFromAnimeId(_entry.GetMedia().GetID())
if !found {
d.Logger.Trace().Msgf("local manager: Diff > Anime %d is missing from the local collection", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{
AnimeEntry: _entry,
AnimeSnapshot: snapshot,
DiffType: DiffTypeMissing,
}
continue // Go to the next anime
}
// Check if the list data has changed
_listDataKey := GetAnimeListDataKey(_entry)
localListDataKey := GetAnimeListDataKey(localEntry)
if _listDataKey != localListDataKey {
d.Logger.Trace().Str("localListDataKey", localListDataKey).Str("currentListDataKey", _listDataKey).Msgf("local manager: Diff > Anime %d has changed list data", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{
AnimeEntry: _entry,
AnimeSnapshot: snapshot,
DiffType: DiffTypeListData,
}
continue // Go to the next anime
}
}
}
return changedMap
}
//----------------------------------------------------------------------------------------------------------------------------------------------------
type GetMangaDiffOptions struct {
Collection *anilist.MangaCollection
LocalCollection mo.Option[*anilist.MangaCollection]
DownloadedChapterContainers []*manga.ChapterContainer
TrackedManga map[int]*TrackedMedia
Snapshots map[int]*MangaSnapshot
}
type MangaDiffResult struct {
MangaEntry *anilist.MangaListEntry
MangaSnapshot *MangaSnapshot
DiffType DiffType
}
// GetMangaDiffs returns the manga that have changed.
func (d *Diff) GetMangaDiffs(opts GetMangaDiffOptions) map[int]*MangaDiffResult {
collection := opts.Collection
localCollection := opts.LocalCollection
trackedMangaMap := opts.TrackedManga
snapshotMap := opts.Snapshots
changedMap := make(map[int]*MangaDiffResult)
if len(collection.MediaListCollection.Lists) == 0 || len(trackedMangaMap) == 0 {
return changedMap
}
for _, _list := range collection.MediaListCollection.Lists {
if _list.GetStatus() == nil || _list.GetEntries() == nil {
continue
}
for _, _entry := range _list.GetEntries() {
// Check if the manga is tracked
_, isTracked := trackedMangaMap[_entry.GetMedia().GetID()]
if !isTracked {
continue
}
if localCollection.IsAbsent() {
d.Logger.Trace().Msgf("local manager: Diff > Manga %d, local collection is missing", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{
MangaEntry: _entry,
DiffType: DiffTypeMissing,
}
continue // Go to the next manga
}
// Check if the manga has a snapshot
snapshot, hasSnapshot := snapshotMap[_entry.GetMedia().GetID()]
if !hasSnapshot {
d.Logger.Trace().Msgf("local manager: Diff > Manga %d is missing a snapshot", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{
MangaEntry: _entry,
DiffType: DiffTypeMissing,
}
continue // Go to the next manga
}
// Check if the manga has changed
_referenceKey := GetMangaReferenceKey(_entry.Media, opts.DownloadedChapterContainers)
// Check if the reference key is different
if snapshotMap[_entry.GetMedia().GetID()].ReferenceKey != _referenceKey {
d.Logger.Trace().Str("localReferenceKey", snapshotMap[_entry.GetMedia().GetID()].ReferenceKey).Str("currentReferenceKey", _referenceKey).Msgf("local manager: Diff > Manga %d has an outdated snapshot", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{
MangaEntry: _entry,
MangaSnapshot: snapshot,
DiffType: DiffTypeMetadata,
}
continue // Go to the next manga
}
localEntry, found := localCollection.MustGet().GetListEntryFromMangaId(_entry.GetMedia().GetID())
if !found {
d.Logger.Trace().Msgf("local manager: Diff > Manga %d is missing from the local collection", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{
MangaEntry: _entry,
MangaSnapshot: snapshot,
DiffType: DiffTypeMissing,
}
continue // Go to the next manga
}
// Check if the list data has changed
_listDataKey := GetMangaListDataKey(_entry)
localListDataKey := GetMangaListDataKey(localEntry)
if _listDataKey != localListDataKey {
d.Logger.Trace().Str("localListDataKey", localListDataKey).Str("currentListDataKey", _listDataKey).Msgf("local manager: Diff > Manga %d has changed list data", _entry.GetMedia().GetID())
changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{
MangaEntry: _entry,
MangaSnapshot: snapshot,
DiffType: DiffTypeListData,
}
continue // Go to the next manga
}
}
}
return changedMap
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func GetAnimeReferenceKey(bAnime *anilist.BaseAnime, lfs []*anime.LocalFile) string {
// Reference key is used to compare the snapshot with the current data.
// If the reference key is different, the snapshot is outdated.
animeLfs := lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool {
return lf.MediaId == bAnime.ID
})
// Extract the paths and sort them to maintain a consistent order.
paths := lo.Map(animeLfs, func(lf *anime.LocalFile, _ int) string {
return lf.Path
})
slices.Sort(paths)
return fmt.Sprintf("%d-%s", bAnime.ID, strings.Join(paths, ","))
}
func GetMangaReferenceKey(bManga *anilist.BaseManga, dcc []*manga.ChapterContainer) string {
// Reference key is used to compare the snapshot with the current data.
// If the reference key is different, the snapshot is outdated.
mangaDcc := lo.Filter(dcc, func(dc *manga.ChapterContainer, _ int) bool {
return dc.MediaId == bManga.ID
})
slices.SortFunc(mangaDcc, func(i, j *manga.ChapterContainer) int {
return strings.Compare(i.Provider, j.Provider)
})
var k string
for _, dc := range mangaDcc {
l := dc.Provider + "-"
slices.SortFunc(dc.Chapters, func(i, j *hibikemanga.ChapterDetails) int {
return strings.Compare(i.ID, j.ID)
})
for _, c := range dc.Chapters {
l += c.ID + "-"
}
k += l
}
return fmt.Sprintf("%d-%s", bManga.ID, k)
}
func GetAnimeListDataKey(entry *anilist.AnimeListEntry) string {
return fmt.Sprintf("%s-%d-%f-%d-%v-%v-%v-%v-%v-%v",
MediaListStatusPointerValue(entry.GetStatus()),
IntPointerValue(entry.GetProgress()),
Float64PointerValue(entry.GetScore()),
IntPointerValue(entry.GetRepeat()),
IntPointerValue(entry.GetStartedAt().GetYear()),
IntPointerValue(entry.GetStartedAt().GetMonth()),
IntPointerValue(entry.GetStartedAt().GetDay()),
IntPointerValue(entry.GetCompletedAt().GetYear()),
IntPointerValue(entry.GetCompletedAt().GetMonth()),
IntPointerValue(entry.GetCompletedAt().GetDay()),
)
}
func GetMangaListDataKey(entry *anilist.MangaListEntry) string {
return fmt.Sprintf("%s-%d-%f-%d-%v-%v-%v-%v-%v-%v",
MediaListStatusPointerValue(entry.GetStatus()),
IntPointerValue(entry.GetProgress()),
Float64PointerValue(entry.GetScore()),
IntPointerValue(entry.GetRepeat()),
IntPointerValue(entry.GetStartedAt().GetYear()),
IntPointerValue(entry.GetStartedAt().GetMonth()),
IntPointerValue(entry.GetStartedAt().GetDay()),
IntPointerValue(entry.GetCompletedAt().GetYear()),
IntPointerValue(entry.GetCompletedAt().GetMonth()),
IntPointerValue(entry.GetCompletedAt().GetDay()),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
package local
import (
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/util/result"
"strconv"
"github.com/pkg/errors"
)
// OfflineMetadataProvider replaces the metadata provider only when offline
type OfflineMetadataProvider struct {
manager *ManagerImpl
animeSnapshots map[int]*AnimeSnapshot
animeMetadataCache *result.BoundedCache[string, *metadata.AnimeMetadata]
}
type OfflineAnimeMetadataWrapper struct {
anime *anilist.BaseAnime
metadata *metadata.AnimeMetadata
}
func NewOfflineMetadataProvider(manager *ManagerImpl) metadata.Provider {
ret := &OfflineMetadataProvider{
manager: manager,
animeSnapshots: make(map[int]*AnimeSnapshot),
animeMetadataCache: result.NewBoundedCache[string, *metadata.AnimeMetadata](500),
}
// Load the anime snapshots
// DEVNOTE: We assume that it will be loaded once since it's used only when offline
ret.loadAnimeSnapshots()
return ret
}
func (mp *OfflineMetadataProvider) loadAnimeSnapshots() {
animeSnapshots, ok := mp.manager.localDb.GetAnimeSnapshots()
if !ok {
return
}
for _, snapshot := range animeSnapshots {
mp.animeSnapshots[snapshot.MediaId] = snapshot
}
}
func (mp *OfflineMetadataProvider) GetAnimeMetadata(platform metadata.Platform, mId int) (*metadata.AnimeMetadata, error) {
if platform != metadata.AnilistPlatform {
return nil, errors.New("unsupported platform")
}
if snapshot, ok := mp.animeSnapshots[mId]; ok {
localAnimeMetadata := snapshot.AnimeMetadata
for _, episode := range localAnimeMetadata.Episodes {
if imgUrl, ok := snapshot.EpisodeImagePaths[episode.Episode]; ok {
episode.Image = *FormatAssetUrl(mId, imgUrl)
}
}
return &metadata.AnimeMetadata{
Titles: localAnimeMetadata.Titles,
Episodes: localAnimeMetadata.Episodes,
EpisodeCount: localAnimeMetadata.EpisodeCount,
SpecialCount: localAnimeMetadata.SpecialCount,
Mappings: localAnimeMetadata.Mappings,
}, nil
}
return nil, errors.New("anime metadata not found")
}
func (mp *OfflineMetadataProvider) GetCache() *result.BoundedCache[string, *metadata.AnimeMetadata] {
return mp.animeMetadataCache
}
func (mp *OfflineMetadataProvider) GetAnimeMetadataWrapper(anime *anilist.BaseAnime, metadata *metadata.AnimeMetadata) metadata.AnimeMetadataWrapper {
return &OfflineAnimeMetadataWrapper{
anime: anime,
metadata: metadata,
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (mw *OfflineAnimeMetadataWrapper) GetEpisodeMetadata(episodeNumber int) (ret metadata.EpisodeMetadata) {
episodeMetadata, found := mw.metadata.FindEpisode(strconv.Itoa(episodeNumber))
if found {
ret = *episodeMetadata
}
return
}

View File

@@ -0,0 +1,48 @@
package local
import (
"path/filepath"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/database/db"
"seanime/internal/events"
"seanime/internal/extension_repo"
"seanime/internal/manga"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
"github.com/stretchr/testify/require"
)
func GetMockManager(t *testing.T, db *db.Database) Manager {
logger := util.NewLogger()
metadataProvider := metadata.GetMockProvider(t)
extensionRepository := extension_repo.GetMockExtensionRepository(t)
mangaRepository := manga.GetMockRepository(t, db)
mangaRepository.InitExtensionBank(extensionRepository.GetExtensionBank())
wsEventManager := events.NewMockWSEventManager(logger)
anilistClient := anilist.NewMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
localDir := filepath.Join(test_utils.ConfigData.Path.DataDir, "offline")
assetsDir := filepath.Join(test_utils.ConfigData.Path.DataDir, "offline", "assets")
m, err := NewManager(&NewManagerOptions{
LocalDir: localDir,
AssetDir: assetsDir,
Logger: util.NewLogger(),
MetadataProvider: metadataProvider,
MangaRepository: mangaRepository,
Database: db,
WSEventManager: wsEventManager,
AnilistPlatform: anilistPlatform,
IsOffline: false,
})
require.NoError(t, err)
return m
}

View File

@@ -0,0 +1,749 @@
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
}

View File

@@ -0,0 +1,243 @@
package local
import (
"fmt"
"path/filepath"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/library/anime"
"seanime/internal/util"
"seanime/internal/util/image_downloader"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
)
// BaseAnimeDeepCopy creates a deep copy of the given base anime struct.
func BaseAnimeDeepCopy(animeCollection *anilist.BaseAnime) *anilist.BaseAnime {
bytes, err := json.Marshal(animeCollection)
if err != nil {
return nil
}
deepCopy := &anilist.BaseAnime{}
err = json.Unmarshal(bytes, deepCopy)
if err != nil {
return nil
}
deepCopy.NextAiringEpisode = nil
return deepCopy
}
// BaseMangaDeepCopy creates a deep copy of the given base manga struct.
func BaseMangaDeepCopy(animeCollection *anilist.BaseManga) *anilist.BaseManga {
bytes, err := json.Marshal(animeCollection)
if err != nil {
return nil
}
deepCopy := &anilist.BaseManga{}
err = json.Unmarshal(bytes, deepCopy)
if err != nil {
return nil
}
return deepCopy
}
func ToNewPointer[A any](a *A) *A {
if a == nil {
return nil
}
t := *a
return &t
}
func IntPointerValue[A int](a *A) A {
if a == nil {
return 0
}
return *a
}
func Float64PointerValue[A float64](a *A) A {
if a == nil {
return 0
}
return *a
}
func MediaListStatusPointerValue(a *anilist.MediaListStatus) anilist.MediaListStatus {
if a == nil {
return anilist.MediaListStatusPlanning
}
return *a
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// DownloadAnimeEpisodeImages saves the episode images for the given anime media ID.
// This should be used to update the episode images for an anime, e.g. after a new episode is released.
//
// The episodeImageUrls map should be in the format of {"1": "url1", "2": "url2", ...}, where the key is the episode number (defined in metadata.AnimeMetadata).
// It will download the images to the `<assetsDir>/<mId>` directory and return a map of episode numbers to the downloaded image filenames.
//
// DownloadAnimeEpisodeImages(logger, "path/to/datadir/local/assets", 123, map[string]string{"1": "url1", "2": "url2"})
// -> map[string]string{"1": "filename1.jpg", "2": "filename2.jpg"}
func DownloadAnimeEpisodeImages(logger *zerolog.Logger, assetsDir string, mId int, episodeImageUrls map[string]string) (map[string]string, bool) {
defer util.HandlePanicInModuleThen("sync/DownloadAnimeEpisodeImages", func() {})
logger.Trace().Msgf("local manager: Downloading episode images for anime %d", mId)
// e.g. /path/to/datadir/local/assets/123
mediaAssetPath := filepath.Join(assetsDir, fmt.Sprintf("%d", mId))
imageDownloader := image_downloader.NewImageDownloader(mediaAssetPath, logger)
// Download the images
imgUrls := make([]string, 0, len(episodeImageUrls))
for _, episodeImage := range episodeImageUrls {
if episodeImage == "" {
continue
}
imgUrls = append(imgUrls, episodeImage)
}
err := imageDownloader.DownloadImages(imgUrls)
if err != nil {
logger.Error().Err(err).Msgf("local manager: Failed to download images for anime %d", mId)
return nil, false
}
images, err := imageDownloader.GetImageFilenamesByUrls(imgUrls)
if err != nil {
logger.Error().Err(err).Msgf("local manager: Failed to get image filenames for anime %d", mId)
return nil, false
}
episodeImagePaths := make(map[string]string)
for episodeNum, episodeImage := range episodeImageUrls {
episodeImagePaths[episodeNum] = images[episodeImage]
}
return episodeImagePaths, true
}
// DownloadAnimeImages saves the banner, cover, and episode images for the given anime entry.
// This should be used to download the images for an anime for the first time.
//
// It will download the images to the `<assetsDir>/<mId>` directory and return the filenames of the banner, cover, and episode images.
//
// DownloadAnimeImages(logger, "path/to/datadir/local/assets", entry, animeMetadata)
// -> "banner.jpg", "cover.jpg", map[string]string{"1": "filename1.jpg", "2": "filename2.jpg"}
func DownloadAnimeImages(
logger *zerolog.Logger,
assetsDir string,
entry *anilist.AnimeListEntry,
animeMetadata *metadata.AnimeMetadata, // This is updated
metadataWrapper metadata.AnimeMetadataWrapper,
lfs []*anime.LocalFile,
) (string, string, map[string]string, bool) {
defer util.HandlePanicInModuleThen("sync/DownloadAnimeImages", func() {})
logger.Trace().Msgf("local manager: Downloading images for anime %d", entry.Media.ID)
// e.g. /datadir/local/assets/123
mediaAssetPath := filepath.Join(assetsDir, fmt.Sprintf("%d", entry.Media.ID))
imageDownloader := image_downloader.NewImageDownloader(mediaAssetPath, logger)
// Download the images
ogBannerImage := entry.GetMedia().GetBannerImageSafe()
ogCoverImage := entry.GetMedia().GetCoverImageSafe()
imgUrls := []string{ogBannerImage, ogCoverImage}
lfMap := make(map[string]*anime.LocalFile)
for _, lf := range lfs {
lfMap[lf.Metadata.AniDBEpisode] = lf
}
ogEpisodeImages := make(map[string]string)
for episodeNum, episode := range animeMetadata.Episodes {
// Check if the episode is in the local files
if _, ok := lfMap[episodeNum]; !ok {
continue
}
episodeInt, ok := util.StringToInt(episodeNum)
if !ok {
ogEpisodeImages[episodeNum] = episode.Image
imgUrls = append(imgUrls, episode.Image)
continue
}
epMetadata := metadataWrapper.GetEpisodeMetadata(episodeInt)
episode = &epMetadata
ogEpisodeImages[episodeNum] = episode.Image
imgUrls = append(imgUrls, episode.Image)
}
err := imageDownloader.DownloadImages(imgUrls)
if err != nil {
logger.Error().Err(err).Msgf("local manager: Failed to download images for anime %d", entry.Media.ID)
return "", "", nil, false
}
images, err := imageDownloader.GetImageFilenamesByUrls(imgUrls)
if err != nil {
logger.Error().Err(err).Msgf("local manager: Failed to get image filenames for anime %d", entry.Media.ID)
return "", "", nil, false
}
bannerImage := images[ogBannerImage]
coverImage := images[ogCoverImage]
episodeImagePaths := make(map[string]string)
for episodeNum, episodeImage := range ogEpisodeImages {
if episodeImage == "" {
continue
}
episodeImagePaths[episodeNum] = images[episodeImage]
}
logger.Debug().Msgf("local manager: Stored images for anime %d, %+v, %+v, episode images: %+v", entry.Media.ID, bannerImage, coverImage, len(episodeImagePaths))
return bannerImage, coverImage, episodeImagePaths, true
}
// DownloadMangaImages saves the banner and cover images for the given manga entry.
// This should be used to download the images for a manga for the first time.
//
// It will download the images to the `<assetsDir>/<mId>` directory and return the filenames of the banner and cover images.
//
// DownloadMangaImages(logger, "path/to/datadir/local/assets", entry)
// -> "banner.jpg", "cover.jpg"
func DownloadMangaImages(logger *zerolog.Logger, assetsDir string, entry *anilist.MangaListEntry) (string, string, bool) {
logger.Trace().Msgf("local manager: Downloading images for manga %d", entry.Media.ID)
// e.g. /datadir/local/assets/123
mediaAssetPath := filepath.Join(assetsDir, fmt.Sprintf("%d", entry.Media.ID))
imageDownloader := image_downloader.NewImageDownloader(mediaAssetPath, logger)
// Download the images
ogBannerImage := entry.GetMedia().GetBannerImageSafe()
ogCoverImage := entry.GetMedia().GetCoverImageSafe()
imgUrls := []string{ogBannerImage, ogCoverImage}
err := imageDownloader.DownloadImages(imgUrls)
if err != nil {
logger.Error().Err(err).Msgf("local manager: Failed to download images for anime %d", entry.Media.ID)
return "", "", false
}
images, err := imageDownloader.GetImageFilenamesByUrls(imgUrls)
if err != nil {
logger.Error().Err(err).Msgf("local manager: Failed to get image filenames for anime %d", entry.Media.ID)
return "", "", false
}
bannerImage := images[ogBannerImage]
coverImage := images[ogCoverImage]
logger.Debug().Msgf("local manager: Stored images for manga %d, %+v, %+v", entry.Media.ID, bannerImage, coverImage)
return bannerImage, coverImage, true
}

View File

@@ -0,0 +1,136 @@
package local
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/database/db"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
"time"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
func testSetupManager(t *testing.T) (Manager, *anilist.AnimeCollection, *anilist.MangaCollection) {
logger := util.NewLogger()
anilistClient := anilist.NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt)
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
anilistPlatform.SetUsername(test_utils.ConfigData.Provider.AnilistUsername)
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true)
require.NoError(t, err)
mangaCollection, err := anilistPlatform.GetMangaCollection(t.Context(), true)
require.NoError(t, err)
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
require.NoError(t, err)
manager := GetMockManager(t, database)
manager.SetAnimeCollection(animeCollection)
manager.SetMangaCollection(mangaCollection)
return manager, animeCollection, mangaCollection
}
func TestSync2(t *testing.T) {
test_utils.SetTwoLevelDeep()
test_utils.InitTestProvider(t, test_utils.Anilist())
manager, animeCollection, _ := testSetupManager(t)
err := manager.TrackAnime(130003) // Bocchi the rock
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.TrackAnime(10800) // Chihayafuru
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.TrackAnime(171457) // Make Heroine ga Oosugiru!
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.TrackManga(101517) // JJK
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.SynchronizeLocal()
require.NoError(t, err)
select {
case <-manager.GetSyncer().doneUpdatingLocalCollections:
util.Spew(manager.GetLocalAnimeCollection().MustGet())
util.Spew(manager.GetLocalMangaCollection().MustGet())
break
case <-time.After(10 * time.Second):
t.Log("Timeout")
break
}
anilist.TestModifyAnimeCollectionEntry(animeCollection, 130003, anilist.TestModifyAnimeCollectionEntryInput{
Status: lo.ToPtr(anilist.MediaListStatusCompleted),
Progress: lo.ToPtr(12), // Mock progress
})
fmt.Println("================================================================================================")
fmt.Println("================================================================================================")
err = manager.SynchronizeLocal()
require.NoError(t, err)
select {
case <-manager.GetSyncer().doneUpdatingLocalCollections:
util.Spew(manager.GetLocalAnimeCollection().MustGet())
util.Spew(manager.GetLocalMangaCollection().MustGet())
break
case <-time.After(10 * time.Second):
t.Log("Timeout")
break
}
}
func TestSync(t *testing.T) {
test_utils.SetTwoLevelDeep()
test_utils.InitTestProvider(t, test_utils.Anilist())
manager, _, _ := testSetupManager(t)
err := manager.TrackAnime(130003) // Bocchi the rock
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.TrackAnime(10800) // Chihayafuru
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.TrackAnime(171457) // Make Heroine ga Oosugiru!
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.TrackManga(101517) // JJK
if err != nil && !errors.Is(err, ErrAlreadyTracked) {
require.NoError(t, err)
}
err = manager.SynchronizeLocal()
require.NoError(t, err)
select {
case <-manager.GetSyncer().doneUpdatingLocalCollections:
util.Spew(manager.GetLocalAnimeCollection().MustGet())
util.Spew(manager.GetLocalMangaCollection().MustGet())
break
case <-time.After(10 * time.Second):
t.Log("Timeout")
break
}
}

View File

@@ -0,0 +1,13 @@
package local
import "fmt"
// FormatAssetUrl formats the asset URL for the given mediaId and filename.
//
// FormatAssetUrl(123, "cover.jpg") -> "{{LOCAL_ASSETS}}/123/cover.jpg"
func FormatAssetUrl(mediaId int, filename string) *string {
// {{LOCAL_ASSETS}} should be replaced in the client with the actual URL
// e.g. http://<hostname>/local_assets/123/cover.jpg
a := fmt.Sprintf("{{LOCAL_ASSETS}}/%d/%s", mediaId, filename)
return &a
}