node build fixed
This commit is contained in:
78
seanime-2.9.10/internal/local/database.go
Normal file
78
seanime-2.9.10/internal/local/database.go
Normal 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
|
||||
}
|
||||
242
seanime-2.9.10/internal/local/database_helpers.go
Normal file
242
seanime-2.9.10/internal/local/database_helpers.go
Normal 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
|
||||
}
|
||||
198
seanime-2.9.10/internal/local/database_models.go
Normal file
198
seanime-2.9.10/internal/local/database_models.go
Normal 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
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
327
seanime-2.9.10/internal/local/diff.go
Normal file
327
seanime-2.9.10/internal/local/diff.go
Normal 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()),
|
||||
)
|
||||
}
|
||||
1039
seanime-2.9.10/internal/local/manager.go
Normal file
1039
seanime-2.9.10/internal/local/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
93
seanime-2.9.10/internal/local/metadata.go
Normal file
93
seanime-2.9.10/internal/local/metadata.go
Normal 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
|
||||
}
|
||||
48
seanime-2.9.10/internal/local/mock.go
Normal file
48
seanime-2.9.10/internal/local/mock.go
Normal 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
|
||||
}
|
||||
749
seanime-2.9.10/internal/local/sync.go
Normal file
749
seanime-2.9.10/internal/local/sync.go
Normal 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
|
||||
}
|
||||
243
seanime-2.9.10/internal/local/sync_helpers.go
Normal file
243
seanime-2.9.10/internal/local/sync_helpers.go
Normal 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
|
||||
}
|
||||
136
seanime-2.9.10/internal/local/sync_test.go
Normal file
136
seanime-2.9.10/internal/local/sync_test.go
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
13
seanime-2.9.10/internal/local/sync_util.go
Normal file
13
seanime-2.9.10/internal/local/sync_util.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user