2019-06-03 09:32:43 +00:00
|
|
|
package arn
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"sort"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/aerogo/nano"
|
|
|
|
"github.com/animenotifier/notify.moe/arn/validate"
|
|
|
|
"github.com/animenotifier/twist"
|
|
|
|
|
|
|
|
"github.com/akyoto/color"
|
|
|
|
"github.com/animenotifier/kitsu"
|
|
|
|
"github.com/animenotifier/shoboi"
|
|
|
|
)
|
|
|
|
|
|
|
|
// AnimeDateFormat describes the anime date format for the date conversion.
|
|
|
|
const AnimeDateFormat = validate.DateFormat
|
|
|
|
|
|
|
|
// AnimeSourceHumanReadable maps the anime source to a human readable version.
|
|
|
|
var AnimeSourceHumanReadable = map[string]string{}
|
|
|
|
|
|
|
|
// Register a list of supported anime status and source types.
|
|
|
|
func init() {
|
|
|
|
DataLists["anime-types"] = []*Option{
|
|
|
|
{"tv", "TV"},
|
|
|
|
{"movie", "Movie"},
|
|
|
|
{"ova", "OVA"},
|
|
|
|
{"ona", "ONA"},
|
|
|
|
{"special", "Special"},
|
|
|
|
{"music", "Music"},
|
|
|
|
}
|
|
|
|
|
|
|
|
DataLists["anime-status"] = []*Option{
|
|
|
|
{"current", "Current"},
|
|
|
|
{"finished", "Finished"},
|
|
|
|
{"upcoming", "Upcoming"},
|
|
|
|
{"tba", "To be announced"},
|
|
|
|
}
|
|
|
|
|
|
|
|
DataLists["anime-sources"] = []*Option{
|
|
|
|
{"", "Unknown"},
|
|
|
|
{"original", "Original"},
|
|
|
|
{"manga", "Manga"},
|
|
|
|
{"novel", "Novel"},
|
|
|
|
{"light novel", "Light novel"},
|
|
|
|
{"visual novel", "Visual novel"},
|
|
|
|
{"game", "Game"},
|
|
|
|
{"book", "Book"},
|
|
|
|
{"4-koma manga", "4-koma Manga"},
|
|
|
|
{"music", "Music"},
|
|
|
|
{"picture book", "Picture book"},
|
|
|
|
{"web manga", "Web manga"},
|
|
|
|
{"other", "Other"},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, option := range DataLists["anime-sources"] {
|
|
|
|
AnimeSourceHumanReadable[option.Value] = option.Label
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-06-07 01:50:38 +00:00
|
|
|
// AnimeID represents an anime ID.
|
|
|
|
type AnimeID = string
|
|
|
|
|
2019-06-03 09:32:43 +00:00
|
|
|
// Anime represents an anime.
|
|
|
|
type Anime struct {
|
2019-06-07 01:50:38 +00:00
|
|
|
ID AnimeID `json:"id"`
|
2019-06-03 09:32:43 +00:00
|
|
|
Type string `json:"type" editable:"true" datalist:"anime-types"`
|
2019-06-04 03:16:27 +00:00
|
|
|
Title *MediaTitle `json:"title" editable:"true"`
|
2019-06-03 09:32:43 +00:00
|
|
|
Summary string `json:"summary" editable:"true" type:"textarea"`
|
|
|
|
Status string `json:"status" editable:"true" datalist:"anime-status"`
|
|
|
|
Genres []string `json:"genres" editable:"true"`
|
|
|
|
StartDate string `json:"startDate" editable:"true"`
|
|
|
|
EndDate string `json:"endDate" editable:"true"`
|
|
|
|
EpisodeCount int `json:"episodeCount" editable:"true"`
|
|
|
|
EpisodeLength int `json:"episodeLength" editable:"true"`
|
|
|
|
Source string `json:"source" editable:"true" datalist:"anime-sources"`
|
2019-06-04 03:16:27 +00:00
|
|
|
Image Image `json:"image"`
|
2019-06-03 09:32:43 +00:00
|
|
|
FirstChannel string `json:"firstChannel"`
|
|
|
|
Rating *AnimeRating `json:"rating"`
|
|
|
|
Popularity *AnimePopularity `json:"popularity"`
|
|
|
|
Trailers []*ExternalMedia `json:"trailers" editable:"true"`
|
|
|
|
|
|
|
|
// Mixins
|
|
|
|
hasMappings
|
|
|
|
hasPosts
|
|
|
|
hasLikes
|
|
|
|
hasCreator
|
|
|
|
hasEditor
|
|
|
|
hasDraft
|
|
|
|
|
|
|
|
// Company IDs
|
|
|
|
StudioIDs []string `json:"studios" editable:"true"`
|
|
|
|
ProducerIDs []string `json:"producers" editable:"true"`
|
|
|
|
LicensorIDs []string `json:"licensors" editable:"true"`
|
|
|
|
|
|
|
|
// Links to external websites
|
|
|
|
Links []*Link `json:"links" editable:"true"`
|
|
|
|
|
|
|
|
// SynopsisSource string `json:"synopsisSource" editable:"true"`
|
|
|
|
// Hashtag string `json:"hashtag"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewAnime creates a new anime.
|
|
|
|
func NewAnime() *Anime {
|
|
|
|
return &Anime{
|
2019-06-07 01:50:38 +00:00
|
|
|
ID: GenerateID("Anime"),
|
2019-06-04 03:16:27 +00:00
|
|
|
Title: &MediaTitle{},
|
2019-06-03 09:32:43 +00:00
|
|
|
Rating: &AnimeRating{},
|
|
|
|
Popularity: &AnimePopularity{},
|
|
|
|
Trailers: []*ExternalMedia{},
|
|
|
|
hasCreator: hasCreator{
|
|
|
|
Created: DateTimeUTC(),
|
|
|
|
},
|
|
|
|
hasMappings: hasMappings{
|
|
|
|
Mappings: []*Mapping{},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetAnime gets the anime with the given ID.
|
2019-06-07 01:50:38 +00:00
|
|
|
func GetAnime(id AnimeID) (*Anime, error) {
|
2019-06-03 09:32:43 +00:00
|
|
|
obj, err := DB.Get("Anime", id)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return obj.(*Anime), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// TitleByUser returns the preferred title for the given user.
|
|
|
|
func (anime *Anime) TitleByUser(user *User) string {
|
|
|
|
return anime.Title.ByUser(user)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddStudio adds the company ID to the studio ID list if it doesn't exist already.
|
|
|
|
func (anime *Anime) AddStudio(companyID string) {
|
|
|
|
// Is the ID valid?
|
|
|
|
if companyID == "" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// If it already exists we don't need to add it
|
|
|
|
for _, id := range anime.StudioIDs {
|
|
|
|
if id == companyID {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
anime.StudioIDs = append(anime.StudioIDs, companyID)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddProducer adds the company ID to the producer ID list if it doesn't exist already.
|
|
|
|
func (anime *Anime) AddProducer(companyID string) {
|
|
|
|
// Is the ID valid?
|
|
|
|
if companyID == "" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// If it already exists we don't need to add it
|
|
|
|
for _, id := range anime.ProducerIDs {
|
|
|
|
if id == companyID {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
anime.ProducerIDs = append(anime.ProducerIDs, companyID)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddLicensor adds the company ID to the licensor ID list if it doesn't exist already.
|
|
|
|
func (anime *Anime) AddLicensor(companyID string) {
|
|
|
|
// Is the ID valid?
|
|
|
|
if companyID == "" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// If it already exists we don't need to add it
|
|
|
|
for _, id := range anime.LicensorIDs {
|
|
|
|
if id == companyID {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
anime.LicensorIDs = append(anime.LicensorIDs, companyID)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Studios returns the list of studios for this anime.
|
|
|
|
func (anime *Anime) Studios() []*Company {
|
|
|
|
companies := []*Company{}
|
|
|
|
|
|
|
|
for _, obj := range DB.GetMany("Company", anime.StudioIDs) {
|
|
|
|
if obj == nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
companies = append(companies, obj.(*Company))
|
|
|
|
}
|
|
|
|
|
|
|
|
return companies
|
|
|
|
}
|
|
|
|
|
|
|
|
// Producers returns the list of producers for this anime.
|
|
|
|
func (anime *Anime) Producers() []*Company {
|
|
|
|
companies := []*Company{}
|
|
|
|
|
|
|
|
for _, obj := range DB.GetMany("Company", anime.ProducerIDs) {
|
|
|
|
if obj == nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
companies = append(companies, obj.(*Company))
|
|
|
|
}
|
|
|
|
|
|
|
|
return companies
|
|
|
|
}
|
|
|
|
|
|
|
|
// Licensors returns the list of licensors for this anime.
|
|
|
|
func (anime *Anime) Licensors() []*Company {
|
|
|
|
companies := []*Company{}
|
|
|
|
|
|
|
|
for _, obj := range DB.GetMany("Company", anime.LicensorIDs) {
|
|
|
|
if obj == nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
companies = append(companies, obj.(*Company))
|
|
|
|
}
|
|
|
|
|
|
|
|
return companies
|
|
|
|
}
|
|
|
|
|
|
|
|
// Prequels returns the list of prequels for that anime.
|
|
|
|
func (anime *Anime) Prequels() []*Anime {
|
|
|
|
prequels := []*Anime{}
|
|
|
|
relations := anime.Relations()
|
|
|
|
|
|
|
|
relations.Lock()
|
|
|
|
defer relations.Unlock()
|
|
|
|
|
|
|
|
for _, relation := range relations.Items {
|
|
|
|
if relation.Type != "prequel" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
prequel := relation.Anime()
|
|
|
|
|
|
|
|
if prequel == nil {
|
|
|
|
color.Red("Anime %s has invalid anime relation ID %s", anime.ID, relation.AnimeID)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
prequels = append(prequels, prequel)
|
|
|
|
}
|
|
|
|
|
|
|
|
return prequels
|
|
|
|
}
|
|
|
|
|
|
|
|
// ImageLink ...
|
|
|
|
func (anime *Anime) ImageLink(size string) string {
|
|
|
|
extension := ".jpg"
|
|
|
|
|
|
|
|
if size == "original" {
|
|
|
|
extension = anime.Image.Extension
|
|
|
|
}
|
|
|
|
|
|
|
|
return fmt.Sprintf("//%s/images/anime/%s/%s%s?%v", MediaHost, size, anime.ID, extension, anime.Image.LastModified)
|
|
|
|
}
|
|
|
|
|
|
|
|
// HasImage returns whether the anime has an image or not.
|
|
|
|
func (anime *Anime) HasImage() bool {
|
|
|
|
return anime.Image.Extension != "" && anime.Image.Width > 0
|
|
|
|
}
|
|
|
|
|
|
|
|
// AverageColor returns the average color of the image.
|
|
|
|
func (anime *Anime) AverageColor() string {
|
|
|
|
color := anime.Image.AverageColor
|
|
|
|
|
|
|
|
if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
return color.String()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Season returns the season the anime started airing in.
|
|
|
|
func (anime *Anime) Season() string {
|
|
|
|
if !validate.Date(anime.StartDate) {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
return DateToSeason(anime.StartDateTime())
|
|
|
|
}
|
|
|
|
|
|
|
|
// Characters ...
|
|
|
|
func (anime *Anime) Characters() *AnimeCharacters {
|
|
|
|
characters, _ := GetAnimeCharacters(anime.ID)
|
|
|
|
|
|
|
|
if characters != nil {
|
|
|
|
// TODO: Sort by role in sync-characters job
|
|
|
|
// Sort by role
|
|
|
|
sort.Slice(characters.Items, func(i, j int) bool {
|
|
|
|
// A little trick because "main" < "supporting"
|
|
|
|
return characters.Items[i].Role < characters.Items[j].Role
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return characters
|
|
|
|
}
|
|
|
|
|
|
|
|
// Relations ...
|
|
|
|
func (anime *Anime) Relations() *AnimeRelations {
|
|
|
|
relations, _ := GetAnimeRelations(anime.ID)
|
|
|
|
return relations
|
|
|
|
}
|
|
|
|
|
|
|
|
// Link returns the URI to the anime page.
|
|
|
|
func (anime *Anime) Link() string {
|
|
|
|
return "/anime/" + anime.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
// StartDateTime returns the start date as a time object.
|
|
|
|
func (anime *Anime) StartDateTime() time.Time {
|
|
|
|
format := AnimeDateFormat
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case len(anime.StartDate) >= len(AnimeDateFormat):
|
|
|
|
// ...
|
|
|
|
case len(anime.StartDate) >= len("2006-01"):
|
|
|
|
format = "2006-01"
|
|
|
|
case len(anime.StartDate) >= len("2006"):
|
|
|
|
format = "2006"
|
|
|
|
}
|
|
|
|
|
|
|
|
t, _ := time.Parse(format, anime.StartDate)
|
|
|
|
return t
|
|
|
|
}
|
|
|
|
|
|
|
|
// EndDateTime returns the end date as a time object.
|
|
|
|
func (anime *Anime) EndDateTime() time.Time {
|
|
|
|
format := AnimeDateFormat
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case len(anime.EndDate) >= len(AnimeDateFormat):
|
|
|
|
// ...
|
|
|
|
case len(anime.EndDate) >= len("2006-01"):
|
|
|
|
format = "2006-01"
|
|
|
|
case len(anime.EndDate) >= len("2006"):
|
|
|
|
format = "2006"
|
|
|
|
}
|
|
|
|
|
|
|
|
t, _ := time.Parse(format, anime.EndDate)
|
|
|
|
return t
|
|
|
|
}
|
|
|
|
|
|
|
|
// Episodes returns the anime episodes wrapper.
|
|
|
|
func (anime *Anime) Episodes() *AnimeEpisodes {
|
|
|
|
record, err := DB.Get("AnimeEpisodes", anime.ID)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return record.(*AnimeEpisodes)
|
|
|
|
}
|
|
|
|
|
|
|
|
// UsersWatchingOrPlanned returns a list of users who are watching the anime right now.
|
|
|
|
func (anime *Anime) UsersWatchingOrPlanned() []*User {
|
|
|
|
users := FilterUsers(func(user *User) bool {
|
|
|
|
item := user.AnimeList().Find(anime.ID)
|
|
|
|
|
|
|
|
if item == nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return item.Status == AnimeListStatusWatching || item.Status == AnimeListStatusPlanned
|
|
|
|
})
|
|
|
|
|
|
|
|
return users
|
|
|
|
}
|
|
|
|
|
|
|
|
// RefreshEpisodes will refresh the episode data.
|
|
|
|
func (anime *Anime) RefreshEpisodes() error {
|
|
|
|
// Fetch episodes
|
|
|
|
episodes := anime.Episodes()
|
|
|
|
|
|
|
|
if episodes == nil {
|
|
|
|
episodes = &AnimeEpisodes{
|
|
|
|
AnimeID: anime.ID,
|
|
|
|
Items: []*AnimeEpisode{},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save number of available episodes for comparison later
|
|
|
|
oldAvailableCount := episodes.AvailableCount()
|
|
|
|
|
|
|
|
// Shoboi
|
|
|
|
shoboiEpisodes, err := anime.ShoboiEpisodes()
|
|
|
|
|
|
|
|
if err != nil {
|
2019-06-05 06:45:54 +00:00
|
|
|
return err
|
2019-06-03 09:32:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
episodes.Merge(shoboiEpisodes)
|
|
|
|
|
|
|
|
// AnimeTwist
|
|
|
|
twistEpisodes, err := anime.TwistEpisodes()
|
|
|
|
|
|
|
|
if err != nil {
|
2019-06-05 06:45:54 +00:00
|
|
|
return err
|
2019-06-03 09:32:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
episodes.Merge(twistEpisodes)
|
|
|
|
|
|
|
|
// Count number of available episodes
|
|
|
|
newAvailableCount := episodes.AvailableCount()
|
|
|
|
|
|
|
|
if anime.Status != "finished" && newAvailableCount > oldAvailableCount {
|
|
|
|
// New episodes have been released.
|
|
|
|
// Notify all users who are watching the anime.
|
|
|
|
go func() {
|
|
|
|
for _, user := range anime.UsersWatchingOrPlanned() {
|
|
|
|
if !user.Settings().Notification.AnimeEpisodeReleases {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
user.SendNotification(&PushNotification{
|
|
|
|
Title: anime.Title.ByUser(user),
|
|
|
|
Message: "Episode " + strconv.Itoa(newAvailableCount) + " has been released!",
|
|
|
|
Icon: anime.ImageLink("medium"),
|
|
|
|
Link: "https://notify.moe" + anime.Link(),
|
|
|
|
Type: NotificationTypeAnimeEpisode,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Number remaining episodes
|
|
|
|
startNumber := 0
|
|
|
|
|
|
|
|
for _, episode := range episodes.Items {
|
|
|
|
if episode.Number != -1 {
|
|
|
|
startNumber = episode.Number
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
startNumber++
|
|
|
|
episode.Number = startNumber
|
|
|
|
}
|
|
|
|
|
|
|
|
// Guess airing dates
|
|
|
|
oneWeek := 7 * 24 * time.Hour
|
|
|
|
lastAiringDate := ""
|
|
|
|
timeDifference := oneWeek
|
|
|
|
|
|
|
|
for _, episode := range episodes.Items {
|
|
|
|
if validate.DateTime(episode.AiringDate.Start) {
|
|
|
|
if lastAiringDate != "" {
|
|
|
|
a, _ := time.Parse(time.RFC3339, lastAiringDate)
|
|
|
|
b, _ := time.Parse(time.RFC3339, episode.AiringDate.Start)
|
|
|
|
timeDifference = b.Sub(a)
|
|
|
|
|
|
|
|
// Cap time difference at one week
|
|
|
|
if timeDifference > oneWeek {
|
|
|
|
timeDifference = oneWeek
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
lastAiringDate = episode.AiringDate.Start
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add 1 week to the last known airing date
|
|
|
|
nextAiringDate, _ := time.Parse(time.RFC3339, lastAiringDate)
|
|
|
|
nextAiringDate = nextAiringDate.Add(timeDifference)
|
|
|
|
|
|
|
|
// Guess start and end time
|
|
|
|
episode.AiringDate.Start = nextAiringDate.Format(time.RFC3339)
|
|
|
|
episode.AiringDate.End = nextAiringDate.Add(30 * time.Minute).Format(time.RFC3339)
|
|
|
|
|
|
|
|
// Set this date as the new last known airing date
|
|
|
|
lastAiringDate = episode.AiringDate.Start
|
|
|
|
}
|
|
|
|
|
|
|
|
episodes.Save()
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ShoboiEpisodes returns a slice of episode info from cal.syoboi.jp.
|
|
|
|
func (anime *Anime) ShoboiEpisodes() ([]*AnimeEpisode, error) {
|
|
|
|
shoboiID := anime.GetMapping("shoboi/anime")
|
|
|
|
|
|
|
|
if shoboiID == "" {
|
|
|
|
return nil, errors.New("Missing shoboi/anime mapping")
|
|
|
|
}
|
|
|
|
|
|
|
|
shoboiAnime, err := shoboi.GetAnime(shoboiID)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
arnEpisodes := []*AnimeEpisode{}
|
|
|
|
shoboiEpisodes := shoboiAnime.Episodes()
|
|
|
|
|
|
|
|
for _, shoboiEpisode := range shoboiEpisodes {
|
|
|
|
episode := NewAnimeEpisode()
|
|
|
|
episode.Number = shoboiEpisode.Number
|
|
|
|
episode.Title.Japanese = shoboiEpisode.TitleJapanese
|
|
|
|
|
|
|
|
// Try to get airing date
|
|
|
|
airingDate := shoboiEpisode.AiringDate
|
|
|
|
|
|
|
|
if airingDate != nil {
|
|
|
|
episode.AiringDate.Start = airingDate.Start
|
|
|
|
episode.AiringDate.End = airingDate.End
|
|
|
|
} else {
|
|
|
|
episode.AiringDate.Start = ""
|
|
|
|
episode.AiringDate.End = ""
|
|
|
|
}
|
|
|
|
|
|
|
|
arnEpisodes = append(arnEpisodes, episode)
|
|
|
|
}
|
|
|
|
|
|
|
|
return arnEpisodes, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// TwistEpisodes returns a slice of episode info from twist.moe.
|
|
|
|
func (anime *Anime) TwistEpisodes() ([]*AnimeEpisode, error) {
|
|
|
|
idList, err := GetIDList("animetwist index")
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Does the index contain the ID?
|
|
|
|
kitsuID := anime.GetMapping("kitsu/anime")
|
|
|
|
found := false
|
|
|
|
|
|
|
|
for _, id := range idList {
|
|
|
|
if id == kitsuID {
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the ID is not the index we don't need to query the feed
|
|
|
|
if !found {
|
|
|
|
return nil, errors.New("Not available in twist.moe anime index")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get twist.moe feed
|
|
|
|
feed, err := twist.GetFeedByKitsuID(kitsuID)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
episodes := feed.Episodes
|
|
|
|
|
|
|
|
// Sort by episode number
|
|
|
|
sort.Slice(episodes, func(a, b int) bool {
|
|
|
|
return episodes[a].Number < episodes[b].Number
|
|
|
|
})
|
|
|
|
|
|
|
|
arnEpisodes := []*AnimeEpisode{}
|
|
|
|
|
|
|
|
for _, episode := range episodes {
|
|
|
|
arnEpisode := NewAnimeEpisode()
|
|
|
|
arnEpisode.Number = episode.Number
|
|
|
|
arnEpisode.Links = map[string]string{
|
|
|
|
"twist.moe": strings.Replace(episode.Link, "https://test.twist.moe/", "https://twist.moe/", 1),
|
|
|
|
}
|
|
|
|
|
|
|
|
arnEpisodes = append(arnEpisodes, arnEpisode)
|
|
|
|
}
|
|
|
|
|
|
|
|
return arnEpisodes, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// UpcomingEpisodes ...
|
|
|
|
func (anime *Anime) UpcomingEpisodes() []*UpcomingEpisode {
|
|
|
|
var upcomingEpisodes []*UpcomingEpisode
|
|
|
|
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
|
|
for _, episode := range anime.Episodes().Items {
|
|
|
|
if episode.AiringDate.Start > now && validate.DateTime(episode.AiringDate.Start) {
|
|
|
|
upcomingEpisodes = append(upcomingEpisodes, &UpcomingEpisode{
|
|
|
|
Anime: anime,
|
|
|
|
Episode: episode,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return upcomingEpisodes
|
|
|
|
}
|
|
|
|
|
|
|
|
// UpcomingEpisode ...
|
|
|
|
func (anime *Anime) UpcomingEpisode() *UpcomingEpisode {
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
|
|
for _, episode := range anime.Episodes().Items {
|
|
|
|
if episode.AiringDate.Start > now && validate.DateTime(episode.AiringDate.Start) {
|
|
|
|
return &UpcomingEpisode{
|
|
|
|
Anime: anime,
|
|
|
|
Episode: episode,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// EpisodeCountString ...
|
|
|
|
func (anime *Anime) EpisodeCountString() string {
|
|
|
|
if anime.EpisodeCount == 0 {
|
|
|
|
return "?"
|
|
|
|
}
|
|
|
|
|
|
|
|
return strconv.Itoa(anime.EpisodeCount)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ImportKitsuMapping imports the given Kitsu mapping.
|
|
|
|
func (anime *Anime) ImportKitsuMapping(mapping *kitsu.Mapping) {
|
|
|
|
switch mapping.Attributes.ExternalSite {
|
|
|
|
case "myanimelist/anime":
|
|
|
|
anime.SetMapping("myanimelist/anime", mapping.Attributes.ExternalID)
|
|
|
|
case "anidb":
|
|
|
|
anime.SetMapping("anidb/anime", mapping.Attributes.ExternalID)
|
|
|
|
case "trakt":
|
|
|
|
anime.SetMapping("trakt/anime", mapping.Attributes.ExternalID)
|
|
|
|
// case "hulu":
|
|
|
|
// anime.SetMapping("hulu/anime", mapping.Attributes.ExternalID)
|
|
|
|
case "anilist":
|
|
|
|
externalID := mapping.Attributes.ExternalID
|
|
|
|
externalID = strings.TrimPrefix(externalID, "anime/")
|
|
|
|
|
|
|
|
anime.SetMapping("anilist/anime", externalID)
|
|
|
|
case "thetvdb", "thetvdb/series":
|
|
|
|
externalID := mapping.Attributes.ExternalID
|
|
|
|
slashPos := strings.Index(externalID, "/")
|
|
|
|
|
|
|
|
if slashPos != -1 {
|
|
|
|
externalID = externalID[:slashPos]
|
|
|
|
}
|
|
|
|
|
|
|
|
anime.SetMapping("thetvdb/anime", externalID)
|
|
|
|
case "thetvdb/season":
|
|
|
|
// Ignore
|
|
|
|
default:
|
|
|
|
color.Yellow("Unknown mapping: %s %s", mapping.Attributes.ExternalSite, mapping.Attributes.ExternalID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// TypeHumanReadable ...
|
|
|
|
func (anime *Anime) TypeHumanReadable() string {
|
|
|
|
switch anime.Type {
|
|
|
|
case "tv":
|
|
|
|
return "TV"
|
|
|
|
case "movie":
|
|
|
|
return "Movie"
|
|
|
|
case "ova":
|
|
|
|
return "OVA"
|
|
|
|
case "ona":
|
|
|
|
return "ONA"
|
|
|
|
case "special":
|
|
|
|
return "Special"
|
|
|
|
case "music":
|
|
|
|
return "Music"
|
|
|
|
default:
|
|
|
|
return anime.Type
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// StatusHumanReadable ...
|
|
|
|
func (anime *Anime) StatusHumanReadable() string {
|
|
|
|
switch anime.Status {
|
|
|
|
case "finished":
|
|
|
|
return "Finished"
|
|
|
|
case "current":
|
|
|
|
return "Airing"
|
|
|
|
case "upcoming":
|
|
|
|
return "Upcoming"
|
|
|
|
case "tba":
|
|
|
|
return "To be announced"
|
|
|
|
default:
|
|
|
|
return anime.Status
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// CalculatedStatus returns the status of the anime inferred by the start and end date.
|
|
|
|
func (anime *Anime) CalculatedStatus() string {
|
|
|
|
// If we are past the end date, the anime is finished.
|
|
|
|
if validate.Date(anime.EndDate) {
|
|
|
|
end := anime.EndDateTime()
|
|
|
|
|
|
|
|
if time.Since(end) > 0 {
|
|
|
|
return "finished"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we have a start date and we didn't reach the end date, it's either current or upcoming.
|
|
|
|
if validate.Date(anime.StartDate) {
|
|
|
|
start := anime.StartDateTime()
|
|
|
|
|
|
|
|
if time.Since(start) > 0 {
|
|
|
|
return "current"
|
|
|
|
}
|
|
|
|
|
|
|
|
return "upcoming"
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we have no date information it's to be announced.
|
|
|
|
return "tba"
|
|
|
|
}
|
|
|
|
|
|
|
|
// EpisodeByNumber returns the episode with the given number.
|
|
|
|
func (anime *Anime) EpisodeByNumber(number int) *AnimeEpisode {
|
|
|
|
for _, episode := range anime.Episodes().Items {
|
|
|
|
if number == episode.Number {
|
|
|
|
return episode
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// RefreshAnimeCharacters ...
|
|
|
|
func (anime *Anime) RefreshAnimeCharacters() (*AnimeCharacters, error) {
|
|
|
|
resp, err := kitsu.GetAnimeCharactersForAnime(anime.GetMapping("kitsu/anime"))
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
animeCharacters := &AnimeCharacters{
|
|
|
|
AnimeID: anime.ID,
|
|
|
|
Items: []*AnimeCharacter{},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, incl := range resp.Included {
|
|
|
|
if incl.Type != "animeCharacters" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
role := incl.Attributes["role"].(string)
|
|
|
|
characterID := incl.Relationships.Character.Data.ID
|
|
|
|
|
|
|
|
animeCharacter := &AnimeCharacter{
|
|
|
|
CharacterID: characterID,
|
|
|
|
Role: role,
|
|
|
|
}
|
|
|
|
|
|
|
|
animeCharacters.Items = append(animeCharacters.Items, animeCharacter)
|
|
|
|
}
|
|
|
|
|
|
|
|
animeCharacters.Save()
|
|
|
|
|
|
|
|
return animeCharacters, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// String implements the default string serialization.
|
|
|
|
func (anime *Anime) String() string {
|
|
|
|
return anime.Title.Canonical
|
|
|
|
}
|
|
|
|
|
2019-06-07 01:50:38 +00:00
|
|
|
// GetID returns the ID.
|
|
|
|
func (anime *Anime) GetID() string {
|
|
|
|
return anime.ID
|
|
|
|
}
|
|
|
|
|
2019-06-03 09:32:43 +00:00
|
|
|
// TypeName returns the type name.
|
|
|
|
func (anime *Anime) TypeName() string {
|
|
|
|
return "Anime"
|
|
|
|
}
|
|
|
|
|
|
|
|
// Self returns the object itself.
|
|
|
|
func (anime *Anime) Self() Loggable {
|
|
|
|
return anime
|
|
|
|
}
|
|
|
|
|
|
|
|
// StreamAnime returns a stream of all anime.
|
|
|
|
func StreamAnime() <-chan *Anime {
|
|
|
|
channel := make(chan *Anime, nano.ChannelBufferSize)
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
for obj := range DB.All("Anime") {
|
|
|
|
channel <- obj.(*Anime)
|
|
|
|
}
|
|
|
|
|
|
|
|
close(channel)
|
|
|
|
}()
|
|
|
|
|
|
|
|
return channel
|
|
|
|
}
|
|
|
|
|
|
|
|
// AllAnime returns a slice of all anime.
|
|
|
|
func AllAnime() []*Anime {
|
|
|
|
all := make([]*Anime, 0, DB.Collection("Anime").Count())
|
|
|
|
|
|
|
|
stream := StreamAnime()
|
|
|
|
|
|
|
|
for obj := range stream {
|
|
|
|
all = append(all, obj)
|
|
|
|
}
|
|
|
|
|
|
|
|
return all
|
|
|
|
}
|
|
|
|
|
|
|
|
// FilterAnime filters all anime by a custom function.
|
|
|
|
func FilterAnime(filter func(*Anime) bool) []*Anime {
|
|
|
|
var filtered []*Anime
|
|
|
|
|
|
|
|
channel := DB.All("Anime")
|
|
|
|
|
|
|
|
for obj := range channel {
|
|
|
|
realObject := obj.(*Anime)
|
|
|
|
|
|
|
|
if filter(realObject) {
|
|
|
|
filtered = append(filtered, realObject)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return filtered
|
|
|
|
}
|
|
|
|
|
|
|
|
// // SetID performs a database-wide ID change.
|
|
|
|
// // Calling this will automatically save the anime.
|
|
|
|
// func (anime *Anime) SetID(newID string) {
|
|
|
|
// oldID := anime.ID
|
|
|
|
|
|
|
|
// // Update anime ID in character list
|
|
|
|
// characters, _ := GetAnimeCharacters(oldID)
|
|
|
|
// characters.Delete()
|
|
|
|
// characters.AnimeID = newID
|
|
|
|
// characters.Save()
|
|
|
|
|
|
|
|
// // Update anime ID in relation list
|
|
|
|
// relations, _ := GetAnimeRelations(oldID)
|
|
|
|
// relations.Delete()
|
|
|
|
// relations.AnimeID = newID
|
|
|
|
// relations.Save()
|
|
|
|
|
|
|
|
// // Update anime ID in episode list
|
|
|
|
// episodes, _ := GetAnimeEpisodes(oldID)
|
|
|
|
// episodes.Delete()
|
|
|
|
// episodes.AnimeID = newID
|
|
|
|
// episodes.Save()
|
|
|
|
|
|
|
|
// // Update anime list items
|
|
|
|
// for animeList := range StreamAnimeLists() {
|
|
|
|
// item := animeList.Find(oldID)
|
|
|
|
|
|
|
|
// if item != nil {
|
|
|
|
// item.AnimeID = newID
|
|
|
|
// animeList.Save()
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // Update relations pointing to this anime
|
|
|
|
// for relations := range StreamAnimeRelations() {
|
|
|
|
// relation := relations.Find(oldID)
|
|
|
|
|
|
|
|
// if relation != nil {
|
|
|
|
// relation.AnimeID = newID
|
|
|
|
// relations.Save()
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // Update quotes
|
|
|
|
// for quote := range StreamQuotes() {
|
|
|
|
// if quote.AnimeID == oldID {
|
|
|
|
// quote.AnimeID = newID
|
|
|
|
// quote.Save()
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // Update log entries
|
|
|
|
// for entry := range StreamEditLogEntries() {
|
|
|
|
// switch entry.ObjectType {
|
|
|
|
// case "Anime", "AnimeRelations", "AnimeCharacters", "AnimeEpisodes":
|
|
|
|
// if entry.ObjectID == oldID {
|
|
|
|
// entry.ObjectID = newID
|
|
|
|
// entry.Save()
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // Update ignored anime differences
|
|
|
|
// for ignore := range StreamIgnoreAnimeDifferences() {
|
|
|
|
// // ID example: arn:10052|mal:28701|RomajiTitle
|
|
|
|
// arnPart := strings.Split(ignore.ID, "|")[0]
|
|
|
|
// actualID := strings.Split(arnPart, ":")[1]
|
|
|
|
|
|
|
|
// if actualID == oldID {
|
|
|
|
// DB.Delete("IgnoreAnimeDifference", ignore.ID)
|
|
|
|
// ignore.ID = strings.Replace(ignore.ID, arnPart, "arn:"+newID, 1)
|
|
|
|
// ignore.Save()
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // Update soundtrack tags
|
|
|
|
// for track := range StreamSoundTracks() {
|
|
|
|
// newTags := []string{}
|
|
|
|
// modified := false
|
|
|
|
|
|
|
|
// for _, tag := range track.Tags {
|
|
|
|
// if strings.HasPrefix(tag, "anime:") {
|
|
|
|
// parts := strings.Split(tag, ":")
|
|
|
|
// id := parts[1]
|
|
|
|
|
|
|
|
// if id == oldID {
|
|
|
|
// newTags = append(newTags, "anime:"+newID)
|
|
|
|
// modified = true
|
|
|
|
// continue
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// newTags = append(newTags, tag)
|
|
|
|
// }
|
|
|
|
|
|
|
|
// if modified {
|
|
|
|
// track.Tags = newTags
|
|
|
|
// track.Save()
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // Update images on file system
|
|
|
|
// anime.MoveImageFiles(oldID, newID)
|
|
|
|
|
|
|
|
// // Delete old anime ID
|
|
|
|
// DB.Delete("Anime", oldID)
|
|
|
|
|
|
|
|
// // Change anime ID and save it
|
|
|
|
// anime.ID = newID
|
|
|
|
// anime.Save()
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // MoveImageFiles ...
|
|
|
|
// func (anime *Anime) MoveImageFiles(oldID string, newID string) {
|
|
|
|
// if anime.Image.Extension == "" {
|
|
|
|
// return
|
|
|
|
// }
|
|
|
|
|
|
|
|
// err := os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/original/", oldID+anime.Image.Extension),
|
|
|
|
// path.Join(Root, "images/anime/original/", newID+anime.Image.Extension),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// if err != nil {
|
|
|
|
// // Don't return the error.
|
|
|
|
// // It's too late to stop the process at this point.
|
|
|
|
// // Instead, log the error.
|
|
|
|
// color.Red(err.Error())
|
|
|
|
// }
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/large/", oldID+".jpg"),
|
|
|
|
// path.Join(Root, "images/anime/large/", newID+".jpg"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/large/", oldID+"@2.jpg"),
|
|
|
|
// path.Join(Root, "images/anime/large/", newID+"@2.jpg"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/large/", oldID+".webp"),
|
|
|
|
// path.Join(Root, "images/anime/large/", newID+".webp"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/large/", oldID+"@2.webp"),
|
|
|
|
// path.Join(Root, "images/anime/large/", newID+"@2.webp"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/medium/", oldID+".jpg"),
|
|
|
|
// path.Join(Root, "images/anime/medium/", newID+".jpg"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/medium/", oldID+"@2.jpg"),
|
|
|
|
// path.Join(Root, "images/anime/medium/", newID+"@2.jpg"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/medium/", oldID+".webp"),
|
|
|
|
// path.Join(Root, "images/anime/medium/", newID+".webp"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/medium/", oldID+"@2.webp"),
|
|
|
|
// path.Join(Root, "images/anime/medium/", newID+"@2.webp"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/small/", oldID+".jpg"),
|
|
|
|
// path.Join(Root, "images/anime/small/", newID+".jpg"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/small/", oldID+"@2.jpg"),
|
|
|
|
// path.Join(Root, "images/anime/small/", newID+"@2.jpg"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/small/", oldID+".webp"),
|
|
|
|
// path.Join(Root, "images/anime/small/", newID+".webp"),
|
|
|
|
// )
|
|
|
|
|
|
|
|
// os.Rename(
|
|
|
|
// path.Join(Root, "images/anime/small/", oldID+"@2.webp"),
|
|
|
|
// path.Join(Root, "images/anime/small/", newID+"@2.webp"),
|
|
|
|
// )
|
|
|
|
// }
|