diff options
| author | Bobby <[email protected]> | 2026-02-24 14:02:37 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-02-24 14:02:37 +0530 |
| commit | de0c8e5b052342bc7aaa3e8e3795ce432208af61 (patch) | |
| tree | 4ee288d86fc17c4e28a101a35cbdf9b2c1597042 | |
| parent | 45e858c3062eacb2f9d51b65d5680eb211ce5482 (diff) | |
| download | metachan-de0c8e5b052342bc7aaa3e8e3795ce432208af61.tar.xz metachan-de0c8e5b052342bc7aaa3e8e3795ce432208af61.zip | |
Refactor entities and repositories: add AnimeCharacter and CharacterVoiceActor types, update character handling in anime, and enhance producer enrichment logic
| -rw-r--r-- | database/migrate.go | 2 | ||||
| -rw-r--r-- | entities/anime.go | 2 | ||||
| -rw-r--r-- | entities/meta.go | 4 | ||||
| -rw-r--r-- | entities/persona.go | 36 | ||||
| -rw-r--r-- | entities/producer.go | 3 | ||||
| -rw-r--r-- | repositories/anime.go | 100 | ||||
| -rw-r--r-- | repositories/meta.go | 24 | ||||
| -rw-r--r-- | repositories/producer.go | 56 | ||||
| -rw-r--r-- | services/anime.go | 46 | ||||
| -rw-r--r-- | tasks/manager.go | 4 | ||||
| -rw-r--r-- | tasks/producersync.task.go | 91 | ||||
| -rw-r--r-- | tasks/tasks.go | 1 | ||||
| -rw-r--r-- | types/aniskip.go | 10 | ||||
| -rw-r--r-- | types/jikan.go | 23 | ||||
| -rw-r--r-- | types/tasks.go | 1 |
15 files changed, 312 insertions, 91 deletions
diff --git a/database/migrate.go b/database/migrate.go index 44a462c..c40b19c 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -48,6 +48,8 @@ func migrate() { // Character/Persona entities &entities.Character{}, &entities.VoiceActor{}, + &entities.AnimeCharacter{}, + &entities.CharacterVoiceActor{}, ) if err != nil { logger.Fatalf("Database", "Error during database migration: %v", err) diff --git a/entities/anime.go b/entities/anime.go index cdcc563..9c23767 100644 --- a/entities/anime.go +++ b/entities/anime.go @@ -46,6 +46,6 @@ type Anime struct { Studios []Producer `gorm:"many2many:anime_studios;" json:"studios,omitempty"` Licensors []Producer `gorm:"many2many:anime_licensors;" json:"licensors,omitempty"` Episodes []Episode `gorm:"foreignKey:AnimeID" json:"episodes,omitempty"` - Characters []Character `gorm:"foreignKey:AnimeID" json:"characters,omitempty"` + Characters []Character `gorm:"-" json:"characters,omitempty"` Schedule []EpisodeSchedule `gorm:"foreignKey:AnimeID;constraint:OnDelete:CASCADE" json:"airing_schedule,omitempty"` } diff --git a/entities/meta.go b/entities/meta.go index 8c37417..2395ddd 100644 --- a/entities/meta.go +++ b/entities/meta.go @@ -67,8 +67,8 @@ type ExternalURL struct { type SimpleTitle struct { BaseModel - Type string `json:"type,omitempty"` - Title string `json:"title,omitempty"` + Type string `gorm:"uniqueIndex:idx_simple_title" json:"type,omitempty"` + Title string `gorm:"uniqueIndex:idx_simple_title" json:"title,omitempty"` } type SimpleImage struct { diff --git a/entities/persona.go b/entities/persona.go index 481f7af..91a0b49 100644 --- a/entities/persona.go +++ b/entities/persona.go @@ -2,21 +2,31 @@ package entities type Character struct { BaseModel - AnimeID uint `json:"-"` - MALID int `json:"mal_id,omitempty"` - URL string `json:"url,omitempty"` - ImageURL string `json:"image_url,omitempty"` - Name string `json:"name,omitempty"` - Role string `json:"role,omitempty"` - VoiceActors []VoiceActor `gorm:"foreignKey:CharacterID" json:"voice_actors,omitempty"` + MALID int `gorm:"uniqueIndex" json:"mal_id,omitempty"` + URL string `json:"url,omitempty"` + ImageURL string `json:"image_url,omitempty"` + Name string `json:"name,omitempty"` + Role string `gorm:"-" json:"role,omitempty"` + VoiceActors []CharacterVoiceActor `gorm:"foreignKey:CharacterID" json:"voice_actors,omitempty"` } type VoiceActor struct { BaseModel - CharacterID uint `json:"-"` - MALID int `json:"mal_id,omitempty"` - URL string `json:"url,omitempty"` - Image string `json:"image_url,omitempty"` - Name string `json:"name,omitempty"` - Language string `json:"language,omitempty"` + MALID int `gorm:"uniqueIndex" json:"mal_id,omitempty"` + URL string `json:"url,omitempty"` + Image string `json:"image_url,omitempty"` + Name string `json:"name,omitempty"` +} + +type AnimeCharacter struct { + AnimeID uint `gorm:"primaryKey"` + CharacterID uint `gorm:"primaryKey"` + Role string +} + +type CharacterVoiceActor struct { + CharacterID uint `gorm:"primaryKey" json:"-"` + VoiceActorID uint `gorm:"primaryKey" json:"-"` + Language string `json:"language,omitempty"` + VoiceActor *VoiceActor `gorm:"foreignKey:ID;references:VoiceActorID" json:"voice_actor,omitempty"` } diff --git a/entities/producer.go b/entities/producer.go index c68dc32..42cada9 100644 --- a/entities/producer.go +++ b/entities/producer.go @@ -1,5 +1,7 @@ package entities +import "time" + type Producer struct { BaseModel MALID int `gorm:"uniqueIndex" json:"mal_id,omitempty"` @@ -8,6 +10,7 @@ type Producer struct { Count int `json:"count,omitempty"` Established string `json:"established,omitempty"` About string `gorm:"type:text" json:"about,omitempty"` + EnrichedAt *time.Time `json:"-"` ImageID *uint `json:"-"` Image *SimpleImage `gorm:"foreignKey:ImageID" json:"image,omitempty"` Titles []SimpleTitle `gorm:"many2many:producer_titles" json:"titles,omitempty"` diff --git a/repositories/anime.go b/repositories/anime.go index 8870f35..b20a256 100644 --- a/repositories/anime.go +++ b/repositories/anime.go @@ -51,8 +51,6 @@ func GetAnime[T idType](maptype enums.MappingType, id T) (entities.Anime, error) Preload("Episodes.StreamInfo"). Preload("Episodes.StreamInfo.SubSources"). Preload("Episodes.StreamInfo.DubSources"). - Preload("Characters"). - Preload("Characters.VoiceActors"). Preload("Schedule"). Preload("Seasons"). Preload("Seasons.Title"). @@ -69,9 +67,82 @@ func GetAnime[T idType](maptype enums.MappingType, id T) (entities.Anime, error) return entities.Anime{}, errors.New("anime not found") } + loadAnimeCharacters(&anime) + return anime, nil } +func loadAnimeCharacters(anime *entities.Anime) { + var rows []struct { + CharacterID uint + Role string + } + DB.Table("anime_characters"). + Select("character_id, role"). + Where("anime_id = ?", anime.ID). + Scan(&rows) + + for _, row := range rows { + var char entities.Character + if err := DB.First(&char, row.CharacterID).Error; err != nil { + continue + } + DB.Preload("VoiceActor"). + Where("character_id = ?", char.ID). + Find(&char.VoiceActors) + char.Role = row.Role + anime.Characters = append(anime.Characters, char) + } +} + +func SaveAnimeCharacters(animeID uint, characters []entities.Character) error { + for i := range characters { + char := &characters[i] + + DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "mal_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"url", "image_url", "name"}), + }).Create(char) + if char.ID == 0 { + DB.Where("mal_id = ?", char.MALID).First(char) + } + + DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "anime_id"}, {Name: "character_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"role"}), + }).Create(&entities.AnimeCharacter{ + AnimeID: animeID, + CharacterID: char.ID, + Role: char.Role, + }) + + for _, cva := range char.VoiceActors { + va := cva.VoiceActor + if va == nil { + continue + } + + DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "mal_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"url", "image", "name"}), + }).Create(va) + if va.ID == 0 { + DB.Where("mal_id = ?", va.MALID).First(va) + } + + DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "character_id"}, {Name: "voice_actor_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"language"}), + }).Create(&entities.CharacterVoiceActor{ + CharacterID: char.ID, + VoiceActorID: va.ID, + Language: cva.Language, + }) + } + } + return nil +} + func CreateOrUpdateAnime(anime *entities.Anime) error { if anime == nil { return fmt.Errorf("anime is nil") @@ -85,7 +156,7 @@ func CreateOrUpdateAnime(anime *entities.Anime) error { result = DB.Session(&gorm.Session{FullSaveAssociations: true}).Clauses(clause.OnConflict{ UpdateAll: true, - }).Save(anime) + }).Omit("Characters", "Episodes").Save(anime) if result.Error != nil { return fmt.Errorf("failed to save anime: %w", result.Error) @@ -95,6 +166,29 @@ func CreateOrUpdateAnime(anime *entities.Anime) error { return nil } +func SaveAnimeEpisodes(animeID uint, episodes []entities.Episode) error { + for i := range episodes { + ep := &episodes[i] + ep.AnimeID = animeID + + var existing entities.Episode + if DB.Where("episode_id = ?", ep.EpisodeID).First(&existing).Error == nil { + ep.ID = existing.ID + ep.TitleID = existing.TitleID + DB.Model(ep).Omit("SkipTimes", "StreamInfo", "Title").Updates(ep) + if ep.Title != nil && existing.TitleID != 0 { + ep.Title.ID = existing.TitleID + DB.Save(ep.Title) + } + } else { + DB.Session(&gorm.Session{FullSaveAssociations: true}). + Omit("SkipTimes", "StreamInfo"). + Create(ep) + } + } + return nil +} + func SaveEpisodeSkipTimes(episodeID string, skipTimes []entities.EpisodeSkipTime) error { if len(skipTimes) == 0 { return nil diff --git a/repositories/meta.go b/repositories/meta.go index 34487a4..817a843 100644 --- a/repositories/meta.go +++ b/repositories/meta.go @@ -72,6 +72,30 @@ func CreateOrUpdateSimpleTitle(title *entities.SimpleTitle) (uint, error) { return title.ID, nil } +func GetAllImagesMapped() (map[string]uint, error) { + var images []entities.SimpleImage + if err := DB.Select("id, image_url").Find(&images).Error; err != nil { + return nil, err + } + m := make(map[string]uint, len(images)) + for _, img := range images { + m[img.ImageURL] = img.ID + } + return m, nil +} + +func GetAllTitlesMapped() (map[string]uint, error) { + var titles []entities.SimpleTitle + if err := DB.Select("id, type, title").Find(&titles).Error; err != nil { + return nil, err + } + m := make(map[string]uint, len(titles)) + for _, t := range titles { + m[t.Type+":"+t.Title] = t.ID + } + return m, nil +} + func CreateOrUpdateExternalURL(url *entities.ExternalURL) (uint, error) { result := DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "name"}, {Name: "url"}}, diff --git a/repositories/producer.go b/repositories/producer.go index 53deefe..afe9108 100644 --- a/repositories/producer.go +++ b/repositories/producer.go @@ -4,26 +4,80 @@ import ( "errors" "metachan/entities" "metachan/utils/logger" + "time" "gorm.io/gorm/clause" ) func CreateOrUpdateProducer(producer *entities.Producer) error { + for i := range producer.Titles { + t := &producer.Titles[i] + DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "type"}, {Name: "title"}}, + DoNothing: true, + }).Create(t) + if t.ID == 0 { + DB.Where("type = ? AND title = ?", t.Type, t.Title).First(t) + } + } + result := DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "mal_id"}}, DoUpdates: clause.AssignmentColumns([]string{ "url", "favorites", "count", "established", "about", "image_id", }), - }).Create(producer) + }).Omit("Titles").Create(producer) if result.Error != nil { logger.Errorf("Producer", "Failed to create or update producer: %v", result.Error) return errors.New("failed to create or update producer") } + if len(producer.Titles) > 0 { + if err := DB.Model(producer).Association("Titles").Replace(producer.Titles); err != nil { + logger.Errorf("Producer", "Failed to associate titles for producer %d: %v", producer.MALID, err) + } + } + return nil } +func GetAllProducers() ([]entities.Producer, error) { + var producers []entities.Producer + if err := DB.Select("id, mal_id, enriched_at").Find(&producers).Error; err != nil { + return nil, err + } + return producers, nil +} + +func GetProducerExternalURLCount(producer *entities.Producer) int64 { + return DB.Model(producer).Association("ExternalURLs").Count() +} + +func ReplaceProducerExternalURLs(producer *entities.Producer, urls []entities.ExternalURL) error { + return DB.Model(producer).Association("ExternalURLs").Replace(urls) +} + +func UpdateProducerDetails(id uint, url, established, about string, favorites, count int, imageURL string) error { + now := time.Now() + updates := map[string]interface{}{ + "url": url, + "favorites": favorites, + "count": count, + "established": established, + "about": about, + "enriched_at": now, + } + if imageURL != "" { + img := entities.SimpleImage{ImageURL: imageURL} + imgID, err := CreateOrUpdateSimpleImage(&img) + if err == nil && imgID != 0 { + updates["image_id"] = imgID + } + } + return DB.Model(&entities.Producer{}).Where("id = ?", id).Updates(updates).Error +} + func BatchCreateProducers(producers []entities.Producer) error { if len(producers) == 0 { return nil diff --git a/services/anime.go b/services/anime.go index 2bd35d1..eba303b 100644 --- a/services/anime.go +++ b/services/anime.go @@ -282,29 +282,27 @@ func applyJikanData(anime *entities.Anime, jikanAnime *types.JikanAnimeResponse, if jikanCharacters != nil { for _, jc := range jikanCharacters.Data { - character := entities.Character{ - MALID: jc.MALID, - Name: jc.Name, + char := entities.Character{ + MALID: jc.Character.MALID, + Name: jc.Character.Name, + URL: jc.Character.URL, + ImageURL: jc.Character.Images.JPG.ImageURL, Role: jc.Role, - URL: jc.URL, - ImageURL: jc.Images.JPG.ImageURL, } - if len(jc.VoiceActors) > 0 { - for _, va := range jc.VoiceActors { - if va.Language == "Japanese" { - character.VoiceActors = append(character.VoiceActors, entities.VoiceActor{ - MALID: va.MALID, - Name: va.Name, - Language: va.Language, - URL: va.URL, - Image: va.Images.JPG.ImageURL, - }) - } - } + for _, va := range jc.VoiceActors { + char.VoiceActors = append(char.VoiceActors, entities.CharacterVoiceActor{ + Language: va.Language, + VoiceActor: &entities.VoiceActor{ + MALID: va.Person.MALID, + Name: va.Person.Name, + URL: va.Person.URL, + Image: va.Person.Images.JPG.ImageURL, + }, + }) } - anime.Characters = append(anime.Characters, character) + anime.Characters = append(anime.Characters, char) } } } @@ -671,6 +669,18 @@ func saveAnime(anime *entities.Anime, skipTimeMap map[string][]entities.EpisodeS return fmt.Errorf("failed to save anime: %w", err) } + if len(anime.Episodes) > 0 { + if err := repositories.SaveAnimeEpisodes(anime.ID, anime.Episodes); err != nil { + logger.Warnf("AnimeService", "Failed to save episodes: %v", err) + } + } + + if len(anime.Characters) > 0 { + if err := repositories.SaveAnimeCharacters(anime.ID, anime.Characters); err != nil { + logger.Warnf("AnimeService", "Failed to save characters: %v", err) + } + } + for episodeID, skipTimes := range skipTimeMap { if err := repositories.SaveEpisodeSkipTimes(episodeID, skipTimes); err != nil { logger.Warnf("AnimeService", "Failed to save skip times for episode %s: %v", episodeID, err) diff --git a/tasks/manager.go b/tasks/manager.go index ac85630..30522a5 100644 --- a/tasks/manager.go +++ b/tasks/manager.go @@ -92,6 +92,10 @@ func (tm *TaskManager) StartTask(taskName string) { tm.Done[taskName] = doneChan tm.Mutex.Unlock() + if task.OnResume != nil { + go task.OnResume() + } + go func() { if shouldExec { if !tm.checkDependencies(task) { diff --git a/tasks/producersync.task.go b/tasks/producersync.task.go index 5055618..88ea682 100644 --- a/tasks/producersync.task.go +++ b/tasks/producersync.task.go @@ -9,6 +9,12 @@ import ( "time" ) +// ResumeProducerEnrichment is called on startup to resume any background enrichment +// that was interrupted by a previous shutdown. +func ResumeProducerEnrichment() { + go enrichProducersInBackground() +} + func ProducerSync() error { logger.Infof("ProducerSync", "Starting producer sync (includes studios and licensors)") @@ -27,7 +33,7 @@ func ProducerSync() error { logger.Successf("ProducerSync", "Saved basic data for %d producers, enriching external URLs in background", total) - go enrichProducersInBackground(response.Data) + go enrichProducersInBackground() return nil } @@ -99,25 +105,17 @@ func saveProducerListData(producersData []types.JikanSingleProducer) error { } } - var dbImages []entities.SimpleImage - if err := repositories.DB.Select("id, image_url").Find(&dbImages).Error; err != nil { + imageIDMap, err := repositories.GetAllImagesMapped() + if err != nil { logger.Errorf("ProducerSync", "Failed to query images: %v", err) return err } - imageIDMap := make(map[string]uint) - for _, img := range dbImages { - imageIDMap[img.ImageURL] = img.ID - } - var dbTitles []entities.SimpleTitle - if err := repositories.DB.Select("id, type, title").Find(&dbTitles).Error; err != nil { + titleIDMap, err := repositories.GetAllTitlesMapped() + if err != nil { logger.Errorf("ProducerSync", "Failed to query titles: %v", err) return err } - titleIDMap := make(map[string]uint) - for _, t := range dbTitles { - titleIDMap[t.Type+":"+t.Title] = t.ID - } producers := make([]entities.Producer, 0, len(producerItems)) for _, pd := range producerItems { @@ -147,42 +145,61 @@ func saveProducerListData(producersData []types.JikanSingleProducer) error { return nil } -func enrichProducersInBackground(producersData []types.JikanSingleProducer) { - total := len(producersData) - startTime := time.Now() - enriched := 0 +func enrichProducersInBackground() { + producers, err := repositories.GetAllProducers() + if err != nil { + logger.Errorf("ProducerSync", "Failed to load producers for enrichment: %v", err) + return + } - for i, pd := range producersData { - var existing entities.Producer - if err := repositories.DB.Where("mal_id = ?", pd.MALID).First(&existing).Error; err == nil { - threeDaysAgo := time.Now().Add(-3 * 24 * time.Hour) - if existing.UpdatedAt.After(threeDaysAgo) && len(existing.ExternalURLs) > 0 { - continue - } + sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour) + + // Pre-check: skip entire run if nothing qualifies + hasWork := false + for _, p := range producers { + if p.EnrichedAt == nil || !p.EnrichedAt.After(sevenDaysAgo) { + hasWork = true + break } + } + if !hasWork { + return + } - detail, err := jikan.GetProducerByID(pd.MALID) - if err != nil { - logger.Warnf("ProducerSync", "Failed to fetch details for producer %d: %v", pd.MALID, err) + logger.Infof("ProducerSync", "Resuming background enrichment for producers missing external URLs") + + total := len(producers) + startTime := time.Now() + enriched := 0 + + for i, p := range producers { + if p.EnrichedAt != nil && p.EnrichedAt.After(sevenDaysAgo) { continue } - if len(detail.Data.External) == 0 { + detail, err := jikan.GetProducerByID(p.MALID) + if err != nil { + logger.Warnf("ProducerSync", "Failed to fetch details for producer %d: %v", p.MALID, err) continue } - var producer entities.Producer - if err := repositories.DB.Where("mal_id = ?", pd.MALID).First(&producer).Error; err != nil { + data := detail.Data + if err := repositories.UpdateProducerDetails( + p.ID, data.URL, data.Established, data.About, data.Favorites, data.Count, + data.Images.JPG.ImageURL, + ); err != nil { + logger.Warnf("ProducerSync", "Failed to update details for producer %d: %v", p.MALID, err) continue } - externalURLs := make([]entities.ExternalURL, 0, len(detail.Data.External)) - for _, ext := range detail.Data.External { - externalURLs = append(externalURLs, entities.ExternalURL{Name: ext.Name, URL: ext.URL}) - } - if err := repositories.DB.Model(&producer).Association("ExternalURLs").Replace(externalURLs); err != nil { - logger.Warnf("ProducerSync", "Failed to update external URLs for producer %d: %v", pd.MALID, err) - continue + if len(data.External) > 0 { + externalURLs := make([]entities.ExternalURL, 0, len(data.External)) + for _, ext := range data.External { + externalURLs = append(externalURLs, entities.ExternalURL{Name: ext.Name, URL: ext.URL}) + } + if err := repositories.ReplaceProducerExternalURLs(&p, externalURLs); err != nil { + logger.Warnf("ProducerSync", "Failed to update external URLs for producer %d: %v", p.MALID, err) + } } enriched++ diff --git a/tasks/tasks.go b/tasks/tasks.go index 8b19a19..8eb22f4 100644 --- a/tasks/tasks.go +++ b/tasks/tasks.go @@ -23,6 +23,7 @@ func init() { Name: "ProducerSync", Interval: 7 * 24 * time.Hour, Execute: ProducerSync, + OnResume: ResumeProducerEnrichment, }) if err != nil { diff --git a/types/aniskip.go b/types/aniskip.go index 3e25432..0ac94c9 100644 --- a/types/aniskip.go +++ b/types/aniskip.go @@ -1,15 +1,15 @@ package types type AniskipInterval struct { - StartTime float64 `json:"start_time"` - EndTime float64 `json:"end_time"` + StartTime float64 `json:"startTime"` + EndTime float64 `json:"endTime"` } type AniskipResult struct { Interval AniskipInterval `json:"interval"` - SkipType string `json:"skip_type"` - SkipID string `json:"skip_id"` - EpisodeLength float64 `json:"episode_length"` + SkipType string `json:"skipType"` + SkipID string `json:"skipId"` + EpisodeLength float64 `json:"episodeLength"` } type AniskipResponse struct { diff --git a/types/jikan.go b/types/jikan.go index a91c14a..bbbea8b 100644 --- a/types/jikan.go +++ b/types/jikan.go @@ -159,21 +159,22 @@ type JikanAnimeEpisodeResponse struct { Data []JikanAnimeSingleEpisode `json:"data"` } +type JikanCharacterPerson struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Images JikanGenericImageEntity `json:"images"` + Name string `json:"name"` +} + type JikanSingleCharacter struct { - MALID int `json:"mal_id"` - URL string `json:"url"` - Images JikanGenericImageEntity `json:"images"` - Name string `json:"name"` - Role string `json:"role"` - VoiceActors []JikanVoiceActor `json:"voice_actors"` + Character JikanCharacterPerson `json:"character"` + Role string `json:"role"` + VoiceActors []JikanVoiceActor `json:"voice_actors"` } type JikanVoiceActor struct { - MALID int `json:"mal_id"` - URL string `json:"url"` - Images JikanGenericImageEntity `json:"images"` - Name string `json:"name"` - Language string `json:"language"` + Person JikanCharacterPerson `json:"person"` + Language string `json:"language"` } type JikanAnimeCharacterResponse struct { diff --git a/types/tasks.go b/types/tasks.go index 10ba608..f86a497 100644 --- a/types/tasks.go +++ b/types/tasks.go @@ -6,6 +6,7 @@ type Task struct { Name string Interval time.Duration Execute func() error + OnResume func() LastRun time.Time Dependencies []string } |
