Added arn to the main repository

This commit is contained in:
Eduard Urbach 2019-06-03 18:32:43 +09:00
parent cf258573a8
commit 29a48d94a5
Signed by: akyoto
GPG Key ID: C874F672B1AF20C0
465 changed files with 15968 additions and 288 deletions

267
arn/AMV.go Normal file
View File

@ -0,0 +1,267 @@
package arn
import (
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"github.com/aerogo/nano"
"github.com/animenotifier/notify.moe/arn/video"
)
// AMV is an anime music video.
type AMV struct {
File string `json:"file" editable:"true" type:"upload" filetype:"video" endpoint:"/api/upload/amv/:id/file"`
Title AMVTitle `json:"title" editable:"true"`
MainAnimeID string `json:"mainAnimeId" editable:"true"`
ExtraAnimeIDs []string `json:"extraAnimeIds" editable:"true"`
VideoEditorIDs []string `json:"videoEditorIds" editable:"true"`
Links []Link `json:"links" editable:"true"`
Tags []string `json:"tags" editable:"true"`
Info video.Info `json:"info"`
hasID
hasPosts
hasCreator
hasEditor
hasLikes
hasDraft
}
// Link returns the permalink for the AMV.
func (amv *AMV) Link() string {
return "/amv/" + amv.ID
}
// TitleByUser returns the preferred title for the given user.
func (amv *AMV) TitleByUser(user *User) string {
return amv.Title.ByUser(user)
}
// SetVideoBytes sets the bytes for the video file.
func (amv *AMV) SetVideoBytes(data []byte) error {
fileName := amv.ID + ".webm"
filePath := path.Join(Root, "videos", "amvs", fileName)
err := ioutil.WriteFile(filePath, data, 0644)
if err != nil {
return err
}
// Run mkclean
optimizedFile := filePath + ".optimized"
cmd := exec.Command(
"mkclean",
"--doctype", "4",
"--keep-cues",
"--optimize",
filePath,
optimizedFile,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Start()
if err != nil {
return err
}
err = cmd.Wait()
if err != nil {
return err
}
// Now delete the original file and replace it with the optimized file
err = os.Remove(filePath)
if err != nil {
return err
}
err = os.Rename(optimizedFile, filePath)
if err != nil {
return err
}
// Refresh video file info
amv.File = fileName
return amv.RefreshInfo()
}
// RefreshInfo refreshes the information about the video file.
func (amv *AMV) RefreshInfo() error {
if amv.File == "" {
return fmt.Errorf("Video file has not been uploaded yet for AMV %s", amv.ID)
}
info, err := video.GetInfo(path.Join(Root, "videos", "amvs", amv.File))
if err != nil {
return err
}
amv.Info = *info
return nil
}
// MainAnime returns main anime for the AMV, if available.
func (amv *AMV) MainAnime() *Anime {
mainAnime, _ := GetAnime(amv.MainAnimeID)
return mainAnime
}
// ExtraAnime returns main anime for the AMV, if available.
func (amv *AMV) ExtraAnime() []*Anime {
objects := DB.GetMany("Anime", amv.ExtraAnimeIDs)
animes := []*Anime{}
for _, obj := range objects {
if obj == nil {
continue
}
animes = append(animes, obj.(*Anime))
}
return animes
}
// VideoEditors returns a slice of all the users involved in creating the AMV.
func (amv *AMV) VideoEditors() []*User {
objects := DB.GetMany("User", amv.VideoEditorIDs)
editors := []*User{}
for _, obj := range objects {
if obj == nil {
continue
}
editors = append(editors, obj.(*User))
}
return editors
}
// Publish turns the draft into a published object.
func (amv *AMV) Publish() error {
// No title
if amv.Title.String() == "" {
return errors.New("AMV doesn't have a title")
}
// No anime found
if amv.MainAnimeID == "" && len(amv.ExtraAnimeIDs) == 0 {
return errors.New("Need to specify at least one anime")
}
// No file uploaded
if amv.File == "" {
return errors.New("You need to upload a WebM file for this AMV")
}
if _, err := os.Stat(path.Join(Root, "videos", "amvs", amv.File)); os.IsNotExist(err) {
return errors.New("You need to upload a WebM file for this AMV")
}
return publish(amv)
}
// Unpublish turns the object back into a draft.
func (amv *AMV) Unpublish() error {
return unpublish(amv)
}
// OnLike is called when the AMV receives a like.
func (amv *AMV) OnLike(likedBy *User) {
if likedBy.ID == amv.CreatedBy {
return
}
go func() {
amv.Creator().SendNotification(&PushNotification{
Title: likedBy.Nick + " liked your AMV " + amv.Title.ByUser(amv.Creator()),
Message: likedBy.Nick + " liked your AMV " + amv.Title.ByUser(amv.Creator()) + ".",
Icon: "https:" + likedBy.AvatarLink("large"),
Link: "https://notify.moe" + likedBy.Link(),
Type: NotificationTypeLike,
})
}()
}
// String implements the default string serialization.
func (amv *AMV) String() string {
return amv.Title.ByUser(nil)
}
// TypeName returns the type name.
func (amv *AMV) TypeName() string {
return "AMV"
}
// Self returns the object itself.
func (amv *AMV) Self() Loggable {
return amv
}
// GetAMV returns the AMV with the given ID.
func GetAMV(id string) (*AMV, error) {
obj, err := DB.Get("AMV", id)
if err != nil {
return nil, err
}
return obj.(*AMV), nil
}
// StreamAMVs returns a stream of all AMVs.
func StreamAMVs() <-chan *AMV {
channel := make(chan *AMV, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("AMV") {
channel <- obj.(*AMV)
}
close(channel)
}()
return channel
}
// AllAMVs returns a slice of all AMVs.
func AllAMVs() []*AMV {
all := make([]*AMV, 0, DB.Collection("AMV").Count())
stream := StreamAMVs()
for obj := range stream {
all = append(all, obj)
}
return all
}
// FilterAMVs filters all AMVs by a custom function.
func FilterAMVs(filter func(*AMV) bool) []*AMV {
var filtered []*AMV
for obj := range DB.All("AMV") {
realObject := obj.(*AMV)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}

139
arn/AMVAPI.go Normal file
View File

@ -0,0 +1,139 @@
package arn
import (
"errors"
"fmt"
"os"
"path"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ Publishable = (*AMV)(nil)
_ Likeable = (*AMV)(nil)
_ LikeEventReceiver = (*AMV)(nil)
_ PostParent = (*AMV)(nil)
_ fmt.Stringer = (*AMV)(nil)
_ api.Newable = (*AMV)(nil)
_ api.Editable = (*AMV)(nil)
_ api.Deletable = (*AMV)(nil)
_ api.ArrayEventListener = (*AMV)(nil)
)
// Actions
func init() {
API.RegisterActions("AMV", []*api.Action{
// Publish
PublishAction(),
// Unpublish
UnpublishAction(),
// Like
LikeAction(),
// Unlike
UnlikeAction(),
})
}
// Create sets the data for a new AMV with data we received from the API request.
func (amv *AMV) Create(ctx aero.Context) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
amv.ID = GenerateID("AMV")
amv.Created = DateTimeUTC()
amv.CreatedBy = user.ID
// Write log entry
logEntry := NewEditLogEntry(user.ID, "create", "AMV", amv.ID, "", "", "")
logEntry.Save()
return amv.Unpublish()
}
// Edit creates an edit log entry.
func (amv *AMV) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(amv, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (amv *AMV) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(amv, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (amv *AMV) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(amv, ctx, key, index, obj)
}
// DeleteInContext deletes the amv in the given context.
func (amv *AMV) DeleteInContext(ctx aero.Context) error {
user := GetUserFromContext(ctx)
// Write log entry
logEntry := NewEditLogEntry(user.ID, "delete", "AMV", amv.ID, "", fmt.Sprint(amv), "")
logEntry.Save()
return amv.Delete()
}
// Delete deletes the object from the database.
func (amv *AMV) Delete() error {
if amv.IsDraft {
draftIndex := amv.Creator().DraftIndex()
draftIndex.AMVID = ""
draftIndex.Save()
}
// Remove posts
for _, post := range amv.Posts() {
err := post.Delete()
if err != nil {
return err
}
}
// Remove file
if amv.File != "" {
err := os.Remove(path.Join(Root, "videos", "amvs", amv.File))
if err != nil {
return err
}
}
DB.Delete("AMV", amv.ID)
return nil
}
// Authorize returns an error if the given API POST request is not authorized.
func (amv *AMV) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
if action == "delete" {
if user.Role != "editor" && user.Role != "admin" {
return errors.New("Insufficient permissions")
}
}
return nil
}
// Save saves the amv object in the database.
func (amv *AMV) Save() {
DB.Set("AMV", amv.ID, amv)
}

32
arn/AMVTitle.go Normal file
View File

@ -0,0 +1,32 @@
package arn
// AMVTitle is the same as a soundtrack title.
type AMVTitle SoundTrackTitle
// String is the default representation of the title.
func (title *AMVTitle) String() string {
return title.ByUser(nil)
}
// ByUser returns the preferred title for the given user.
func (title *AMVTitle) ByUser(user *User) string {
if user == nil {
if title.Canonical != "" {
return title.Canonical
}
return title.Native
}
switch user.Settings().TitleLanguage {
case "japanese":
if title.Native == "" {
return title.Canonical
}
return title.Native
default:
return title.ByUser(nil)
}
}

121
arn/APIKeys.go Normal file
View File

@ -0,0 +1,121 @@
package arn
import (
"io/ioutil"
"os"
"path"
"github.com/animenotifier/anilist"
"github.com/animenotifier/osu"
jsoniter "github.com/json-iterator/go"
)
// Root is the full path to the root directory of notify.moe repository.
var Root = os.Getenv("ARN_ROOT")
// APIKeys are global API keys for several services
var APIKeys APIKeysData
// APIKeysData ...
type APIKeysData struct {
Google struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"google"`
Facebook struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"facebook"`
Twitter struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"twitter"`
Discord struct {
ID string `json:"id"`
Secret string `json:"secret"`
Token string `json:"token"`
} `json:"discord"`
SoundCloud struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"soundcloud"`
GoogleAPI struct {
Key string `json:"key"`
} `json:"googleAPI"`
IPInfoDB struct {
ID string `json:"id"`
} `json:"ipInfoDB"`
AniList struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"anilist"`
Osu struct {
Secret string `json:"secret"`
} `json:"osu"`
PayPal struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"paypal"`
VAPID struct {
Subject string `json:"subject"`
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKey"`
} `json:"vapid"`
SMTP struct {
Server string `json:"server"`
Address string `json:"address"`
Password string `json:"password"`
} `json:"smtp"`
S3 struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"s3"`
}
func init() {
// Path for API keys
apiKeysPath := path.Join(Root, "security/api-keys.json")
// If the API keys file is not available, create a symlink to the default API keys
if _, err := os.Stat(apiKeysPath); os.IsNotExist(err) {
defaultAPIKeysPath := path.Join(Root, "security/default/api-keys.json")
err := os.Link(defaultAPIKeysPath, apiKeysPath)
if err != nil {
panic(err)
}
}
// Load API keys
data, err := ioutil.ReadFile(apiKeysPath)
if err != nil {
panic(err)
}
// Parse JSON
err = jsoniter.Unmarshal(data, &APIKeys)
if err != nil {
panic(err)
}
// Set Osu API key
osu.APIKey = APIKeys.Osu.Secret
// Set Anilist API keys
anilist.APIKeyID = APIKeys.AniList.ID
anilist.APIKeySecret = APIKeys.AniList.Secret
}

81
arn/Activity.go Normal file
View File

@ -0,0 +1,81 @@
package arn
import (
"sort"
"sync"
"time"
"github.com/aerogo/nano"
)
// Activity is a user activity that appears in the follower's feeds.
type Activity interface {
Creator() *User
TypeName() string
GetID() string
GetCreated() string
GetCreatedBy() UserID
GetCreatedTime() time.Time
}
// SortActivitiesLatestFirst puts the latest entries on top.
func SortActivitiesLatestFirst(entries []Activity) {
sort.Slice(entries, func(i, j int) bool {
return entries[i].GetCreated() > entries[j].GetCreated()
})
}
// StreamActivities returns a stream of all activities.
func StreamActivities() <-chan Activity {
channel := make(chan Activity, nano.ChannelBufferSize)
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
for obj := range DB.All("ActivityCreate") {
channel <- obj.(Activity)
}
wg.Done()
}()
go func() {
for obj := range DB.All("ActivityConsumeAnime") {
channel <- obj.(Activity)
}
wg.Done()
}()
go func() {
wg.Wait()
close(channel)
}()
return channel
}
// AllActivities returns a slice of all activities.
func AllActivities() []Activity {
all := make([]Activity, 0, DB.Collection("ActivityCreate").Count()+DB.Collection("ActivityConsumeAnime").Count())
stream := StreamActivities()
for obj := range stream {
all = append(all, obj)
}
return all
}
// FilterActivities filters all Activities by a custom function.
func FilterActivities(filter func(Activity) bool) []Activity {
var filtered []Activity
for obj := range StreamActivities() {
if filter(obj) {
filtered = append(filtered, obj)
}
}
return filtered
}

View File

@ -0,0 +1,97 @@
package arn
import "sort"
// ActivityConsumeAnime is a user activity that consumes anime.
type ActivityConsumeAnime struct {
AnimeID string `json:"animeId"`
FromEpisode int `json:"fromEpisode"`
ToEpisode int `json:"toEpisode"`
hasID
hasCreator
hasLikes
}
// NewActivityConsumeAnime creates a new activity.
func NewActivityConsumeAnime(animeID string, fromEpisode int, toEpisode int, userID UserID) *ActivityConsumeAnime {
return &ActivityConsumeAnime{
hasID: hasID{
ID: GenerateID("ActivityConsumeAnime"),
},
hasCreator: hasCreator{
Created: DateTimeUTC(),
CreatedBy: userID,
},
AnimeID: animeID,
FromEpisode: fromEpisode,
ToEpisode: toEpisode,
}
}
// Anime returns the anime.
func (activity *ActivityConsumeAnime) Anime() *Anime {
anime, _ := GetAnime(activity.AnimeID)
return anime
}
// TypeName returns the type name.
func (activity *ActivityConsumeAnime) TypeName() string {
return "ActivityConsumeAnime"
}
// Self returns the object itself.
func (activity *ActivityConsumeAnime) Self() Loggable {
return activity
}
// LastActivityConsumeAnime returns the last activity for the given anime.
func (user *User) LastActivityConsumeAnime(animeID string) *ActivityConsumeAnime {
activities := FilterActivitiesConsumeAnime(func(activity *ActivityConsumeAnime) bool {
return activity.AnimeID == animeID && activity.CreatedBy == user.ID
})
if len(activities) == 0 {
return nil
}
sort.Slice(activities, func(i, j int) bool {
return activities[i].Created > activities[j].Created
})
return activities[0]
}
// FilterActivitiesConsumeAnime filters all anime consumption activities by a custom function.
func FilterActivitiesConsumeAnime(filter func(*ActivityConsumeAnime) bool) []*ActivityConsumeAnime {
var filtered []*ActivityConsumeAnime
for obj := range DB.All("ActivityConsumeAnime") {
realObject := obj.(*ActivityConsumeAnime)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}
// // OnLike is called when the activity receives a like.
// func (activity *Activity) OnLike(likedBy *User) {
// if likedBy.ID == activity.CreatedBy {
// return
// }
// go func() {
// notifyUser := activity.Creator()
// notifyUser.SendNotification(&PushNotification{
// Title: likedBy.Nick + " liked your activity",
// Message: activity.TextByUser(notifyUser),
// Icon: "https:" + likedBy.AvatarLink("large"),
// Link: "https://notify.moe" + activity.Link(),
// Type: NotificationTypeLike,
// })
// }()
// }

View File

@ -0,0 +1,80 @@
package arn
import (
"errors"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ Activity = (*ActivityConsumeAnime)(nil)
_ api.Deletable = (*ActivityConsumeAnime)(nil)
_ api.Savable = (*ActivityConsumeAnime)(nil)
)
// Authorize returns an error if the given API POST request is not authorized.
func (activity *ActivityConsumeAnime) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
if user.ID != activity.CreatedBy {
return errors.New("Can't modify activities from other users")
}
return nil
}
// Save saves the activity object in the database.
func (activity *ActivityConsumeAnime) Save() {
DB.Set("ActivityConsumeAnime", activity.ID, activity)
}
// DeleteInContext deletes the activity in the given context.
func (activity *ActivityConsumeAnime) DeleteInContext(ctx aero.Context) error {
return activity.Delete()
}
// Delete deletes the object from the database.
func (activity *ActivityConsumeAnime) Delete() error {
DB.Delete("ActivityConsumeAnime", activity.ID)
return nil
}
// // Force interface implementations
// var (
// _ Likeable = (*Activity)(nil)
// _ LikeEventReceiver = (*Activity)(nil)
// _ api.Deletable = (*Activity)(nil)
// )
// // Actions
// func init() {
// API.RegisterActions("Activity", []*api.Action{
// // Like
// LikeAction(),
// // Unlike
// UnlikeAction(),
// })
// }
// // Authorize returns an error if the given API request is not authorized.
// func (activity *Activity) Authorize(ctx aero.Context, action string) error {
// user := GetUserFromContext(ctx)
// if user == nil {
// return errors.New("Not logged in")
// }
// return nil
// }
// // DeleteInContext deletes the activity in the given context.
// func (activity *Activity) DeleteInContext(ctx aero.Context) error {
// return activity.Delete()
// }

63
arn/ActivityCreate.go Normal file
View File

@ -0,0 +1,63 @@
package arn
import "github.com/aerogo/nano"
// ActivityCreate is a user activity that creates something.
type ActivityCreate struct {
ObjectType string `json:"objectType"`
ObjectID string `json:"objectId"`
hasID
hasCreator
}
// NewActivityCreate creates a new activity.
func NewActivityCreate(objectType string, objectID string, userID UserID) *ActivityCreate {
return &ActivityCreate{
hasID: hasID{
ID: GenerateID("ActivityCreate"),
},
hasCreator: hasCreator{
Created: DateTimeUTC(),
CreatedBy: userID,
},
ObjectType: objectType,
ObjectID: objectID,
}
}
// Object returns the object that was created.
func (activity *ActivityCreate) Object() Likeable {
obj, _ := DB.Get(activity.ObjectType, activity.ObjectID)
return obj.(Likeable)
}
// Postable casts the object to the Postable interface.
func (activity *ActivityCreate) Postable() Postable {
return activity.Object().(Postable)
}
// TypeName returns the type name.
func (activity *ActivityCreate) TypeName() string {
return "ActivityCreate"
}
// Self returns the object itself.
func (activity *ActivityCreate) Self() Loggable {
return activity
}
// StreamActivityCreates returns a stream of all ActivityCreate objects.
func StreamActivityCreates() <-chan *ActivityCreate {
channel := make(chan *ActivityCreate, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("ActivityCreate") {
channel <- obj.(*ActivityCreate)
}
close(channel)
}()
return channel
}

22
arn/ActivityCreateAPI.go Normal file
View File

@ -0,0 +1,22 @@
package arn
import (
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ Activity = (*ActivityCreate)(nil)
_ api.Savable = (*ActivityCreate)(nil)
)
// Save saves the activity object in the database.
func (activity *ActivityCreate) Save() {
DB.Set("ActivityCreate", activity.ID, activity)
}
// Delete deletes the activity object from the database.
func (activity *ActivityCreate) Delete() error {
DB.Delete("ActivityCreate", activity.ID)
return nil
}

43
arn/AiringDate.go Normal file
View File

@ -0,0 +1,43 @@
package arn
import (
"time"
)
// AiringDate represents the airing date of an anime.
type AiringDate struct {
Start string `json:"start" editable:"true"`
End string `json:"end" editable:"true"`
}
// StartDateHuman returns the start date of the anime in human readable form.
func (airing *AiringDate) StartDateHuman() string {
t, _ := time.Parse(time.RFC3339, airing.Start)
humanReadable := t.Format(time.RFC1123)
return humanReadable[:len("Thu, 25 May 2017")]
}
// EndDateHuman returns the end date of the anime in human readable form.
func (airing *AiringDate) EndDateHuman() string {
t, _ := time.Parse(time.RFC3339, airing.End)
humanReadable := t.Format(time.RFC1123)
return humanReadable[:len("Thu, 25 May 2017")]
}
// StartTimeHuman returns the start time of the anime in human readable form.
func (airing *AiringDate) StartTimeHuman() string {
t, _ := time.Parse(time.RFC3339, airing.Start)
humanReadable := t.Format(time.RFC1123)
return humanReadable[len("Thu, 25 May 2017 "):]
}
// EndTimeHuman returns the end time of the anime in human readable form.
func (airing *AiringDate) EndTimeHuman() string {
t, _ := time.Parse(time.RFC3339, airing.End)
humanReadable := t.Format(time.RFC1123)
return humanReadable[len("Thu, 25 May 2017 "):]
}

78
arn/Analytics.go Normal file
View File

@ -0,0 +1,78 @@
package arn
import "github.com/aerogo/nano"
// Analytics stores user-related statistics.
type Analytics struct {
UserID string `json:"userId"`
General GeneralAnalytics `json:"general"`
Screen ScreenAnalytics `json:"screen"`
System SystemAnalytics `json:"system"`
Connection ConnectionAnalytics `json:"connection"`
}
// GeneralAnalytics stores general information.
type GeneralAnalytics struct {
TimezoneOffset int `json:"timezoneOffset"`
}
// ScreenAnalytics stores information about the device screen.
type ScreenAnalytics struct {
Width int `json:"width"`
Height int `json:"height"`
AvailableWidth int `json:"availableWidth"`
AvailableHeight int `json:"availableHeight"`
PixelRatio float64 `json:"pixelRatio"`
}
// SystemAnalytics stores information about the CPU and OS.
type SystemAnalytics struct {
CPUCount int `json:"cpuCount"`
Platform string `json:"platform"`
}
// ConnectionAnalytics stores information about connection speed and ping.
type ConnectionAnalytics struct {
DownLink float64 `json:"downLink"`
RoundTripTime float64 `json:"roundTripTime"`
EffectiveType string `json:"effectiveType"`
}
// GetAnalytics returns the analytics for the given user ID.
func GetAnalytics(userID UserID) (*Analytics, error) {
obj, err := DB.Get("Analytics", userID)
if err != nil {
return nil, err
}
return obj.(*Analytics), nil
}
// StreamAnalytics returns a stream of all analytics.
func StreamAnalytics() <-chan *Analytics {
channel := make(chan *Analytics, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("Analytics") {
channel <- obj.(*Analytics)
}
close(channel)
}()
return channel
}
// AllAnalytics returns a slice of all analytics.
func AllAnalytics() []*Analytics {
all := make([]*Analytics, 0, DB.Collection("Analytics").Count())
stream := StreamAnalytics()
for obj := range stream {
all = append(all, obj)
}
return all
}

41
arn/AnalyticsAPI.go Normal file
View File

@ -0,0 +1,41 @@
package arn
import (
"github.com/aerogo/aero"
"github.com/aerogo/api"
jsoniter "github.com/json-iterator/go"
)
// Force interface implementations
var (
_ api.Newable = (*Analytics)(nil)
)
// Authorize returns an error if the given API POST request is not authorized.
func (analytics *Analytics) Authorize(ctx aero.Context, action string) error {
return AuthorizeIfLoggedIn(ctx)
}
// Create creates a new analytics object.
func (analytics *Analytics) Create(ctx aero.Context) error {
body, err := ctx.Request().Body().Bytes()
if err != nil {
return err
}
err = jsoniter.Unmarshal(body, analytics)
if err != nil {
return err
}
analytics.UserID = GetUserFromContext(ctx).ID
return nil
}
// Save saves the analytics in the database.
func (analytics *Analytics) Save() {
DB.Set("Analytics", analytics.UserID, analytics)
}

95
arn/AniList.go Normal file
View File

@ -0,0 +1,95 @@
package arn
import (
"fmt"
"github.com/animenotifier/anilist"
)
// AniListAnimeFinder holds an internal map of ID to anime mappings
// and is therefore very efficient to use when trying to find
// anime by a given service and ID.
type AniListAnimeFinder struct {
idToAnime map[string]*Anime
malIDToAnime map[string]*Anime
}
// NewAniListAnimeFinder creates a new finder for Anilist anime.
func NewAniListAnimeFinder() *AniListAnimeFinder {
finder := &AniListAnimeFinder{
idToAnime: map[string]*Anime{},
malIDToAnime: map[string]*Anime{},
}
for anime := range StreamAnime() {
id := anime.GetMapping("anilist/anime")
if id != "" {
finder.idToAnime[id] = anime
}
malID := anime.GetMapping("myanimelist/anime")
if malID != "" {
finder.malIDToAnime[malID] = anime
}
}
return finder
}
// GetAnime tries to find an AniList anime in our anime database.
func (finder *AniListAnimeFinder) GetAnime(id string, malID string) *Anime {
animeByID, existsByID := finder.idToAnime[id]
animeByMALID, existsByMALID := finder.malIDToAnime[malID]
// Add anilist mapping to the MAL mapped anime if it's missing
if existsByMALID && animeByMALID.GetMapping("anilist/anime") != id {
animeByMALID.SetMapping("anilist/anime", id)
animeByMALID.Save()
finder.idToAnime[id] = animeByMALID
}
// If both MAL ID and AniList ID are matched, but the matched anime are different,
// while the MAL IDs are different as well,
// then we're trusting the MAL ID matching more and deleting the incorrect mapping.
if existsByID && existsByMALID && animeByID.ID != animeByMALID.ID && animeByID.GetMapping("myanimelist/anime") != animeByMALID.GetMapping("myanimelist/anime") {
animeByID.RemoveMapping("anilist/anime")
animeByID.Save()
delete(finder.idToAnime, id)
fmt.Println("MAL / Anilist mismatch:")
fmt.Println(animeByID.ID, animeByID)
fmt.Println(animeByMALID.ID, animeByMALID)
}
if existsByID {
return animeByID
}
if existsByMALID {
return animeByMALID
}
return nil
}
// AniListAnimeListStatus returns the ARN version of the anime status.
func AniListAnimeListStatus(item *anilist.AnimeListItem) string {
switch item.Status {
case "CURRENT", "REPEATING":
return AnimeListStatusWatching
case "COMPLETED":
return AnimeListStatusCompleted
case "PLANNING":
return AnimeListStatusPlanned
case "PAUSED":
return AnimeListStatusHold
case "DROPPED":
return AnimeListStatusDropped
default:
return AnimeListStatusPlanned
}
}

19
arn/AniListMatch.go Normal file
View File

@ -0,0 +1,19 @@
package arn
import (
"github.com/animenotifier/anilist"
jsoniter "github.com/json-iterator/go"
)
// AniListMatch ...
type AniListMatch struct {
AniListItem *anilist.AnimeListItem `json:"anilistItem"`
ARNAnime *Anime `json:"arnAnime"`
}
// JSON ...
func (match *AniListMatch) JSON() string {
b, err := jsoniter.Marshal(match)
PanicOnError(err)
return string(b)
}

1015
arn/Anime.go Normal file

File diff suppressed because it is too large Load Diff

210
arn/AnimeAPI.go Normal file
View File

@ -0,0 +1,210 @@
package arn
import (
"errors"
"fmt"
"os"
"path"
"reflect"
"strings"
"github.com/aerogo/aero"
"github.com/aerogo/api"
"github.com/akyoto/color"
)
// Force interface implementations
var (
_ fmt.Stringer = (*Anime)(nil)
_ Likeable = (*Anime)(nil)
_ PostParent = (*Anime)(nil)
_ api.Deletable = (*Anime)(nil)
_ api.Editable = (*Anime)(nil)
_ api.CustomEditable = (*Anime)(nil)
_ api.ArrayEventListener = (*Anime)(nil)
)
// Actions
func init() {
API.RegisterActions("Anime", []*api.Action{
// Like anime
LikeAction(),
// Unlike anime
UnlikeAction(),
})
}
// Edit creates an edit log entry.
func (anime *Anime) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
user := GetUserFromContext(ctx)
if key == "Status" {
oldStatus := value.String()
newStatus := newValue.String()
// Notify people who want to know about finished series
if oldStatus == "current" && newStatus == "finished" {
go func() {
for _, user := range anime.UsersWatchingOrPlanned() {
if !user.Settings().Notification.AnimeFinished {
continue
}
user.SendNotification(&PushNotification{
Title: anime.Title.ByUser(user),
Message: anime.Title.ByUser(user) + " has finished airing!",
Icon: anime.ImageLink("medium"),
Link: "https://notify.moe" + anime.Link(),
Type: NotificationTypeAnimeFinished,
})
}
}()
}
}
// Write log entry
logEntry := NewEditLogEntry(user.ID, "edit", "Anime", anime.ID, key, fmt.Sprint(value.Interface()), fmt.Sprint(newValue.Interface()))
logEntry.Save()
return false, nil
}
// OnAppend saves a log entry.
func (anime *Anime) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(anime, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (anime *Anime) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(anime, ctx, key, index, obj)
}
// Authorize returns an error if the given API POST request is not authorized.
func (anime *Anime) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil || (user.Role != "editor" && user.Role != "admin") {
return errors.New("Not logged in or not authorized to edit this anime")
}
return nil
}
// DeleteInContext deletes the anime in the given context.
func (anime *Anime) DeleteInContext(ctx aero.Context) error {
user := GetUserFromContext(ctx)
// Write log entry
logEntry := NewEditLogEntry(user.ID, "delete", "Anime", anime.ID, "", fmt.Sprint(anime), "")
logEntry.Save()
return anime.Delete()
}
// Delete deletes the anime from the database.
func (anime *Anime) Delete() error {
// Delete anime characters
DB.Delete("AnimeCharacters", anime.ID)
// Delete anime relations
DB.Delete("AnimeRelations", anime.ID)
// Delete anime episodes
DB.Delete("AnimeEpisodes", anime.ID)
// Delete anime list items
for animeList := range StreamAnimeLists() {
removed := animeList.Remove(anime.ID)
if removed {
animeList.Save()
}
}
// Delete anime ID from existing relations
for relations := range StreamAnimeRelations() {
removed := relations.Remove(anime.ID)
if removed {
relations.Save()
}
}
// Delete anime ID from quotes
for quote := range StreamQuotes() {
if quote.AnimeID == anime.ID {
quote.AnimeID = ""
quote.Save()
}
}
// Remove posts
for _, post := range anime.Posts() {
err := post.Delete()
if err != nil {
return err
}
}
// Delete soundtrack tags
for track := range StreamSoundTracks() {
newTags := []string{}
for _, tag := range track.Tags {
if strings.HasPrefix(tag, "anime:") {
parts := strings.Split(tag, ":")
id := parts[1]
if id == anime.ID {
continue
}
}
newTags = append(newTags, tag)
}
if len(track.Tags) != len(newTags) {
track.Tags = newTags
track.Save()
}
}
// Delete images on file system
if anime.HasImage() {
err := os.Remove(path.Join(Root, "images/anime/original/", anime.ID+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.Remove(path.Join(Root, "images/anime/large/", anime.ID+".jpg"))
os.Remove(path.Join(Root, "images/anime/large/", anime.ID+"@2.jpg"))
os.Remove(path.Join(Root, "images/anime/large/", anime.ID+".webp"))
os.Remove(path.Join(Root, "images/anime/large/", anime.ID+"@2.webp"))
os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+".jpg"))
os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+"@2.jpg"))
os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+".webp"))
os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+"@2.webp"))
os.Remove(path.Join(Root, "images/anime/small/", anime.ID+".jpg"))
os.Remove(path.Join(Root, "images/anime/small/", anime.ID+"@2.jpg"))
os.Remove(path.Join(Root, "images/anime/small/", anime.ID+".webp"))
os.Remove(path.Join(Root, "images/anime/small/", anime.ID+"@2.webp"))
}
// Delete the actual anime
DB.Delete("Anime", anime.ID)
return nil
}
// Save saves the anime in the database.
func (anime *Anime) Save() {
DB.Set("Anime", anime.ID, anime)
}

21
arn/AnimeCharacter.go Normal file
View File

@ -0,0 +1,21 @@
package arn
// Register a list of supported character roles.
func init() {
DataLists["anime-character-roles"] = []*Option{
{"main", "Main character"},
{"supporting", "Supporting character"},
}
}
// AnimeCharacter ...
type AnimeCharacter struct {
CharacterID string `json:"characterId" editable:"true"`
Role string `json:"role" editable:"true" datalist:"anime-character-roles"`
}
// Character ...
func (char *AnimeCharacter) Character() *Character {
character, _ := GetCharacter(char.CharacterID)
return character
}

17
arn/AnimeCharacterAPI.go Normal file
View File

@ -0,0 +1,17 @@
package arn
import (
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ api.Creatable = (*AnimeCharacter)(nil)
)
// Create sets the data for new anime characters.
func (character *AnimeCharacter) Create(ctx aero.Context) error {
character.Role = "supporting"
return nil
}

134
arn/AnimeCharacters.go Normal file
View File

@ -0,0 +1,134 @@
package arn
import (
"errors"
"sync"
"github.com/aerogo/nano"
"github.com/akyoto/color"
)
// AnimeCharacters is a list of characters for an anime.
type AnimeCharacters struct {
AnimeID string `json:"animeId" mainID:"true"`
Items []*AnimeCharacter `json:"items" editable:"true"`
sync.Mutex
}
// Anime returns the anime the characters refer to.
func (characters *AnimeCharacters) Anime() *Anime {
anime, _ := GetAnime(characters.AnimeID)
return anime
}
// Add adds an anime character to the list.
func (characters *AnimeCharacters) Add(animeCharacter *AnimeCharacter) error {
if animeCharacter.CharacterID == "" || animeCharacter.Role == "" {
return errors.New("Empty ID or role")
}
characters.Lock()
characters.Items = append(characters.Items, animeCharacter)
characters.Unlock()
return nil
}
// FindByMapping finds an anime character by the given mapping.
func (characters *AnimeCharacters) FindByMapping(service string, serviceID string) *AnimeCharacter {
characters.Lock()
defer characters.Unlock()
for _, animeCharacter := range characters.Items {
character := animeCharacter.Character()
if character == nil {
color.Red("Anime %s has an incorrect Character ID inside AnimeCharacter: %s", characters.AnimeID, animeCharacter.CharacterID)
continue
}
if character.GetMapping(service) == serviceID {
return animeCharacter
}
}
return nil
}
// Link returns the link for that object.
func (characters *AnimeCharacters) Link() string {
return "/anime/" + characters.AnimeID + "/characters"
}
// String implements the default string serialization.
func (characters *AnimeCharacters) String() string {
return characters.Anime().String()
}
// GetID returns the anime ID.
func (characters *AnimeCharacters) GetID() string {
return characters.AnimeID
}
// TypeName returns the type name.
func (characters *AnimeCharacters) TypeName() string {
return "AnimeCharacters"
}
// Self returns the object itself.
func (characters *AnimeCharacters) Self() Loggable {
return characters
}
// Contains tells you whether the given character ID exists.
func (characters *AnimeCharacters) Contains(characterID string) bool {
characters.Lock()
defer characters.Unlock()
for _, item := range characters.Items {
if item.CharacterID == characterID {
return true
}
}
return false
}
// First gives you the first "count" anime characters.
func (characters *AnimeCharacters) First(count int) []*AnimeCharacter {
characters.Lock()
defer characters.Unlock()
if count > len(characters.Items) {
count = len(characters.Items)
}
return characters.Items[:count]
}
// GetAnimeCharacters ...
func GetAnimeCharacters(animeID string) (*AnimeCharacters, error) {
obj, err := DB.Get("AnimeCharacters", animeID)
if err != nil {
return nil, err
}
return obj.(*AnimeCharacters), nil
}
// StreamAnimeCharacters returns a stream of all anime characters.
func StreamAnimeCharacters() <-chan *AnimeCharacters {
channel := make(chan *AnimeCharacters, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("AnimeCharacters") {
channel <- obj.(*AnimeCharacters)
}
close(channel)
}()
return channel
}

54
arn/AnimeCharactersAPI.go Normal file
View File

@ -0,0 +1,54 @@
package arn
import (
"errors"
"fmt"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ fmt.Stringer = (*AnimeCharacters)(nil)
_ api.Editable = (*AnimeCharacters)(nil)
_ api.ArrayEventListener = (*AnimeCharacters)(nil)
)
// Authorize returns an error if the given API POST request is not authorized.
func (characters *AnimeCharacters) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil || (user.Role != "editor" && user.Role != "admin") {
return errors.New("Not logged in or not authorized to edit")
}
return nil
}
// Edit creates an edit log entry.
func (characters *AnimeCharacters) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(characters, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (characters *AnimeCharacters) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(characters, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (characters *AnimeCharacters) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(characters, ctx, key, index, obj)
}
// Save saves the character in the database.
func (characters *AnimeCharacters) Save() {
DB.Set("AnimeCharacters", characters.AnimeID, characters)
}
// Delete deletes the character list from the database.
func (characters *AnimeCharacters) Delete() error {
DB.Delete("AnimeCharacters", characters.AnimeID)
return nil
}

78
arn/AnimeEpisode.go Normal file
View File

@ -0,0 +1,78 @@
package arn
import "github.com/animenotifier/notify.moe/arn/validate"
// AnimeEpisode ...
type AnimeEpisode struct {
Number int `json:"number" editable:"true"`
Title EpisodeTitle `json:"title" editable:"true"`
AiringDate AiringDate `json:"airingDate" editable:"true"`
Links map[string]string `json:"links"`
}
// EpisodeTitle ...
type EpisodeTitle struct {
Romaji string `json:"romaji" editable:"true"`
English string `json:"english" editable:"true"`
Japanese string `json:"japanese" editable:"true"`
}
// Available tells you whether the episode is available (triggered when it has a link).
func (a *AnimeEpisode) Available() bool {
return len(a.Links) > 0
}
// AvailableOn tells you whether the episode is available on a given service.
func (a *AnimeEpisode) AvailableOn(serviceName string) bool {
return a.Links[serviceName] != ""
}
// Merge combines the data of both episodes to one.
func (a *AnimeEpisode) Merge(b *AnimeEpisode) {
if b == nil {
return
}
a.Number = b.Number
// Titles
if b.Title.Romaji != "" {
a.Title.Romaji = b.Title.Romaji
}
if b.Title.English != "" {
a.Title.English = b.Title.English
}
if b.Title.Japanese != "" {
a.Title.Japanese = b.Title.Japanese
}
// Airing date
if validate.DateTime(b.AiringDate.Start) {
a.AiringDate.Start = b.AiringDate.Start
}
if validate.DateTime(b.AiringDate.End) {
a.AiringDate.End = b.AiringDate.End
}
// Links
if a.Links == nil {
a.Links = map[string]string{}
}
for name, link := range b.Links {
a.Links[name] = link
}
}
// NewAnimeEpisode creates an empty anime episode.
func NewAnimeEpisode() *AnimeEpisode {
return &AnimeEpisode{
Number: -1,
Title: EpisodeTitle{},
AiringDate: AiringDate{},
Links: map[string]string{},
}
}

157
arn/AnimeEpisodes.go Normal file
View File

@ -0,0 +1,157 @@
package arn
import (
"sort"
"strconv"
"strings"
"sync"
"github.com/aerogo/nano"
)
// AnimeEpisodes is a list of episodes for an anime.
type AnimeEpisodes struct {
AnimeID string `json:"animeId" mainID:"true"`
Items []*AnimeEpisode `json:"items" editable:"true"`
sync.Mutex
}
// Link returns the link for that object.
func (episodes *AnimeEpisodes) Link() string {
return "/anime/" + episodes.AnimeID + "/episodes"
}
// Sort sorts the episodes by episode number.
func (episodes *AnimeEpisodes) Sort() {
episodes.Lock()
defer episodes.Unlock()
sort.Slice(episodes.Items, func(i, j int) bool {
return episodes.Items[i].Number < episodes.Items[j].Number
})
}
// Find finds the given episode number.
func (episodes *AnimeEpisodes) Find(episodeNumber int) (*AnimeEpisode, int) {
episodes.Lock()
defer episodes.Unlock()
for index, episode := range episodes.Items {
if episode.Number == episodeNumber {
return episode, index
}
}
return nil, -1
}
// Merge combines the data of both episode slices to one.
func (episodes *AnimeEpisodes) Merge(b []*AnimeEpisode) {
if b == nil {
return
}
episodes.Lock()
defer episodes.Unlock()
for index, episode := range b {
if index >= len(episodes.Items) {
episodes.Items = append(episodes.Items, episode)
} else {
episodes.Items[index].Merge(episode)
}
}
}
// Last returns the last n items.
func (episodes *AnimeEpisodes) Last(count int) []*AnimeEpisode {
return episodes.Items[len(episodes.Items)-count:]
}
// AvailableCount counts the number of available episodes.
func (episodes *AnimeEpisodes) AvailableCount() int {
episodes.Lock()
defer episodes.Unlock()
available := 0
for _, episode := range episodes.Items {
if len(episode.Links) > 0 {
available++
}
}
return available
}
// Anime returns the anime the episodes refer to.
func (episodes *AnimeEpisodes) Anime() *Anime {
anime, _ := GetAnime(episodes.AnimeID)
return anime
}
// String implements the default string serialization.
func (episodes *AnimeEpisodes) String() string {
return episodes.Anime().String()
}
// GetID returns the anime ID.
func (episodes *AnimeEpisodes) GetID() string {
return episodes.AnimeID
}
// TypeName returns the type name.
func (episodes *AnimeEpisodes) TypeName() string {
return "AnimeEpisodes"
}
// Self returns the object itself.
func (episodes *AnimeEpisodes) Self() Loggable {
return episodes
}
// ListString returns a text representation of the anime episodes.
func (episodes *AnimeEpisodes) ListString() string {
episodes.Lock()
defer episodes.Unlock()
b := strings.Builder{}
for _, episode := range episodes.Items {
b.WriteString(strconv.Itoa(episode.Number))
b.WriteString(" | ")
b.WriteString(episode.Title.Japanese)
b.WriteString(" | ")
b.WriteString(episode.AiringDate.StartDateHuman())
b.WriteByte('\n')
}
return strings.TrimRight(b.String(), "\n")
}
// StreamAnimeEpisodes returns a stream of all anime episodes.
func StreamAnimeEpisodes() <-chan *AnimeEpisodes {
channel := make(chan *AnimeEpisodes, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("AnimeEpisodes") {
channel <- obj.(*AnimeEpisodes)
}
close(channel)
}()
return channel
}
// GetAnimeEpisodes ...
func GetAnimeEpisodes(id string) (*AnimeEpisodes, error) {
obj, err := DB.Get("AnimeEpisodes", id)
if err != nil {
return nil, err
}
return obj.(*AnimeEpisodes), nil
}

54
arn/AnimeEpisodesAPI.go Normal file
View File

@ -0,0 +1,54 @@
package arn
import (
"errors"
"fmt"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ fmt.Stringer = (*AnimeEpisodes)(nil)
_ api.Editable = (*AnimeEpisodes)(nil)
_ api.ArrayEventListener = (*AnimeEpisodes)(nil)
)
// Authorize returns an error if the given API POST request is not authorized.
func (episodes *AnimeEpisodes) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil || (user.Role != "editor" && user.Role != "admin") {
return errors.New("Not logged in or not authorized to edit")
}
return nil
}
// Edit creates an edit log entry.
func (episodes *AnimeEpisodes) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(episodes, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (episodes *AnimeEpisodes) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(episodes, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (episodes *AnimeEpisodes) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(episodes, ctx, key, index, obj)
}
// Save saves the episodes in the database.
func (episodes *AnimeEpisodes) Save() {
DB.Set("AnimeEpisodes", episodes.AnimeID, episodes)
}
// Delete deletes the episode list from the database.
func (episodes *AnimeEpisodes) Delete() error {
DB.Delete("AnimeEpisodes", episodes.AnimeID)
return nil
}

30
arn/AnimeFinder.go Normal file
View File

@ -0,0 +1,30 @@
package arn
// AnimeFinder holds an internal map of ID to anime mappings
// and is therefore very efficient to use when trying to find
// anime by a given service and ID.
type AnimeFinder struct {
idToAnime map[string]*Anime
}
// NewAnimeFinder creates a new finder for external anime.
func NewAnimeFinder(mappingName string) *AnimeFinder {
finder := &AnimeFinder{
idToAnime: map[string]*Anime{},
}
for anime := range StreamAnime() {
id := anime.GetMapping(mappingName)
if id != "" {
finder.idToAnime[id] = anime
}
}
return finder
}
// GetAnime tries to find an external anime in our anime database.
func (finder *AnimeFinder) GetAnime(id string) *Anime {
return finder.idToAnime[id]
}

213
arn/AnimeImage.go Normal file
View File

@ -0,0 +1,213 @@
package arn
import (
"bytes"
"image"
"path"
"time"
"github.com/akyoto/imageserver"
)
const (
// AnimeImageLargeWidth is the minimum width in pixels of a large anime image.
AnimeImageLargeWidth = 250
// AnimeImageLargeHeight is the minimum height in pixels of a large anime image.
AnimeImageLargeHeight = 350
// AnimeImageMediumWidth is the minimum width in pixels of a medium anime image.
AnimeImageMediumWidth = 142
// AnimeImageMediumHeight is the minimum height in pixels of a medium anime image.
AnimeImageMediumHeight = 200
// AnimeImageSmallWidth is the minimum width in pixels of a small anime image.
AnimeImageSmallWidth = 55
// AnimeImageSmallHeight is the minimum height in pixels of a small anime image.
AnimeImageSmallHeight = 78
// AnimeImageWebPQuality is the WebP quality of anime images.
AnimeImageWebPQuality = 70
// AnimeImageJPEGQuality is the JPEG quality of anime images.
AnimeImageJPEGQuality = 70
// AnimeImageQualityBonusLowDPI ...
AnimeImageQualityBonusLowDPI = 10
// AnimeImageQualityBonusLarge ...
AnimeImageQualityBonusLarge = 5
// AnimeImageQualityBonusMedium ...
AnimeImageQualityBonusMedium = 10
// AnimeImageQualityBonusSmall ...
AnimeImageQualityBonusSmall = 10
)
// Define the anime image outputs
var animeImageOutputs = []imageserver.Output{
// Original at full size
&imageserver.OriginalFile{
Directory: path.Join(Root, "images/anime/original/"),
Width: 0,
Height: 0,
Quality: 0,
},
// JPEG - Large
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/anime/large/"),
Width: AnimeImageLargeWidth,
Height: AnimeImageLargeHeight,
Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusLarge,
},
// JPEG - Medium
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/anime/medium/"),
Width: AnimeImageMediumWidth,
Height: AnimeImageMediumHeight,
Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusMedium,
},
// JPEG - Small
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/anime/small/"),
Width: AnimeImageSmallWidth,
Height: AnimeImageSmallHeight,
Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusSmall,
},
// WebP - Large
&imageserver.WebPFile{
Directory: path.Join(Root, "images/anime/large/"),
Width: AnimeImageLargeWidth,
Height: AnimeImageLargeHeight,
Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusLarge,
},
// WebP - Medium
&imageserver.WebPFile{
Directory: path.Join(Root, "images/anime/medium/"),
Width: AnimeImageMediumWidth,
Height: AnimeImageMediumHeight,
Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusMedium,
},
// WebP - Small
&imageserver.WebPFile{
Directory: path.Join(Root, "images/anime/small/"),
Width: AnimeImageSmallWidth,
Height: AnimeImageSmallHeight,
Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusSmall,
},
}
// Define the high DPI anime image outputs
var animeImageOutputsHighDPI = []imageserver.Output{
// JPEG - Large
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/anime/large/"),
Width: AnimeImageLargeWidth * 2,
Height: AnimeImageLargeHeight * 2,
Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLarge,
},
// JPEG - Medium
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/anime/medium/"),
Width: AnimeImageMediumWidth * 2,
Height: AnimeImageMediumHeight * 2,
Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusMedium,
},
// JPEG - Small
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/anime/small/"),
Width: AnimeImageSmallWidth * 2,
Height: AnimeImageSmallHeight * 2,
Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusSmall,
},
// WebP - Large
&imageserver.WebPFile{
Directory: path.Join(Root, "images/anime/large/"),
Width: AnimeImageLargeWidth * 2,
Height: AnimeImageLargeHeight * 2,
Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLarge,
},
// WebP - Medium
&imageserver.WebPFile{
Directory: path.Join(Root, "images/anime/medium/"),
Width: AnimeImageMediumWidth * 2,
Height: AnimeImageMediumHeight * 2,
Quality: AnimeImageWebPQuality + AnimeImageQualityBonusMedium,
},
// WebP - Small
&imageserver.WebPFile{
Directory: path.Join(Root, "images/anime/small/"),
Width: AnimeImageSmallWidth * 2,
Height: AnimeImageSmallHeight * 2,
Quality: AnimeImageWebPQuality + AnimeImageQualityBonusSmall,
},
}
// AnimeImage ...
type AnimeImage struct {
Extension string `json:"extension"`
Width int `json:"width"`
Height int `json:"height"`
AverageColor HSLColor `json:"averageColor"`
LastModified int64 `json:"lastModified"`
}
// SetImageBytes accepts a byte buffer that represents an image file and updates the anime image.
func (anime *Anime) SetImageBytes(data []byte) error {
// Decode
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return err
}
return anime.SetImage(&imageserver.MetaImage{
Image: img,
Format: format,
Data: data,
})
}
// SetImage sets the anime image to the given MetaImage.
func (anime *Anime) SetImage(metaImage *imageserver.MetaImage) error {
var lastError error
// Save the different image formats and sizes in low DPI
for _, output := range animeImageOutputs {
err := output.Save(metaImage, anime.ID)
if err != nil {
lastError = err
}
}
// Save the different image formats and sizes in high DPI
for _, output := range animeImageOutputsHighDPI {
err := output.Save(metaImage, anime.ID+"@2")
if err != nil {
lastError = err
}
}
anime.Image.Extension = metaImage.Extension()
anime.Image.Width = metaImage.Image.Bounds().Dx()
anime.Image.Height = metaImage.Image.Bounds().Dy()
anime.Image.AverageColor = GetAverageColor(metaImage.Image)
anime.Image.LastModified = time.Now().Unix()
return lastError
}

507
arn/AnimeList.go Normal file
View File

@ -0,0 +1,507 @@
package arn
import (
"errors"
"fmt"
"sort"
"sync"
"github.com/aerogo/nano"
)
// AnimeList is a list of anime list items.
type AnimeList struct {
UserID UserID `json:"userId"`
Items []*AnimeListItem `json:"items"`
sync.Mutex
}
// Add adds an anime to the list if it hasn't been added yet.
func (list *AnimeList) Add(animeID string) error {
if list.Contains(animeID) {
return errors.New("Anime " + animeID + " has already been added")
}
creationDate := DateTimeUTC()
item := &AnimeListItem{
AnimeID: animeID,
Status: AnimeListStatusPlanned,
Rating: AnimeListItemRating{},
Created: creationDate,
Edited: creationDate,
}
if item.Anime() == nil {
return errors.New("Invalid anime ID")
}
list.Lock()
list.Items = append(list.Items, item)
list.Unlock()
return nil
}
// Remove removes the anime ID from the list.
func (list *AnimeList) Remove(animeID string) bool {
list.Lock()
defer list.Unlock()
for index, item := range list.Items {
if item.AnimeID == animeID {
list.Items = append(list.Items[:index], list.Items[index+1:]...)
return true
}
}
return false
}
// Contains checks if the list contains the anime ID already.
func (list *AnimeList) Contains(animeID string) bool {
list.Lock()
defer list.Unlock()
for _, item := range list.Items {
if item.AnimeID == animeID {
return true
}
}
return false
}
// HasItemsWithStatus checks if the list contains an anime with the given status.
func (list *AnimeList) HasItemsWithStatus(status string) bool {
list.Lock()
defer list.Unlock()
for _, item := range list.Items {
if item.Status == status {
return true
}
}
return false
}
// Find returns the list item with the specified anime ID, if available.
func (list *AnimeList) Find(animeID string) *AnimeListItem {
list.Lock()
defer list.Unlock()
for _, item := range list.Items {
if item.AnimeID == animeID {
return item
}
}
return nil
}
// Import adds an anime to the list if it hasn't been added yet
// and if it did exist it will update episode, rating and notes.
func (list *AnimeList) Import(item *AnimeListItem) {
existing := list.Find(item.AnimeID)
// If it doesn't exist yet: Simply add it.
if existing == nil {
list.Lock()
list.Items = append(list.Items, item)
list.Unlock()
return
}
// Temporary save it before changing the status
// because status changes can modify the episode count.
// This will prevent loss of "episodes watched" data.
existingEpisodes := existing.Episodes
// Status
existing.Status = item.Status
existing.OnStatusChange()
// Episodes
if item.Episodes > existingEpisodes {
existing.Episodes = item.Episodes
} else {
existing.Episodes = existingEpisodes
}
existing.OnEpisodesChange()
// Rating
if existing.Rating.Overall == 0 {
existing.Rating.Overall = item.Rating.Overall
existing.Rating.Clamp()
}
if existing.Notes == "" {
existing.Notes = item.Notes
}
if item.RewatchCount > existing.RewatchCount {
existing.RewatchCount = item.RewatchCount
}
// Edited
existing.Edited = DateTimeUTC()
}
// User returns the user this anime list belongs to.
func (list *AnimeList) User() *User {
user, _ := GetUser(list.UserID)
return user
}
// Sort ...
func (list *AnimeList) Sort() {
list.Lock()
defer list.Unlock()
sort.Slice(list.Items, func(i, j int) bool {
a := list.Items[i]
b := list.Items[j]
if (a.Status != AnimeListStatusWatching && a.Status != AnimeListStatusPlanned) && (b.Status != AnimeListStatusWatching && b.Status != AnimeListStatusPlanned) {
if a.Rating.Overall == b.Rating.Overall {
return a.Anime().Title.Canonical < b.Anime().Title.Canonical
}
return a.Rating.Overall > b.Rating.Overall
}
epsA := a.Anime().UpcomingEpisode()
epsB := b.Anime().UpcomingEpisode()
if epsA == nil && epsB == nil {
if a.Rating.Overall == b.Rating.Overall {
return a.Anime().Title.Canonical < b.Anime().Title.Canonical
}
return a.Rating.Overall > b.Rating.Overall
}
if epsA == nil {
return false
}
if epsB == nil {
return true
}
return epsA.Episode.AiringDate.Start < epsB.Episode.AiringDate.Start
})
}
// SortByRating sorts the anime list by overall rating.
func (list *AnimeList) SortByRating() {
list.Lock()
defer list.Unlock()
sort.Slice(list.Items, func(i, j int) bool {
a := list.Items[i]
b := list.Items[j]
if a.Rating.Overall == b.Rating.Overall {
return a.Anime().Title.Canonical < b.Anime().Title.Canonical
}
return a.Rating.Overall > b.Rating.Overall
})
}
// Top returns the top entries.
func (list *AnimeList) Top(count int) []*AnimeListItem {
list.Lock()
defer list.Unlock()
sort.Slice(list.Items, func(i, j int) bool {
a := list.Items[i]
b := list.Items[j]
if a.Rating.Overall == b.Rating.Overall {
return a.Anime().Title.Canonical < b.Anime().Title.Canonical
}
return a.Rating.Overall > b.Rating.Overall
})
if count > len(list.Items) {
count = len(list.Items)
}
tmp := make([]*AnimeListItem, count)
copy(tmp, list.Items[:count])
return tmp
}
// Watching ...
func (list *AnimeList) Watching() *AnimeList {
return list.FilterStatus(AnimeListStatusWatching)
}
// FilterStatus ...
func (list *AnimeList) FilterStatus(status string) *AnimeList {
newList := &AnimeList{
UserID: list.UserID,
Items: []*AnimeListItem{},
}
list.Lock()
defer list.Unlock()
for _, item := range list.Items {
if item.Status == status {
newList.Items = append(newList.Items, item)
}
}
return newList
}
// WithoutPrivateItems returns a new anime list with the private items removed.
func (list *AnimeList) WithoutPrivateItems() *AnimeList {
list.Lock()
defer list.Unlock()
newList := &AnimeList{
UserID: list.UserID,
Items: make([]*AnimeListItem, 0, len(list.Items)),
}
for _, item := range list.Items {
if !item.Private {
newList.Items = append(newList.Items, item)
}
}
return newList
}
// SplitByStatus splits the anime list into multiple ones by status.
func (list *AnimeList) SplitByStatus() map[string]*AnimeList {
statusToList := map[string]*AnimeList{}
statusToList[AnimeListStatusWatching] = &AnimeList{
UserID: list.UserID,
Items: []*AnimeListItem{},
}
statusToList[AnimeListStatusCompleted] = &AnimeList{
UserID: list.UserID,
Items: []*AnimeListItem{},
}
statusToList[AnimeListStatusPlanned] = &AnimeList{
UserID: list.UserID,
Items: []*AnimeListItem{},
}
statusToList[AnimeListStatusHold] = &AnimeList{
UserID: list.UserID,
Items: []*AnimeListItem{},
}
statusToList[AnimeListStatusDropped] = &AnimeList{
UserID: list.UserID,
Items: []*AnimeListItem{},
}
list.Lock()
defer list.Unlock()
for _, item := range list.Items {
statusList := statusToList[item.Status]
statusList.Items = append(statusList.Items, item)
}
return statusToList
}
// NormalizeRatings normalizes all ratings so that they are perfectly stretched among the full scale.
func (list *AnimeList) NormalizeRatings() {
list.Lock()
defer list.Unlock()
mapped := map[float64]float64{}
all := make([]float64, 0, len(list.Items))
for _, item := range list.Items {
// Zero rating counts as not rated
if item.Rating.Overall == 0 {
continue
}
_, found := mapped[item.Rating.Overall]
if !found {
mapped[item.Rating.Overall] = item.Rating.Overall
all = append(all, item.Rating.Overall)
}
}
sort.Slice(all, func(i, j int) bool {
return all[i] < all[j]
})
count := len(all)
// Prevent division by zero
if count <= 1 {
return
}
step := 9.9 / float64(count-1)
currentRating := 0.1
for _, rating := range all {
mapped[rating] = currentRating
currentRating += step
}
for _, item := range list.Items {
item.Rating.Overall = mapped[item.Rating.Overall]
item.Rating.Clamp()
}
}
// GetID returns the anime ID.
func (list *AnimeList) GetID() string {
return list.UserID
}
// TypeName returns the type name.
func (list *AnimeList) TypeName() string {
return "AnimeList"
}
// Self returns the object itself.
func (list *AnimeList) Self() Loggable {
return list
}
// Genres returns a map of genre names mapped to the list items that belong to that genre.
func (list *AnimeList) Genres() map[string][]*AnimeListItem {
genreToListItems := map[string][]*AnimeListItem{}
for _, item := range list.Items {
for _, genre := range item.Anime().Genres {
genreToListItems[genre] = append(genreToListItems[genre], item)
}
}
return genreToListItems
}
// TopGenres returns the most liked genres for the user's anime list.
func (list *AnimeList) TopGenres(count int) []string {
genreItems := list.Genres()
genreAffinity := map[string]float64{}
bestGenres := make([]string, 0, len(genreItems))
for genre, animeListItems := range genreItems {
if genre == "Action" || genre == "Comedy" {
continue
}
affinity := 0.0
for _, item := range animeListItems {
if item.Status != AnimeListStatusCompleted {
continue
}
if item.Rating.Overall != 0 {
affinity += item.Rating.Overall - AverageRating
} else {
// Add 0.1 to avoid all affinities being 0 when a user doesn't have any rated anime.
affinity += 0.1
}
}
genreAffinity[genre] = affinity
bestGenres = append(bestGenres, genre)
}
sort.Slice(bestGenres, func(i, j int) bool {
aAffinity := genreAffinity[bestGenres[i]]
bAffinity := genreAffinity[bestGenres[j]]
if aAffinity == bAffinity {
return bestGenres[i] < bestGenres[j]
}
return aAffinity > bAffinity
})
if len(bestGenres) > count {
bestGenres = bestGenres[:count]
}
return bestGenres
}
// RemoveDuplicates removes duplicate entries.
func (list *AnimeList) RemoveDuplicates() {
list.Lock()
defer list.Unlock()
existed := map[string]bool{}
newItems := make([]*AnimeListItem, 0, len(list.Items))
for _, item := range list.Items {
_, exists := existed[item.AnimeID]
if exists {
fmt.Println(list.User().Nick, "removed anime list item duplicate", item.AnimeID)
continue
}
newItems = append(newItems, item)
existed[item.AnimeID] = true
}
list.Items = newItems
}
// StreamAnimeLists returns a stream of all anime.
func StreamAnimeLists() <-chan *AnimeList {
channel := make(chan *AnimeList, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("AnimeList") {
channel <- obj.(*AnimeList)
}
close(channel)
}()
return channel
}
// AllAnimeLists returns a slice of all anime.
func AllAnimeLists() ([]*AnimeList, error) {
all := make([]*AnimeList, 0, DB.Collection("AnimeList").Count())
stream := StreamAnimeLists()
for obj := range stream {
all = append(all, obj)
}
return all, nil
}
// GetAnimeList ...
func GetAnimeList(userID UserID) (*AnimeList, error) {
animeList, err := DB.Get("AnimeList", userID)
if err != nil {
return nil, err
}
return animeList.(*AnimeList), nil
}

33
arn/AnimeListAPI.go Normal file
View File

@ -0,0 +1,33 @@
package arn
import (
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ api.Editable = (*AnimeList)(nil)
_ IDCollection = (*AnimeList)(nil)
)
// Actions
func init() {
API.RegisterActions("AnimeList", []*api.Action{
// Add follow
AddAction(),
// Remove follow
RemoveAction(),
})
}
// Authorize returns an error if the given API request is not authorized.
func (list *AnimeList) Authorize(ctx aero.Context, action string) error {
return AuthorizeIfLoggedInAndOwnData(ctx, "id")
}
// Save saves the anime list in the database.
func (list *AnimeList) Save() {
DB.Set("AnimeList", list.UserID, list)
}

104
arn/AnimeListItem.go Normal file
View File

@ -0,0 +1,104 @@
package arn
// AnimeListStatus values for anime list items
const (
AnimeListStatusWatching = "watching"
AnimeListStatusCompleted = "completed"
AnimeListStatusPlanned = "planned"
AnimeListStatusHold = "hold"
AnimeListStatusDropped = "dropped"
)
// AnimeListItem ...
type AnimeListItem struct {
AnimeID string `json:"animeId"`
Status string `json:"status" editable:"true"`
Episodes int `json:"episodes" editable:"true"`
Rating AnimeListItemRating `json:"rating"`
Notes string `json:"notes" editable:"true"`
RewatchCount int `json:"rewatchCount" editable:"true"`
Private bool `json:"private" editable:"true"`
Created string `json:"created"`
Edited string `json:"edited"`
}
// Anime fetches the associated anime data.
func (item *AnimeListItem) Anime() *Anime {
anime, _ := GetAnime(item.AnimeID)
return anime
}
// Link returns the URI for the given item.
func (item *AnimeListItem) Link(userNick string) string {
return "/+" + userNick + "/animelist/anime/" + item.AnimeID
}
// StatusHumanReadable returns the human readable representation of the status.
func (item *AnimeListItem) StatusHumanReadable() string {
switch item.Status {
case AnimeListStatusWatching:
return "Watching"
case AnimeListStatusCompleted:
return "Completed"
case AnimeListStatusPlanned:
return "Planned"
case AnimeListStatusHold:
return "On Hold"
case AnimeListStatusDropped:
return "Dropped"
default:
return "Unknown"
}
}
// OnEpisodesChange is called when the watched episode count changes.
func (item *AnimeListItem) OnEpisodesChange() {
maxEpisodesKnown := item.Anime().EpisodeCount != 0
// If we update episodes to the max, set status to completed automatically.
if item.Anime().Status == "finished" && maxEpisodesKnown && item.Episodes == item.Anime().EpisodeCount {
// Complete automatically.
item.Status = AnimeListStatusCompleted
}
// We set episodes lower than the max but the status is set as completed.
if item.Status == AnimeListStatusCompleted && maxEpisodesKnown && item.Episodes < item.Anime().EpisodeCount {
// Set status back to watching.
item.Status = AnimeListStatusWatching
}
// If we increase the episodes and status is planned, set it to watching.
if item.Status == AnimeListStatusPlanned && item.Episodes > 0 {
// Set status to watching.
item.Status = AnimeListStatusWatching
}
// If we set the episodes to 0 and status is not planned or dropped, set it to planned.
if item.Episodes == 0 && (item.Status != AnimeListStatusPlanned && item.Status != AnimeListStatusDropped) {
// Set status to planned.
item.Status = AnimeListStatusPlanned
}
}
// OnStatusChange is called when the status changes.
func (item *AnimeListItem) OnStatusChange() {
maxEpisodesKnown := item.Anime().EpisodeCount != 0
// We just switched to completed status but the episodes aren't max yet.
if item.Status == AnimeListStatusCompleted && maxEpisodesKnown && item.Episodes < item.Anime().EpisodeCount {
// Set episodes to max.
item.Episodes = item.Anime().EpisodeCount
}
// We just switched to plan to watch status but the episodes are greater than zero.
if item.Status == AnimeListStatusPlanned && item.Episodes > 0 {
// Set episodes back to zero.
item.Episodes = 0
}
// If we have an anime with max episodes watched and we change status to not completed, lower episode count by 1.
if maxEpisodesKnown && item.Status != AnimeListStatusCompleted && item.Episodes == item.Anime().EpisodeCount {
// Lower episodes by 1.
item.Episodes--
}
}

95
arn/AnimeListItemAPI.go Normal file
View File

@ -0,0 +1,95 @@
package arn
import (
"errors"
"fmt"
"reflect"
"time"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ api.CustomEditable = (*AnimeListItem)(nil)
_ api.AfterEditable = (*AnimeListItem)(nil)
)
// Edit ...
func (item *AnimeListItem) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (bool, error) {
user := GetUserFromContext(ctx)
if user == nil {
return true, errors.New("Not logged in")
}
switch key {
case "Episodes":
if !newValue.IsValid() {
return true, errors.New("Invalid episode number")
}
oldEpisodes := item.Episodes
newEpisodes := int(newValue.Float())
// Fetch last activity
lastActivity := user.LastActivityConsumeAnime(item.AnimeID)
if lastActivity == nil || time.Since(lastActivity.GetCreatedTime()) > 1*time.Hour {
// If there is no last activity for the given anime,
// or if the last activity happened more than an hour ago,
// create a new activity.
if newEpisodes > oldEpisodes {
activity := NewActivityConsumeAnime(item.AnimeID, newEpisodes, newEpisodes, user.ID)
activity.Save()
// Broadcast event to all users so they can reload the activity page if needed.
for receiver := range StreamUsers() {
receiverIsFollowing := Contains(receiver.Follows().Items, user.ID)
receiver.BroadcastEvent(&aero.Event{
Name: "activity",
Data: receiverIsFollowing,
})
}
}
} else if newEpisodes >= lastActivity.FromEpisode {
// Otherwise, update the last activity.
lastActivity.ToEpisode = newEpisodes
lastActivity.Created = DateTimeUTC()
lastActivity.Save()
}
item.Episodes = newEpisodes
if item.Episodes < 0 {
item.Episodes = 0
}
item.OnEpisodesChange()
return true, nil
case "Status":
newStatus := newValue.String()
switch newStatus {
case AnimeListStatusWatching, AnimeListStatusCompleted, AnimeListStatusPlanned, AnimeListStatusHold, AnimeListStatusDropped:
item.Status = newStatus
item.OnStatusChange()
return true, nil
default:
return true, fmt.Errorf("Invalid anime list item status: %s", newStatus)
}
}
return false, nil
}
// AfterEdit is called after the item is edited.
func (item *AnimeListItem) AfterEdit(ctx aero.Context) error {
item.Rating.Clamp()
item.Edited = DateTimeUTC()
return nil
}

View File

@ -0,0 +1,57 @@
package arn
// AnimeListItemRating ...
type AnimeListItemRating struct {
Overall float64 `json:"overall" editable:"true"`
Story float64 `json:"story" editable:"true"`
Visuals float64 `json:"visuals" editable:"true"`
Soundtrack float64 `json:"soundtrack" editable:"true"`
}
// IsNotRated tells you whether all ratings are zero.
func (rating *AnimeListItemRating) IsNotRated() bool {
return rating.Overall == 0 && rating.Story == 0 && rating.Visuals == 0 && rating.Soundtrack == 0
}
// Reset sets all values to the default anime average rating.
func (rating *AnimeListItemRating) Reset() {
rating.Overall = DefaultRating
rating.Story = DefaultRating
rating.Visuals = DefaultRating
rating.Soundtrack = DefaultRating
}
// Clamp ...
func (rating *AnimeListItemRating) Clamp() {
if rating.Overall < 0 {
rating.Overall = 0
}
if rating.Story < 0 {
rating.Story = 0
}
if rating.Visuals < 0 {
rating.Visuals = 0
}
if rating.Soundtrack < 0 {
rating.Soundtrack = 0
}
if rating.Overall > MaxRating {
rating.Overall = MaxRating
}
if rating.Story > MaxRating {
rating.Story = MaxRating
}
if rating.Visuals > MaxRating {
rating.Visuals = MaxRating
}
if rating.Soundtrack > MaxRating {
rating.Soundtrack = MaxRating
}
}

13
arn/AnimeList_test.go Normal file
View File

@ -0,0 +1,13 @@
package arn_test
import (
"testing"
"github.com/animenotifier/notify.moe/arn"
)
func TestNormalizeRatings(t *testing.T) {
user, _ := arn.GetUser("4J6qpK1ve")
animeList := user.AnimeList()
animeList.NormalizeRatings()
}

15
arn/AnimePopularity.go Normal file
View File

@ -0,0 +1,15 @@
package arn
// AnimePopularity shows how many users have that anime in a certain list.
type AnimePopularity struct {
Watching int `json:"watching"`
Completed int `json:"completed"`
Planned int `json:"planned"`
Hold int `json:"hold"`
Dropped int `json:"dropped"`
}
// Total returns the total number of users that added this anime to their collection.
func (p *AnimePopularity) Total() int {
return p.Watching + p.Completed + p.Planned + p.Hold + p.Dropped
}

31
arn/AnimeRating.go Normal file
View File

@ -0,0 +1,31 @@
package arn
// DefaultRating is the default rating value.
const DefaultRating = 0.0
// AverageRating is the center rating in the system.
// Note that the mathematically correct center would be a little higher,
// but we don't care about these slight offsets.
const AverageRating = 5.0
// MaxRating is the maximum rating users can give.
const MaxRating = 10.0
// RatingCountThreshold is the number of users threshold that, when passed, doesn't dampen the result.
const RatingCountThreshold = 4
// AnimeRating ...
type AnimeRating struct {
AnimeListItemRating
// The amount of people who rated
Count AnimeRatingCount `json:"count"`
}
// AnimeRatingCount ...
type AnimeRatingCount struct {
Overall int `json:"overall"`
Story int `json:"story"`
Visuals int `json:"visuals"`
Soundtrack int `json:"soundtrack"`
}

62
arn/AnimeRelation.go Normal file
View File

@ -0,0 +1,62 @@
package arn
// Register a list of supported anime relation types.
func init() {
DataLists["anime-relation-types"] = []*Option{
{"prequel", HumanReadableAnimeRelation("prequel")},
{"sequel", HumanReadableAnimeRelation("sequel")},
{"alternative version", "Alternative version"},
{"alternative setting", "Alternative setting"},
{"side story", HumanReadableAnimeRelation("side story")},
{"parent story", HumanReadableAnimeRelation("parent story")},
{"full story", HumanReadableAnimeRelation("full story")},
{"spinoff", HumanReadableAnimeRelation("spinoff")},
{"summary", HumanReadableAnimeRelation("summary")},
{"other", HumanReadableAnimeRelation("other")},
}
}
// AnimeRelation ...
type AnimeRelation struct {
AnimeID string `json:"animeId" editable:"true"`
Type string `json:"type" editable:"true" datalist:"anime-relation-types"`
}
// Anime ...
func (relation *AnimeRelation) Anime() *Anime {
anime, _ := GetAnime(relation.AnimeID)
return anime
}
// HumanReadableType ...
func (relation *AnimeRelation) HumanReadableType() string {
return HumanReadableAnimeRelation(relation.Type)
}
// HumanReadableAnimeRelation ...
func HumanReadableAnimeRelation(relationName string) string {
switch relationName {
case "prequel":
return "Prequel"
case "sequel":
return "Sequel"
case "alternative version":
return "Alternative"
case "alternative setting":
return "Alternative"
case "side story":
return "Side story"
case "parent story":
return "Parent story"
case "full story":
return "Full story"
case "spinoff":
return "Spin-off"
case "summary":
return "Summary"
case "other":
return "Other"
}
return relationName
}

127
arn/AnimeRelations.go Normal file
View File

@ -0,0 +1,127 @@
package arn
import (
"sort"
"sync"
"github.com/aerogo/nano"
)
// AnimeRelations is a list of relations for an anime.
type AnimeRelations struct {
AnimeID string `json:"animeId" mainID:"true"`
Items []*AnimeRelation `json:"items" editable:"true"`
sync.Mutex
}
// Link returns the link for that object.
func (relations *AnimeRelations) Link() string {
return "/anime/" + relations.AnimeID + "/relations"
}
// SortByStartDate ...
func (relations *AnimeRelations) SortByStartDate() {
relations.Lock()
defer relations.Unlock()
sort.Slice(relations.Items, func(i, j int) bool {
a := relations.Items[i].Anime()
b := relations.Items[j].Anime()
if a == nil {
return false
}
if b == nil {
return true
}
if a.StartDate == b.StartDate {
return a.Title.Canonical < b.Title.Canonical
}
return a.StartDate < b.StartDate
})
}
// Anime returns the anime the relations list refers to.
func (relations *AnimeRelations) Anime() *Anime {
anime, _ := GetAnime(relations.AnimeID)
return anime
}
// String implements the default string serialization.
func (relations *AnimeRelations) String() string {
return relations.Anime().String()
}
// GetID returns the anime ID.
func (relations *AnimeRelations) GetID() string {
return relations.AnimeID
}
// TypeName returns the type name.
func (relations *AnimeRelations) TypeName() string {
return "AnimeRelations"
}
// Self returns the object itself.
func (relations *AnimeRelations) Self() Loggable {
return relations
}
// Find returns the relation with the specified anime ID, if available.
func (relations *AnimeRelations) Find(animeID string) *AnimeRelation {
relations.Lock()
defer relations.Unlock()
for _, item := range relations.Items {
if item.AnimeID == animeID {
return item
}
}
return nil
}
// Remove removes the anime ID from the relations.
func (relations *AnimeRelations) Remove(animeID string) bool {
relations.Lock()
defer relations.Unlock()
for index, item := range relations.Items {
if item.AnimeID == animeID {
relations.Items = append(relations.Items[:index], relations.Items[index+1:]...)
return true
}
}
return false
}
// GetAnimeRelations ...
func GetAnimeRelations(animeID string) (*AnimeRelations, error) {
obj, err := DB.Get("AnimeRelations", animeID)
if err != nil {
return nil, err
}
return obj.(*AnimeRelations), nil
}
// StreamAnimeRelations returns a stream of all anime relations.
func StreamAnimeRelations() <-chan *AnimeRelations {
channel := make(chan *AnimeRelations, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("AnimeRelations") {
channel <- obj.(*AnimeRelations)
}
close(channel)
}()
return channel
}

54
arn/AnimeRelationsAPI.go Normal file
View File

@ -0,0 +1,54 @@
package arn
import (
"errors"
"fmt"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ fmt.Stringer = (*AnimeRelations)(nil)
_ api.Editable = (*AnimeRelations)(nil)
_ api.ArrayEventListener = (*AnimeRelations)(nil)
)
// Authorize returns an error if the given API POST request is not authorized.
func (relations *AnimeRelations) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil || (user.Role != "editor" && user.Role != "admin") {
return errors.New("Not logged in or not authorized to edit")
}
return nil
}
// Edit creates an edit log entry.
func (relations *AnimeRelations) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(relations, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (relations *AnimeRelations) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(relations, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (relations *AnimeRelations) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(relations, ctx, key, index, obj)
}
// Save saves the anime relations object in the database.
func (relations *AnimeRelations) Save() {
DB.Set("AnimeRelations", relations.AnimeID, relations)
}
// Delete deletes the relation list from the database.
func (relations *AnimeRelations) Delete() error {
DB.Delete("AnimeRelations", relations.AnimeID)
return nil
}

107
arn/AnimeSort.go Normal file
View File

@ -0,0 +1,107 @@
package arn
import (
"fmt"
"sort"
"time"
)
const (
currentlyAiringBonus = 5.0
longSummaryBonus = 0.1
popularityThreshold = 5
popularityPenalty = 8.0
watchingPopularityWeight = 0.07
completedPopularityWeight = watchingPopularityWeight
plannedPopularityWeight = watchingPopularityWeight * (2.0 / 3.0)
droppedPopularityWeight = -plannedPopularityWeight
visualsWeight = 0.0075
storyWeight = 0.0075
soundtrackWeight = 0.0075
movieBonus = 0.28
agePenalty = 11.0
ageThreshold = 6 * 30 * 24 * time.Hour
)
// SortAnimeByPopularity sorts the given slice of anime by popularity.
func SortAnimeByPopularity(animes []*Anime) {
sort.Slice(animes, func(i, j int) bool {
aPopularity := animes[i].Popularity.Total()
bPopularity := animes[j].Popularity.Total()
if aPopularity == bPopularity {
return animes[i].Title.Canonical < animes[j].Title.Canonical
}
return aPopularity > bPopularity
})
}
// SortAnimeByQuality sorts the given slice of anime by quality.
func SortAnimeByQuality(animes []*Anime) {
SortAnimeByQualityDetailed(animes, "")
}
// SortAnimeByQualityDetailed sorts the given slice of anime by quality.
func SortAnimeByQualityDetailed(animes []*Anime, filterStatus string) {
sort.Slice(animes, func(i, j int) bool {
a := animes[i]
b := animes[j]
scoreA := a.Score()
scoreB := b.Score()
// If we show currently running shows, rank shows that started a long time ago a bit lower
if filterStatus == "current" {
if a.StartDate != "" && time.Since(a.StartDateTime()) > ageThreshold {
scoreA -= agePenalty
}
if b.StartDate != "" && time.Since(b.StartDateTime()) > ageThreshold {
scoreB -= agePenalty
}
}
if scoreA == scoreB {
return a.Title.Canonical < b.Title.Canonical
}
return scoreA > scoreB
})
}
// Score returns the score used for the anime ranking.
func (anime *Anime) Score() float64 {
score := anime.Rating.Overall
score += anime.Rating.Story * storyWeight
score += anime.Rating.Visuals * visualsWeight
score += anime.Rating.Soundtrack * soundtrackWeight
score += float64(anime.Popularity.Watching) * watchingPopularityWeight
score += float64(anime.Popularity.Planned) * plannedPopularityWeight
score += float64(anime.Popularity.Completed) * completedPopularityWeight
score += float64(anime.Popularity.Dropped) * droppedPopularityWeight
if anime.Status == "current" {
score += currentlyAiringBonus
}
if anime.Type == "movie" {
score += movieBonus
}
if anime.Popularity.Total() < popularityThreshold {
score -= popularityPenalty
}
if len(anime.Summary) >= 140 {
score += longSummaryBonus
}
return score
}
// ScoreHumanReadable returns the score used for the anime ranking in human readable format.
func (anime *Anime) ScoreHumanReadable() string {
return fmt.Sprintf("%.1f", anime.Score())
}

19
arn/AnimeSort_test.go Normal file
View File

@ -0,0 +1,19 @@
package arn_test
import (
"testing"
"github.com/animenotifier/notify.moe/arn"
"github.com/stretchr/testify/assert"
)
func TestAnimeSort(t *testing.T) {
anime2011 := arn.FilterAnime(func(anime *arn.Anime) bool {
return anime.StartDateTime().Year() == 2011
})
arn.SortAnimeByQuality(anime2011)
// Best anime of 2011 needs to be Steins;Gate
assert.Equal(t, "0KUWpFmig", anime2011[0].ID)
}

43
arn/AnimeTitle.go Normal file
View File

@ -0,0 +1,43 @@
package arn
// AnimeTitle ...
type AnimeTitle struct {
Canonical string `json:"canonical" editable:"true"`
Romaji string `json:"romaji" editable:"true"`
English string `json:"english" editable:"true"`
Japanese string `json:"japanese" editable:"true"`
Hiragana string `json:"hiragana" editable:"true"`
Synonyms []string `json:"synonyms" editable:"true"`
}
// ByUser returns the preferred title for the given user.
func (title *AnimeTitle) ByUser(user *User) string {
if user == nil {
return title.Canonical
}
switch user.Settings().TitleLanguage {
case "canonical":
return title.Canonical
case "romaji":
if title.Romaji == "" {
return title.Canonical
}
return title.Romaji
case "english":
if title.English == "" {
return title.Canonical
}
return title.English
case "japanese":
if title.Japanese == "" {
return title.Canonical
}
return title.Japanese
default:
panic("Invalid title language")
}
}

72
arn/Anime_test.go Normal file
View File

@ -0,0 +1,72 @@
package arn_test
import (
"testing"
"github.com/animenotifier/notify.moe/arn"
"github.com/stretchr/testify/assert"
)
func TestNewAnime(t *testing.T) {
anime := arn.NewAnime()
assert.NotNil(t, anime)
assert.NotEmpty(t, anime.ID)
assert.NotEmpty(t, anime.Created)
}
func TestGetAnime(t *testing.T) {
// Existing anime
anime, err := arn.GetAnime("74y2cFiiR")
assert.NoError(t, err)
assert.NotNil(t, anime)
assert.NotEmpty(t, anime.ID)
assert.NotEmpty(t, anime.Title.Canonical)
// Not existing anime
anime, err = arn.GetAnime("does not exist")
assert.Error(t, err)
assert.Nil(t, anime)
}
func TestAllAnime(t *testing.T) {
validAnimeStatus := []string{
"finished",
"current",
"upcoming",
"tba",
}
validAnimeType := []string{
"tv",
"movie",
"ova",
"ona",
"special",
"music",
}
allAnime := arn.AllAnime()
for _, anime := range allAnime {
assert.NotEmpty(t, anime.ID)
assert.Contains(t, validAnimeStatus, anime.Status, "[%s] %s", anime.ID, anime.String())
assert.Contains(t, validAnimeType, anime.Type, "[%s] %s", anime.ID, anime.String())
assert.Contains(t, validAnimeStatus, anime.CalculatedStatus(), "[%s] %s", anime.ID, anime.String())
assert.NotEmpty(t, anime.StatusHumanReadable(), "[%s] %s", anime.ID, anime.String())
assert.NotEmpty(t, anime.TypeHumanReadable(), "[%s] %s", anime.ID, anime.String())
assert.NotEmpty(t, anime.Link(), "[%s] %s", anime.ID, anime.String())
assert.NotEmpty(t, anime.EpisodeCountString(), "[%s] %s", anime.ID, anime.String())
anime.Episodes()
anime.Characters()
anime.StartDateTime()
anime.EndDateTime()
anime.HasImage()
anime.GetMapping("shoboi/anime")
anime.Studios()
anime.Producers()
anime.Licensors()
anime.Prequels()
}
}

40
arn/AuthorizeHelper.go Normal file
View File

@ -0,0 +1,40 @@
package arn
import (
"errors"
"github.com/aerogo/aero"
)
// AuthorizeIfLoggedInAndOwnData authorizes the given request if a user is logged in
// and the user ID matches the ID in the request.
func AuthorizeIfLoggedInAndOwnData(ctx aero.Context, userIDParameterName string) error {
err := AuthorizeIfLoggedIn(ctx)
if err != nil {
return err
}
userID := ctx.Session().Get("userId").(string)
if userID != ctx.Get(userIDParameterName) {
return errors.New("Can not modify data from other users")
}
return nil
}
// AuthorizeIfLoggedIn authorizes the given request if a user is logged in.
func AuthorizeIfLoggedIn(ctx aero.Context) error {
if !ctx.HasSession() {
return errors.New("Neither logged in nor in session")
}
userID, ok := ctx.Session().Get("userId").(string)
if !ok || userID == "" {
return errors.New("Not logged in")
}
return nil
}

42
arn/Avatar.go Normal file
View File

@ -0,0 +1,42 @@
package arn
import (
"image"
"os"
"path"
)
// OriginalImageExtensions includes all the formats that an avatar source could have sent to us.
var OriginalImageExtensions = []string{
".jpg",
".png",
".gif",
}
// LoadImage loads an image from the given path.
func LoadImage(path string) (img image.Image, format string, err error) {
f, openErr := os.Open(path)
if openErr != nil {
return nil, "", openErr
}
img, format, decodeErr := image.Decode(f)
if decodeErr != nil {
return nil, "", decodeErr
}
return img, format, nil
}
// FindFileWithExtension tries to test different file extensions.
func FindFileWithExtension(baseName string, dir string, extensions []string) string {
for _, ext := range extensions {
if _, err := os.Stat(path.Join(dir, baseName+ext)); !os.IsNotExist(err) {
return dir + baseName + ext
}
}
return ""
}

4
arn/Bot.go Normal file
View File

@ -0,0 +1,4 @@
package arn
// BotUserID is the user ID of the anime notifier bot.
const BotUserID = "3wUBnfUkR"

262
arn/Character.go Normal file
View File

@ -0,0 +1,262 @@
package arn
import (
"errors"
"fmt"
"sort"
"github.com/aerogo/nano"
)
// Character represents an anime or manga character.
type Character struct {
Name CharacterName `json:"name" editable:"true"`
Image CharacterImage `json:"image"`
MainQuoteID string `json:"mainQuoteId" editable:"true"`
Description string `json:"description" editable:"true" type:"textarea"`
Spoilers []Spoiler `json:"spoilers" editable:"true"`
Attributes []*CharacterAttribute `json:"attributes" editable:"true"`
hasID
hasPosts
hasMappings
hasCreator
hasEditor
hasLikes
hasDraft
}
// NewCharacter creates a new character.
func NewCharacter() *Character {
return &Character{
hasID: hasID{
ID: GenerateID("Character"),
},
hasCreator: hasCreator{
Created: DateTimeUTC(),
},
}
}
// Link ...
func (character *Character) Link() string {
return "/character/" + character.ID
}
// TitleByUser returns the preferred title for the given user.
func (character *Character) TitleByUser(user *User) string {
return character.Name.ByUser(user)
}
// String returns the canonical name of the character.
func (character *Character) String() string {
return character.Name.Canonical
}
// TypeName returns the type name.
func (character *Character) TypeName() string {
return "Character"
}
// Self returns the object itself.
func (character *Character) Self() Loggable {
return character
}
// MainQuote ...
func (character *Character) MainQuote() *Quote {
quote, _ := GetQuote(character.MainQuoteID)
return quote
}
// AverageColor returns the average color of the image.
func (character *Character) AverageColor() string {
color := character.Image.AverageColor
if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 {
return ""
}
return color.String()
}
// ImageLink ...
func (character *Character) ImageLink(size string) string {
extension := ".jpg"
if size == "original" {
extension = character.Image.Extension
}
return fmt.Sprintf("//%s/images/characters/%s/%s%s?%v", MediaHost, size, character.ID, extension, character.Image.LastModified)
}
// Publish publishes the character draft.
func (character *Character) Publish() error {
// No name
if character.Name.Canonical == "" {
return errors.New("No canonical character name")
}
// No image
if !character.HasImage() {
return errors.New("No character image")
}
return publish(character)
}
// Unpublish turns the character into a draft.
func (character *Character) Unpublish() error {
return unpublish(character)
}
// Anime returns a list of all anime the character appears in.
func (character *Character) Anime() []*Anime {
var results []*Anime
for animeCharacters := range StreamAnimeCharacters() {
if animeCharacters.Contains(character.ID) {
anime, err := GetAnime(animeCharacters.AnimeID)
if err != nil {
continue
}
results = append(results, anime)
}
}
return results
}
// GetCharacter ...
func GetCharacter(id string) (*Character, error) {
obj, err := DB.Get("Character", id)
if err != nil {
return nil, err
}
return obj.(*Character), nil
}
// Merge deletes the character and moves all existing references to the new character.
func (character *Character) Merge(target *Character) {
// Check anime characters
for list := range StreamAnimeCharacters() {
for _, animeCharacter := range list.Items {
if animeCharacter.CharacterID == character.ID {
animeCharacter.CharacterID = target.ID
list.Save()
break
}
}
}
// Check quotes
for quote := range StreamQuotes() {
if quote.CharacterID == character.ID {
quote.CharacterID = target.ID
quote.Save()
}
}
// Check log
for entry := range StreamEditLogEntries() {
if entry.ObjectType != "Character" {
continue
}
if entry.ObjectID == character.ID {
// Delete log entries for the old character
DB.Delete("EditLogEntry", entry.ID)
}
}
// Merge likes
for _, userID := range character.Likes {
if !Contains(target.Likes, userID) {
target.Likes = append(target.Likes, userID)
}
}
target.Save()
// Delete image files
character.DeleteImages()
// Delete character
DB.Delete("Character", character.ID)
}
// DeleteImages deletes all images for the character.
func (character *Character) DeleteImages() {
deleteImages("characters", character.ID, character.Image.Extension)
}
// Quotes returns the list of quotes for this character.
func (character *Character) Quotes() []*Quote {
return FilterQuotes(func(quote *Quote) bool {
return !quote.IsDraft && quote.CharacterID == character.ID
})
}
// SortCharactersByLikes sorts the given slice of characters by the amount of likes.
func SortCharactersByLikes(characters []*Character) {
sort.Slice(characters, func(i, j int) bool {
aLikes := len(characters[i].Likes)
bLikes := len(characters[j].Likes)
if aLikes == bLikes {
return characters[i].Name.Canonical < characters[j].Name.Canonical
}
return aLikes > bLikes
})
}
// StreamCharacters returns a stream of all characters.
func StreamCharacters() <-chan *Character {
channel := make(chan *Character, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("Character") {
channel <- obj.(*Character)
}
close(channel)
}()
return channel
}
// FilterCharacters filters all characters by a custom function.
func FilterCharacters(filter func(*Character) bool) []*Character {
var filtered []*Character
channel := DB.All("Character")
for obj := range channel {
realObject := obj.(*Character)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}
// AllCharacters returns a slice of all characters.
func AllCharacters() []*Character {
all := make([]*Character, 0, DB.Collection("Character").Count())
stream := StreamCharacters()
for obj := range stream {
all = append(all, obj)
}
return all
}

150
arn/CharacterAPI.go Normal file
View File

@ -0,0 +1,150 @@
package arn
import (
"errors"
"fmt"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ Likeable = (*Character)(nil)
_ Publishable = (*Character)(nil)
_ PostParent = (*Character)(nil)
_ fmt.Stringer = (*Character)(nil)
_ api.Newable = (*Character)(nil)
_ api.Editable = (*Character)(nil)
_ api.Deletable = (*Character)(nil)
)
// Actions
func init() {
API.RegisterActions("Character", []*api.Action{
// Publish
PublishAction(),
// Unpublish
UnpublishAction(),
// Like character
LikeAction(),
// Unlike character
UnlikeAction(),
})
}
// Create sets the data for a new character with data we received from the API request.
func (character *Character) Create(ctx aero.Context) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
character.ID = GenerateID("Character")
character.Created = DateTimeUTC()
character.CreatedBy = user.ID
// Write log entry
logEntry := NewEditLogEntry(user.ID, "create", "Character", character.ID, "", "", "")
logEntry.Save()
return character.Unpublish()
}
// Authorize returns an error if the given API request is not authorized.
func (character *Character) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
// Allow custom actions (like, unlike) for normal users
if action == "like" || action == "unlike" {
return nil
}
if user.Role != "editor" && user.Role != "admin" {
return errors.New("Insufficient permissions")
}
return nil
}
// Edit creates an edit log entry.
func (character *Character) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(character, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (character *Character) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(character, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (character *Character) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(character, ctx, key, index, obj)
}
// DeleteInContext deletes the character in the given context.
func (character *Character) DeleteInContext(ctx aero.Context) error {
user := GetUserFromContext(ctx)
// Write log entry
logEntry := NewEditLogEntry(user.ID, "delete", "Character", character.ID, "", fmt.Sprint(character), "")
logEntry.Save()
return character.Delete()
}
// Delete deletes the object from the database.
func (character *Character) Delete() error {
if character.IsDraft {
draftIndex := character.Creator().DraftIndex()
draftIndex.CharacterID = ""
draftIndex.Save()
}
// Delete from anime characters
for list := range StreamAnimeCharacters() {
list.Lock()
for index, animeCharacter := range list.Items {
if animeCharacter.CharacterID == character.ID {
list.Items = append(list.Items[:index], list.Items[index+1:]...)
list.Save()
break
}
}
list.Unlock()
}
// Delete from quotes
for quote := range StreamQuotes() {
if quote.CharacterID == character.ID {
err := quote.Delete()
if err != nil {
return err
}
}
}
// Delete image files
character.DeleteImages()
// Delete character
DB.Delete("Character", character.ID)
return nil
}
// Save saves the character in the database.
func (character *Character) Save() {
DB.Set("Character", character.ID, character)
}

View File

@ -0,0 +1,7 @@
package arn
// CharacterAttribute describes one attribute of a character, e.g. height or age.
type CharacterAttribute struct {
Name string `json:"name" editable:"true"`
Value string `json:"value" editable:"true"`
}

37
arn/CharacterFinder.go Normal file
View File

@ -0,0 +1,37 @@
package arn
// CharacterFinder holds an internal map of ID to anime mappings
// and is therefore very efficient to use when trying to find
// anime by a given service and ID.
type CharacterFinder struct {
idToCharacter map[string]*Character
mappingName string
}
// NewCharacterFinder creates a new finder for external characters.
func NewCharacterFinder(mappingName string) *CharacterFinder {
finder := &CharacterFinder{
idToCharacter: map[string]*Character{},
mappingName: mappingName,
}
for character := range StreamCharacters() {
finder.Add(character)
}
return finder
}
// Add adds a character to the search pool.
func (finder *CharacterFinder) Add(character *Character) {
id := character.GetMapping(finder.mappingName)
if id != "" {
finder.idToCharacter[id] = character
}
}
// GetCharacter tries to find an external anime in our anime database.
func (finder *CharacterFinder) GetCharacter(id string) *Character {
return finder.idToCharacter[id]
}

219
arn/CharacterImage.go Normal file
View File

@ -0,0 +1,219 @@
package arn
import (
"bytes"
"fmt"
"image"
"net/http"
"path"
"time"
"github.com/aerogo/http/client"
"github.com/akyoto/imageserver"
)
const (
// CharacterImageLargeWidth is the minimum width in pixels of a large character image.
// We subtract 6 pixels due to border removal which can remove up to 6 pixels.
CharacterImageLargeWidth = 225 - 6
// CharacterImageLargeHeight is the minimum height in pixels of a large character image.
// We subtract 6 pixels due to border removal which can remove up to 6 pixels.
CharacterImageLargeHeight = 350 - 6
// CharacterImageMediumWidth is the minimum width in pixels of a medium character image.
CharacterImageMediumWidth = 112
// CharacterImageMediumHeight is the minimum height in pixels of a medium character image.
CharacterImageMediumHeight = 112
// CharacterImageSmallWidth is the minimum width in pixels of a small character image.
CharacterImageSmallWidth = 56
// CharacterImageSmallHeight is the minimum height in pixels of a small character image.
CharacterImageSmallHeight = 56
// CharacterImageWebPQuality is the WebP quality of character images.
CharacterImageWebPQuality = 70
// CharacterImageJPEGQuality is the JPEG quality of character images.
CharacterImageJPEGQuality = 70
// CharacterImageQualityBonusLowDPI ...
CharacterImageQualityBonusLowDPI = 12
// CharacterImageQualityBonusLarge ...
CharacterImageQualityBonusLarge = 10
// CharacterImageQualityBonusMedium ...
CharacterImageQualityBonusMedium = 15
// CharacterImageQualityBonusSmall ...
CharacterImageQualityBonusSmall = 15
)
// Define the character image outputs
var characterImageOutputs = []imageserver.Output{
// Original at full size
&imageserver.OriginalFile{
Directory: path.Join(Root, "images/characters/original/"),
Width: 0,
Height: 0,
Quality: 0,
},
// JPEG - Large
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/characters/large/"),
Width: CharacterImageLargeWidth,
Height: CharacterImageLargeHeight,
Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusLarge,
},
// JPEG - Medium
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/characters/medium/"),
Width: CharacterImageMediumWidth,
Height: CharacterImageMediumHeight,
Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusMedium,
},
// JPEG - Small
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/characters/small/"),
Width: CharacterImageSmallWidth,
Height: CharacterImageSmallHeight,
Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusSmall,
},
// WebP - Large
&imageserver.WebPFile{
Directory: path.Join(Root, "images/characters/large/"),
Width: CharacterImageLargeWidth,
Height: CharacterImageLargeHeight,
Quality: CharacterImageWebPQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusLarge,
},
// WebP - Medium
&imageserver.WebPFile{
Directory: path.Join(Root, "images/characters/medium/"),
Width: CharacterImageMediumWidth,
Height: CharacterImageMediumHeight,
Quality: CharacterImageWebPQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusMedium,
},
// WebP - Small
&imageserver.WebPFile{
Directory: path.Join(Root, "images/characters/small/"),
Width: CharacterImageSmallWidth,
Height: CharacterImageSmallHeight,
Quality: CharacterImageWebPQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusSmall,
},
}
// Define the high DPI character image outputs
var characterImageOutputsHighDPI = []imageserver.Output{
// NOTE: We don't save "large" images in double size because that's usually the maximum size anyway.
// JPEG - Medium
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/characters/medium/"),
Width: CharacterImageMediumWidth * 2,
Height: CharacterImageMediumHeight * 2,
Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusMedium,
},
// JPEG - Small
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/characters/small/"),
Width: CharacterImageSmallWidth * 2,
Height: CharacterImageSmallHeight * 2,
Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusSmall,
},
// WebP - Medium
&imageserver.WebPFile{
Directory: path.Join(Root, "images/characters/medium/"),
Width: CharacterImageMediumWidth * 2,
Height: CharacterImageMediumHeight * 2,
Quality: CharacterImageWebPQuality + CharacterImageQualityBonusMedium,
},
// WebP - Small
&imageserver.WebPFile{
Directory: path.Join(Root, "images/characters/small/"),
Width: CharacterImageSmallWidth * 2,
Height: CharacterImageSmallHeight * 2,
Quality: CharacterImageWebPQuality + CharacterImageQualityBonusSmall,
},
}
// CharacterImage ...
type CharacterImage AnimeImage
// SetImageBytes accepts a byte buffer that represents an image file and updates the character image.
func (character *Character) SetImageBytes(data []byte) error {
// Decode
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return err
}
return character.SetImage(&imageserver.MetaImage{
Image: img,
Format: format,
Data: data,
})
}
// SetImage sets the character image to the given MetaImage.
func (character *Character) SetImage(metaImage *imageserver.MetaImage) error {
var lastError error
// Save the different image formats and sizes in low DPI
for _, output := range characterImageOutputs {
err := output.Save(metaImage, character.ID)
if err != nil {
lastError = err
}
}
// Save the different image formats and sizes in high DPI
for _, output := range characterImageOutputsHighDPI {
err := output.Save(metaImage, character.ID+"@2")
if err != nil {
lastError = err
}
}
character.Image.Extension = metaImage.Extension()
character.Image.Width = metaImage.Image.Bounds().Dx()
character.Image.Height = metaImage.Image.Bounds().Dy()
character.Image.AverageColor = GetAverageColor(metaImage.Image)
character.Image.LastModified = time.Now().Unix()
return lastError
}
// DownloadImage ...
func (character *Character) DownloadImage(url string) error {
response, err := client.Get(url).End()
// Cancel the import if image could not be fetched
if err != nil {
return err
}
if response.StatusCode() != http.StatusOK {
return fmt.Errorf("Image response status code: %d", response.StatusCode())
}
return character.SetImageBytes(response.Bytes())
}
// HasImage returns true if the character has an image.
func (character *Character) HasImage() bool {
return character.Image.Extension != "" && character.Image.Width > 0
}

35
arn/CharacterName.go Normal file
View File

@ -0,0 +1,35 @@
package arn
// CharacterName ...
type CharacterName struct {
Canonical string `json:"canonical" editable:"true"`
English string `json:"english" editable:"true"`
Japanese string `json:"japanese" editable:"true"`
Synonyms []string `json:"synonyms" editable:"true"`
}
// ByUser returns the preferred name for the given user.
func (name *CharacterName) ByUser(user *User) string {
if user == nil {
return name.Canonical
}
switch user.Settings().TitleLanguage {
case "canonical", "romaji":
return name.Canonical
case "english":
if name.English == "" {
return name.Canonical
}
return name.English
case "japanese":
if name.Japanese == "" {
return name.Canonical
}
return name.Japanese
default:
panic("Invalid name language")
}
}

43
arn/ClientErrorReport.go Normal file
View File

@ -0,0 +1,43 @@
package arn
import "github.com/aerogo/nano"
// ClientErrorReport saves JavaScript errors that happen in web clients like browsers.
type ClientErrorReport struct {
ID string `json:"id"`
Message string `json:"message"`
Stack string `json:"stack"`
FileName string `json:"fileName"`
LineNumber int `json:"lineNumber"`
ColumnNumber int `json:"columnNumber"`
hasCreator
}
// StreamClientErrorReports returns a stream of all characters.
func StreamClientErrorReports() <-chan *ClientErrorReport {
channel := make(chan *ClientErrorReport, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("ClientErrorReport") {
channel <- obj.(*ClientErrorReport)
}
close(channel)
}()
return channel
}
// AllClientErrorReports returns a slice of all characters.
func AllClientErrorReports() []*ClientErrorReport {
all := make([]*ClientErrorReport, 0, DB.Collection("ClientErrorReport").Count())
stream := StreamClientErrorReports()
for obj := range stream {
all = append(all, obj)
}
return all
}

View File

@ -0,0 +1,54 @@
package arn
import (
"errors"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
var (
_ api.Newable = (*ClientErrorReport)(nil)
)
// Create sets the data for a new report with data we received from the API request.
func (report *ClientErrorReport) Create(ctx aero.Context) error {
data, err := ctx.Request().Body().JSONObject()
if err != nil {
return err
}
// Get user
user := GetUserFromContext(ctx)
// Create report
report.ID = GenerateID("ClientErrorReport")
report.Message = data["message"].(string)
report.Stack = data["stack"].(string)
report.FileName = data["fileName"].(string)
report.LineNumber = int(data["lineNumber"].(float64))
report.ColumnNumber = int(data["columnNumber"].(float64))
report.Created = DateTimeUTC()
if user != nil {
report.CreatedBy = user.ID
}
report.Save()
return nil
}
// Save saves the client error report in the database.
func (report *ClientErrorReport) Save() {
DB.Set("ClientErrorReport", report.ID, report)
}
// Authorize returns an error if the given API request is not authorized.
func (report *ClientErrorReport) Authorize(ctx aero.Context, action string) error {
if action == "create" {
return nil
}
return errors.New("Action " + action + " not allowed")
}

52
arn/CollectionUtils.go Normal file
View File

@ -0,0 +1,52 @@
package arn
// IndexOf ...
func IndexOf(collection []string, t string) int {
for i, v := range collection {
if v == t {
return i
}
}
return -1
}
// Contains ...
func Contains(collection []string, t string) bool {
return IndexOf(collection, t) >= 0
}
// func Any(collection []string, f func(string) bool) bool {
// for _, v := range collection {
// if f(v) {
// return true
// }
// }
// return false
// }
// func All(collection []string, f func(string) bool) bool {
// for _, v := range collection {
// if !f(v) {
// return false
// }
// }
// return true
// }
// func Filter(collection []string, f func(string) bool) []string {
// vsf := make([]string, 0)
// for _, v := range collection {
// if f(v) {
// vsf = append(vsf, v)
// }
// }
// return vsf
// }
// func Map(collection []string, f func(string) string) []string {
// vsm := make([]string, len(collection))
// for i, v := range collection {
// vsm[i] = f(v)
// }
// return vsm
// }

161
arn/Company.go Normal file
View File

@ -0,0 +1,161 @@
package arn
import (
"errors"
"github.com/aerogo/nano"
)
// Company represents an anime studio, producer or licensor.
type Company struct {
Name CompanyName `json:"name" editable:"true"`
Description string `json:"description" editable:"true" type:"textarea"`
Email string `json:"email" editable:"true"`
Links []*Link `json:"links" editable:"true"`
// Mixins
hasID
hasMappings
hasLikes
hasDraft
// Other editable fields
Location *Location `json:"location" editable:"true"`
Tags []string `json:"tags" editable:"true"`
// Editing dates
hasCreator
hasEditor
}
// NewCompany creates a new company.
func NewCompany() *Company {
return &Company{
hasID: hasID{
ID: GenerateID("Company"),
},
Name: CompanyName{},
Links: []*Link{},
Tags: []string{},
hasCreator: hasCreator{
Created: DateTimeUTC(),
},
hasMappings: hasMappings{
Mappings: []*Mapping{},
},
}
}
// Link returns a single company.
func (company *Company) Link() string {
return "/company/" + company.ID
}
// Anime returns the anime connected with this company.
func (company *Company) Anime() (studioAnime []*Anime, producedAnime []*Anime, licensedAnime []*Anime) {
for anime := range StreamAnime() {
if Contains(anime.StudioIDs, company.ID) {
studioAnime = append(studioAnime, anime)
}
if Contains(anime.ProducerIDs, company.ID) {
producedAnime = append(producedAnime, anime)
}
if Contains(anime.LicensorIDs, company.ID) {
licensedAnime = append(licensedAnime, anime)
}
}
SortAnimeByQuality(studioAnime)
SortAnimeByQuality(producedAnime)
SortAnimeByQuality(licensedAnime)
return studioAnime, producedAnime, licensedAnime
}
// Publish publishes the company draft.
func (company *Company) Publish() error {
// No title
if company.Name.English == "" {
return errors.New("No English company name")
}
return publish(company)
}
// Unpublish turns the company into a draft.
func (company *Company) Unpublish() error {
return unpublish(company)
}
// String implements the default string serialization.
func (company *Company) String() string {
return company.Name.English
}
// TypeName returns the type name.
func (company *Company) TypeName() string {
return "Company"
}
// Self returns the object itself.
func (company *Company) Self() Loggable {
return company
}
// GetCompany returns a single company.
func GetCompany(id string) (*Company, error) {
obj, err := DB.Get("Company", id)
if err != nil {
return nil, err
}
return obj.(*Company), nil
}
// StreamCompanies returns a stream of all companies.
func StreamCompanies() <-chan *Company {
channel := make(chan *Company, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("Company") {
channel <- obj.(*Company)
}
close(channel)
}()
return channel
}
// FilterCompanies filters all companies by a custom function.
func FilterCompanies(filter func(*Company) bool) []*Company {
var filtered []*Company
channel := DB.All("Company")
for obj := range channel {
realObject := obj.(*Company)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}
// AllCompanies returns a slice of all companies.
func AllCompanies() []*Company {
all := make([]*Company, 0, DB.Collection("Company").Count())
stream := StreamCompanies()
for obj := range stream {
all = append(all, obj)
}
return all
}

139
arn/CompanyAPI.go Normal file
View File

@ -0,0 +1,139 @@
package arn
import (
"errors"
"fmt"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ Publishable = (*Company)(nil)
_ fmt.Stringer = (*Company)(nil)
_ api.Newable = (*Company)(nil)
_ api.Editable = (*Company)(nil)
_ api.Deletable = (*Company)(nil)
_ api.ArrayEventListener = (*Company)(nil)
)
// Actions
func init() {
API.RegisterActions("Company", []*api.Action{
// Publish
PublishAction(),
// Unpublish
UnpublishAction(),
// Like
LikeAction(),
// Unlike
UnlikeAction(),
})
}
// Create sets the data for a new company with data we received from the API request.
func (company *Company) Create(ctx aero.Context) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
company.ID = GenerateID("Company")
company.Created = DateTimeUTC()
company.CreatedBy = user.ID
company.Location = &Location{}
// Write log entry
logEntry := NewEditLogEntry(user.ID, "create", "Company", company.ID, "", "", "")
logEntry.Save()
return company.Unpublish()
}
// Edit creates an edit log entry.
func (company *Company) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(company, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (company *Company) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(company, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (company *Company) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(company, ctx, key, index, obj)
}
// Save saves the company in the database.
func (company *Company) Save() {
DB.Set("Company", company.ID, company)
}
// DeleteInContext deletes the company in the given context.
func (company *Company) DeleteInContext(ctx aero.Context) error {
user := GetUserFromContext(ctx)
// Write log entry
logEntry := NewEditLogEntry(user.ID, "delete", "Company", company.ID, "", fmt.Sprint(company), "")
logEntry.Save()
return company.Delete()
}
// Delete deletes the object from the database.
func (company *Company) Delete() error {
if company.IsDraft {
draftIndex := company.Creator().DraftIndex()
draftIndex.CompanyID = ""
draftIndex.Save()
}
// Remove company ID from all anime
for anime := range StreamAnime() {
for index, id := range anime.StudioIDs {
if id == company.ID {
anime.StudioIDs = append(anime.StudioIDs[:index], anime.StudioIDs[index+1:]...)
break
}
}
for index, id := range anime.ProducerIDs {
if id == company.ID {
anime.ProducerIDs = append(anime.ProducerIDs[:index], anime.ProducerIDs[index+1:]...)
break
}
}
for index, id := range anime.LicensorIDs {
if id == company.ID {
anime.LicensorIDs = append(anime.LicensorIDs[:index], anime.LicensorIDs[index+1:]...)
break
}
}
}
DB.Delete("Company", company.ID)
return nil
}
// Authorize returns an error if the given API request is not authorized.
func (company *Company) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
if user.Role != "editor" && user.Role != "admin" {
return errors.New("Insufficient permissions")
}
return nil
}

8
arn/CompanyName.go Normal file
View File

@ -0,0 +1,8 @@
package arn
// CompanyName ...
type CompanyName struct {
English string `json:"english" editable:"true"`
Japanese string `json:"japanese" editable:"true"`
Synonyms []string `json:"synonyms" editable:"true"`
}

48
arn/CompanySort.go Normal file
View File

@ -0,0 +1,48 @@
package arn
import (
"sort"
)
// GetCompanyToAnimeMap returns a map that contains company IDs as keys and their anime as values.
func GetCompanyToAnimeMap() map[string][]*Anime {
companyToAnimes := map[string][]*Anime{}
allAnime := AllAnime()
SortAnimeByQuality(allAnime)
for _, anime := range allAnime {
for _, studioID := range anime.StudioIDs {
companyToAnimes[studioID] = append(companyToAnimes[studioID], anime)
}
}
return companyToAnimes
}
// SortCompaniesPopularFirst ...
func SortCompaniesPopularFirst(companies []*Company) {
// Generate company ID to popularity map
popularity := map[string]int{}
for anime := range StreamAnime() {
for _, studio := range anime.Studios() {
popularity[studio.ID] += anime.Popularity.Watching + anime.Popularity.Completed
}
}
// Sort by using the popularity map
sort.Slice(companies, func(i, j int) bool {
a := companies[i]
b := companies[j]
aPopularity := popularity[a.ID]
bPopularity := popularity[b.ID]
if aPopularity == bPopularity {
return a.Name.English < b.Name.English
}
return aPopularity > bPopularity
})
}

11
arn/DataLists.go Normal file
View File

@ -0,0 +1,11 @@
package arn
// Option is a selection list item.
type Option struct {
Value string
Label string
}
// DataLists maps an ID to a list of keys and values.
// Used for selection lists in UIs.
var DataLists = map[string][]*Option{}

71
arn/Database.go Normal file
View File

@ -0,0 +1,71 @@
package arn
import (
"github.com/aerogo/api"
"github.com/aerogo/nano"
"github.com/animenotifier/kitsu"
"github.com/animenotifier/mal"
)
// Node represents the database node.
var Node = nano.New(nano.Configuration{
Port: 5000,
})
// DB is the main database client.
var DB = Node.Namespace("arn").RegisterTypes(
(*ActivityCreate)(nil),
(*ActivityConsumeAnime)(nil),
(*AMV)(nil),
(*Analytics)(nil),
(*Anime)(nil),
(*AnimeCharacters)(nil),
(*AnimeEpisodes)(nil),
(*AnimeRelations)(nil),
(*AnimeList)(nil),
(*Character)(nil),
(*ClientErrorReport)(nil),
(*Company)(nil),
(*DraftIndex)(nil),
(*EditLogEntry)(nil),
(*EmailToUser)(nil),
(*FacebookToUser)(nil),
(*GoogleToUser)(nil),
(*Group)(nil),
(*IDList)(nil),
(*IgnoreAnimeDifference)(nil),
(*Inventory)(nil),
(*NickToUser)(nil),
(*Notification)(nil),
(*PayPalPayment)(nil),
(*Person)(nil),
(*Post)(nil),
(*Purchase)(nil),
(*PushSubscriptions)(nil),
(*Quote)(nil),
(*Session)(nil),
(*Settings)(nil),
(*ShopItem)(nil),
(*SoundTrack)(nil),
(*Thread)(nil),
(*TwitterToUser)(nil),
(*User)(nil),
(*UserFollows)(nil),
(*UserNotifications)(nil),
)
// MAL is the client for the MyAnimeList database.
var MAL = Node.Namespace("mal").RegisterTypes(
(*mal.Anime)(nil),
(*mal.Character)(nil),
)
// Kitsu is the client for the Kitsu database.
var Kitsu = Node.Namespace("kitsu").RegisterTypes(
(*kitsu.Anime)(nil),
(*kitsu.Mapping)(nil),
(*kitsu.Character)(nil),
)
// API ...
var API = api.New("/api/", DB)

12
arn/Database_test.go Normal file
View File

@ -0,0 +1,12 @@
package arn_test
import (
"testing"
"github.com/animenotifier/notify.moe/arn"
"github.com/stretchr/testify/assert"
)
func TestConnect(t *testing.T) {
assert.NotEmpty(t, arn.DB.Node().Address().String())
}

61
arn/DraftIndex.go Normal file
View File

@ -0,0 +1,61 @@
package arn
import (
"errors"
"reflect"
)
// DraftIndex has references to unpublished drafts a user created.
type DraftIndex struct {
UserID string `json:"userId"`
GroupID string `json:"groupId"`
SoundTrackID string `json:"soundTrackId"`
CompanyID string `json:"companyId"`
QuoteID string `json:"quoteId"`
CharacterID string `json:"characterId"`
AnimeID string `json:"animeId"`
AMVID string `json:"amvId"`
}
// NewDraftIndex ...
func NewDraftIndex(userID UserID) *DraftIndex {
return &DraftIndex{
UserID: userID,
}
}
// GetID gets the ID for the given type name.
func (index *DraftIndex) GetID(typeName string) (string, error) {
v := reflect.ValueOf(index).Elem()
fieldValue := v.FieldByName(typeName + "ID")
if !fieldValue.IsValid() {
return "", errors.New("Invalid draft index ID type: " + typeName)
}
return fieldValue.String(), nil
}
// SetID sets the ID for the given type name.
func (index *DraftIndex) SetID(typeName string, id string) error {
v := reflect.ValueOf(index).Elem()
fieldValue := v.FieldByName(typeName + "ID")
if !fieldValue.IsValid() {
return errors.New("Invalid draft index ID type: " + typeName)
}
fieldValue.SetString(id)
return nil
}
// GetDraftIndex ...
func GetDraftIndex(id string) (*DraftIndex, error) {
obj, err := DB.Get("DraftIndex", id)
if err != nil {
return nil, err
}
return obj.(*DraftIndex), nil
}

13
arn/DraftIndexAPI.go Normal file
View File

@ -0,0 +1,13 @@
package arn
import "github.com/aerogo/api"
// Force interface implementations
var (
_ api.Savable = (*DraftIndex)(nil)
)
// Save saves the index in the database.
func (index *DraftIndex) Save() {
DB.Set("DraftIndex", index.UserID, index)
}

7
arn/Draftable.go Normal file
View File

@ -0,0 +1,7 @@
package arn
// Draftable describes a type where drafts can be created.
type Draftable interface {
GetIsDraft() bool
SetIsDraft(bool)
}

168
arn/EditLogEntry.go Normal file
View File

@ -0,0 +1,168 @@
package arn
import (
"reflect"
"sort"
"github.com/aerogo/nano"
)
// EditLogEntry is an entry in the editor log.
type EditLogEntry struct {
ID string `json:"id"`
UserID string `json:"userId"`
Action string `json:"action"`
ObjectType string `json:"objectType"` // The typename of what was edited
ObjectID string `json:"objectId"` // The ID of what was edited
Key string `json:"key"`
OldValue string `json:"oldValue"`
NewValue string `json:"newValue"`
Created string `json:"created"`
}
// NewEditLogEntry ...
func NewEditLogEntry(userID, action, objectType, objectID, key, oldValue, newValue string) *EditLogEntry {
return &EditLogEntry{
ID: GenerateID("EditLogEntry"),
UserID: userID,
Action: action,
ObjectType: objectType,
ObjectID: objectID,
Key: key,
OldValue: oldValue,
NewValue: newValue,
Created: DateTimeUTC(),
}
}
// User returns the user the log entry belongs to.
func (entry *EditLogEntry) User() *User {
user, _ := GetUser(entry.UserID)
return user
}
// Object returns the object the log entry refers to.
func (entry *EditLogEntry) Object() interface{} {
obj, _ := DB.Get(entry.ObjectType, entry.ObjectID)
return obj
}
// EditorScore returns the editing score for this log entry.
func (entry *EditLogEntry) EditorScore() int {
switch entry.Action {
case "create":
obj, err := DB.Get(entry.ObjectType, entry.ObjectID)
if err != nil {
return 0
}
v := reflect.Indirect(reflect.ValueOf(obj))
isDraft := v.FieldByName("IsDraft")
if isDraft.Kind() == reflect.Bool && isDraft.Bool() {
// No score for drafts
return 0
}
return 4
case "edit":
score := 4
// Bonus score for editing anime
if entry.ObjectType == "Anime" {
score++
// Bonus score for editing anime synopsis
if entry.Key == "Summary" || entry.Key == "Synopsis" {
score++
}
}
return score
case "delete", "arrayRemove":
return 3
case "arrayAppend":
return 0
}
return 0
}
// ActionHumanReadable returns the human readable version of the action.
func (entry *EditLogEntry) ActionHumanReadable() string {
switch entry.Action {
case "create":
return "Created"
case "edit":
return "Edited"
case "delete":
return "Deleted"
case "arrayAppend":
return "Added an element"
case "arrayRemove":
return "Removed an element"
default:
return entry.Action
}
}
// StreamEditLogEntries returns a stream of all log entries.
func StreamEditLogEntries() <-chan *EditLogEntry {
channel := make(chan *EditLogEntry, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("EditLogEntry") {
channel <- obj.(*EditLogEntry)
}
close(channel)
}()
return channel
}
// AllEditLogEntries returns a slice of all log entries.
func AllEditLogEntries() []*EditLogEntry {
all := make([]*EditLogEntry, 0, DB.Collection("EditLogEntry").Count())
stream := StreamEditLogEntries()
for obj := range stream {
all = append(all, obj)
}
return all
}
// FilterEditLogEntries filters all log entries by a custom function.
func FilterEditLogEntries(filter func(*EditLogEntry) bool) []*EditLogEntry {
var filtered []*EditLogEntry
channel := DB.All("EditLogEntry")
for obj := range channel {
realObject := obj.(*EditLogEntry)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}
// SortEditLogEntriesLatestFirst puts the latest entries on top.
func SortEditLogEntriesLatestFirst(entries []*EditLogEntry) {
sort.Slice(entries, func(i, j int) bool {
return entries[i].Created > entries[j].Created
})
}

6
arn/EditLogEntryAPI.go Normal file
View File

@ -0,0 +1,6 @@
package arn
// Save saves the log entry in the database.
func (entry *EditLogEntry) Save() {
DB.Set("EditLogEntry", entry.ID, entry)
}

7
arn/EmailToUser.go Normal file
View File

@ -0,0 +1,7 @@
package arn
// EmailToUser stores the user ID for an email address.
type EmailToUser struct {
Email string `json:"email"`
UserID UserID `json:"userId"`
}

32
arn/ExternalMedia.go Normal file
View File

@ -0,0 +1,32 @@
package arn
// Register a list of supported media services.
func init() {
DataLists["media-services"] = []*Option{
{"Youtube", "Youtube"},
{"SoundCloud", "SoundCloud"},
{"DailyMotion", "DailyMotion"},
}
}
// ExternalMedia ...
type ExternalMedia struct {
Service string `json:"service" editable:"true" datalist:"media-services"`
ServiceID string `json:"serviceId" editable:"true"`
}
// EmbedLink returns the embed link used in iframes for the given media.
func (media *ExternalMedia) EmbedLink() string {
switch media.Service {
case "SoundCloud":
return "//w.soundcloud.com/player/?url=https://api.soundcloud.com/tracks/" + media.ServiceID + "?auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=true"
case "Youtube":
return "//youtube.com/embed/" + media.ServiceID + "?showinfo=0"
case "DailyMotion":
return "//www.dailymotion.com/embed/video/" + media.ServiceID
case "NicoVideo":
return "//ext.nicovideo.jp/thumb/" + media.ServiceID
default:
return ""
}
}

17
arn/ExternalMediaAPI.go Normal file
View File

@ -0,0 +1,17 @@
package arn
import (
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ api.Creatable = (*ExternalMedia)(nil)
)
// Create sets the data for new external media.
func (media *ExternalMedia) Create(ctx aero.Context) error {
media.Service = "Youtube"
return nil
}

4
arn/FacebookToUser.go Normal file
View File

@ -0,0 +1,4 @@
package arn
// FacebookToUser stores the user ID by Facebook user ID.
type FacebookToUser GoogleToUser

22
arn/ForumIcons.go Normal file
View File

@ -0,0 +1,22 @@
package arn
// Icons
var forumIcons = map[string]string{
"general": "paperclip",
"news": "newspaper-o",
"anime": "television",
"update": "cubes",
"suggestion": "lightbulb-o",
"bug": "bug",
}
// GetForumIcon returns the unprefixed icon class name for the forum.
func GetForumIcon(category string) string {
icon, exists := forumIcons[category]
if exists {
return icon
}
return "comments"
}

61
arn/Genres.go Normal file
View File

@ -0,0 +1,61 @@
package arn
import "sort"
// Genres ...
var Genres []string
// Icons
var genreIcons = map[string]string{
"Action": "bomb",
"Adventure": "diamond",
"Cars": "car",
"Comedy": "smile-o",
"Drama": "heartbeat",
"Ecchi": "heart-o",
"Fantasy": "tree",
"Game": "gamepad",
"Harem": "group",
"Hentai": "venus-mars",
"Historical": "history",
"Horror": "frown-o",
"Kids": "child",
"Martial Arts": "hand-rock-o",
"Magic": "magic",
"Mecha": "mecha",
"Military": "fighter-jet",
"Music": "music",
"Mystery": "question",
"Psychological": "lightbulb-o",
"Romance": "heart",
"Sci-Fi": "rocket",
"School": "graduation-cap",
"Seinen": "male",
"Shounen": "child",
"Shoujo": "female",
"Slice of Life": "hand-peace-o",
"Space": "space-shuttle",
"Sports": "soccer-ball-o",
"Supernatural": "magic",
"Super Power": "flash",
"Thriller": "hourglass-end",
"Vampire": "eye",
}
// GetGenreIcon returns the unprefixed icon class name for the genre.
func GetGenreIcon(genre string) string {
icon, exists := genreIcons[genre]
if exists {
return icon
}
return "circle"
}
func init() {
for k := range genreIcons {
Genres = append(Genres, k)
}
sort.Strings(Genres)
}

7
arn/GoogleToUser.go Normal file
View File

@ -0,0 +1,7 @@
package arn
// GoogleToUser stores the user ID by Google user ID.
type GoogleToUser struct {
ID string `json:"id"`
UserID UserID `json:"userId"`
}

310
arn/Group.go Normal file
View File

@ -0,0 +1,310 @@
package arn
import (
"errors"
"fmt"
"os"
"path"
"sync"
"github.com/aerogo/nano"
"github.com/akyoto/color"
)
// Group represents a group of users.
type Group struct {
Name string `json:"name" editable:"true"`
Tagline string `json:"tagline" editable:"true"`
Image GroupImage `json:"image"`
Description string `json:"description" editable:"true" type:"textarea"`
Rules string `json:"rules" editable:"true" type:"textarea"`
Restricted bool `json:"restricted" editable:"true" tooltip:"Restricted groups can only be joined with the founder's permission."`
Tags []string `json:"tags" editable:"true"`
Members []*GroupMember `json:"members"`
Neighbors []string `json:"neighbors"`
// Applications []UserApplication `json:"applications"`
// Mixins
hasID
hasPosts
hasCreator
hasEditor
hasDraft
// Mutex
membersMutex sync.Mutex
}
// Link returns the URI to the group page.
func (group *Group) Link() string {
return "/group/" + group.ID
}
// TitleByUser returns the preferred title for the given user.
func (group *Group) TitleByUser(user *User) string {
if group.Name == "" {
return "untitled"
}
return group.Name
}
// String is the default text representation of the group.
func (group *Group) String() string {
return group.TitleByUser(nil)
}
// FindMember returns the group member by user ID, if available.
func (group *Group) FindMember(userID UserID) *GroupMember {
group.membersMutex.Lock()
defer group.membersMutex.Unlock()
for _, member := range group.Members {
if member.UserID == userID {
return member
}
}
return nil
}
// HasMember returns true if the user is a member of the group.
func (group *Group) HasMember(userID UserID) bool {
return group.FindMember(userID) != nil
}
// Users returns a slice of all users in the group.
func (group *Group) Users() []*User {
group.membersMutex.Lock()
defer group.membersMutex.Unlock()
users := make([]*User, len(group.Members))
for index, member := range group.Members {
users[index] = member.User()
}
return users
}
// TypeName returns the type name.
func (group *Group) TypeName() string {
return "Group"
}
// Self returns the object itself.
func (group *Group) Self() Loggable {
return group
}
// Publish ...
func (group *Group) Publish() error {
if len(group.Name) < 2 {
return errors.New("Name too short: Should be at least 2 characters")
}
if len(group.Name) > 35 {
return errors.New("Name too long: Should not be more than 35 characters")
}
if len(group.Tagline) < 4 {
return errors.New("Tagline too short: Should be at least 4 characters")
}
if len(group.Tagline) > 60 {
return errors.New("Tagline too long: Should not be more than 60 characters")
}
if len(group.Description) < 10 {
return errors.New("Your group needs a description (at least 10 characters)")
}
if len(group.Tags) < 1 {
return errors.New("At least one tag is required")
}
if !group.HasImage() {
return errors.New("Group image required")
}
return publish(group)
}
// Unpublish ...
func (group *Group) Unpublish() error {
return unpublish(group)
}
// Join makes the given user join the group.
func (group *Group) Join(user *User) error {
// Check if the user is already a member
member := group.FindMember(user.ID)
if member != nil {
return errors.New("Already a member of this group")
}
// Add user to the members list
group.membersMutex.Lock()
group.Members = append(group.Members, &GroupMember{
UserID: user.ID,
Joined: DateTimeUTC(),
})
group.membersMutex.Unlock()
// Trigger notifications
group.OnJoin(user)
return nil
}
// Leave makes the given user leave the group.
func (group *Group) Leave(user *User) error {
group.membersMutex.Lock()
defer group.membersMutex.Unlock()
for index, member := range group.Members {
if member.UserID == user.ID {
if member.UserID == group.CreatedBy {
return errors.New("The founder can not leave the group, please contact a staff member")
}
group.Members = append(group.Members[:index], group.Members[index+1:]...)
return nil
}
}
return nil
}
// OnJoin sends notifications to the creator.
func (group *Group) OnJoin(user *User) {
go func() {
group.Creator().SendNotification(&PushNotification{
Title: fmt.Sprintf(`%s joined your group!`, user.Nick),
Message: fmt.Sprintf(`%s has joined your group "%s"`, user.Nick, group.Name),
Icon: "https:" + user.AvatarLink("large"),
Link: "https://notify.moe" + group.Link() + "/members",
Type: NotificationTypeGroupJoin,
})
}()
}
// SendNotification sends a notification to all group members except for the excluded user ID.
func (group *Group) SendNotification(notification *PushNotification, excludeUserID UserID) {
for _, user := range group.Users() {
if user.ID == excludeUserID {
continue
}
user.SendNotification(notification)
}
}
// AverageColor returns the average color of the image.
func (group *Group) AverageColor() string {
color := group.Image.AverageColor
if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 {
return ""
}
return color.String()
}
// ImageLink returns a link to the group image.
func (group *Group) ImageLink(size string) string {
if !group.HasImage() {
return fmt.Sprintf("//%s/images/elements/no-group-image.svg", MediaHost)
}
extension := ".jpg"
if size == "original" {
extension = group.Image.Extension
}
return fmt.Sprintf("//%s/images/groups/%s/%s%s?%v", MediaHost, size, group.ID, extension, group.Image.LastModified)
}
// DeleteImages deletes all images for the group.
func (group *Group) DeleteImages() {
if group.Image.Extension == "" {
return
}
// Original
err := os.Remove(path.Join(Root, "images/groups/original/", group.ID+group.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())
}
// Small
os.Remove(path.Join(Root, "images/groups/small/", group.ID+".jpg"))
os.Remove(path.Join(Root, "images/groups/small/", group.ID+"@2.jpg"))
os.Remove(path.Join(Root, "images/groups/small/", group.ID+".webp"))
os.Remove(path.Join(Root, "images/groups/small/", group.ID+"@2.webp"))
// Large
os.Remove(path.Join(Root, "images/groups/large/", group.ID+".jpg"))
os.Remove(path.Join(Root, "images/groups/large/", group.ID+"@2.jpg"))
os.Remove(path.Join(Root, "images/groups/large/", group.ID+".webp"))
os.Remove(path.Join(Root, "images/groups/large/", group.ID+"@2.webp"))
}
// GetGroup ...
func GetGroup(id string) (*Group, error) {
obj, err := DB.Get("Group", id)
if err != nil {
return nil, err
}
return obj.(*Group), nil
}
// StreamGroups returns a stream of all groups.
func StreamGroups() <-chan *Group {
channel := make(chan *Group, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("Group") {
channel <- obj.(*Group)
}
close(channel)
}()
return channel
}
// AllGroups returns a slice of all groups.
func AllGroups() []*Group {
all := make([]*Group, 0, DB.Collection("Group").Count())
stream := StreamGroups()
for obj := range stream {
all = append(all, obj)
}
return all
}
// FilterGroups filters all groups by a custom function.
func FilterGroups(filter func(*Group) bool) []*Group {
var filtered []*Group
for obj := range DB.All("Group") {
realObject := obj.(*Group)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}

137
arn/GroupAPI.go Normal file
View File

@ -0,0 +1,137 @@
package arn
import (
"errors"
"fmt"
"reflect"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ Joinable = (*Group)(nil)
_ Publishable = (*Group)(nil)
_ PostParent = (*Group)(nil)
_ fmt.Stringer = (*Group)(nil)
_ api.Newable = (*Group)(nil)
_ api.Editable = (*Group)(nil)
_ api.Deletable = (*Group)(nil)
)
// Actions
func init() {
API.RegisterActions("Group", []*api.Action{
// Publish
PublishAction(),
// Unpublish
UnpublishAction(),
// Join
JoinAction(),
// Leave
LeaveAction(),
})
}
// Create ...
func (group *Group) Create(ctx aero.Context) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
if !user.IsPro() {
return errors.New("Not available for normal users during the BETA phase")
}
group.ID = GenerateID("Group")
group.Created = DateTimeUTC()
group.CreatedBy = user.ID
group.Edited = group.Created
group.EditedBy = group.CreatedBy
group.Members = []*GroupMember{
{
UserID: user.ID,
Role: "founder",
Joined: group.Created,
},
}
// Write log entry
logEntry := NewEditLogEntry(user.ID, "create", "Group", group.ID, "", "", "")
logEntry.Save()
return group.Unpublish()
}
// Edit creates an edit log entry.
func (group *Group) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
return edit(group, ctx, key, value, newValue)
}
// OnAppend saves a log entry.
func (group *Group) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
onAppend(group, ctx, key, index, obj)
}
// OnRemove saves a log entry.
func (group *Group) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
onRemove(group, ctx, key, index, obj)
}
// Delete deletes the object from the database.
func (group *Group) Delete() error {
if group.IsDraft {
draftIndex := group.Creator().DraftIndex()
draftIndex.GroupID = ""
draftIndex.Save()
}
// Delete image files
group.DeleteImages()
// Delete group
DB.Delete("Group", group.ID)
return nil
}
// DeleteInContext deletes the amv in the given context.
func (group *Group) DeleteInContext(ctx aero.Context) error {
user := GetUserFromContext(ctx)
// Write log entry
logEntry := NewEditLogEntry(user.ID, "delete", "Group", group.ID, "", fmt.Sprint(group), "")
logEntry.Save()
return group.Delete()
}
// Authorize returns an error if the given API POST request is not authorized.
func (group *Group) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
if action == "edit" && group.CreatedBy != user.ID {
return errors.New("Can't edit groups from other people")
}
if action == "join" && group.Restricted {
return errors.New("Can't join restricted groups")
}
return nil
}
// Save saves the group in the database.
func (group *Group) Save() {
DB.Set("Group", group.ID, group)
}

171
arn/GroupImage.go Normal file
View File

@ -0,0 +1,171 @@
package arn
import (
"bytes"
"image"
"path"
"time"
"github.com/akyoto/imageserver"
)
const (
// GroupImageSmallWidth is the minimum width in pixels of a small group image.
GroupImageSmallWidth = 70
// GroupImageSmallHeight is the minimum height in pixels of a small group image.
GroupImageSmallHeight = 70
// GroupImageLargeWidth is the minimum width in pixels of a large group image.
GroupImageLargeWidth = 280
// GroupImageLargeHeight is the minimum height in pixels of a large group image.
GroupImageLargeHeight = 280
// GroupImageWebPQuality is the WebP quality of group images.
GroupImageWebPQuality = 70
// GroupImageJPEGQuality is the JPEG quality of group images.
GroupImageJPEGQuality = 70
// GroupImageQualityBonusLowDPI ...
GroupImageQualityBonusLowDPI = 12
// GroupImageQualityBonusLarge ...
GroupImageQualityBonusLarge = 10
// GroupImageQualityBonusSmall ...
GroupImageQualityBonusSmall = 15
)
// Define the group image outputs
var groupImageOutputs = []imageserver.Output{
// Original at full size
&imageserver.OriginalFile{
Directory: path.Join(Root, "images/groups/original/"),
Width: 0,
Height: 0,
Quality: 0,
},
// JPEG - Small
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/groups/small/"),
Width: GroupImageSmallWidth,
Height: GroupImageSmallHeight,
Quality: GroupImageJPEGQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusSmall,
},
// JPEG - Large
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/groups/large/"),
Width: GroupImageLargeWidth,
Height: GroupImageLargeHeight,
Quality: GroupImageJPEGQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusLarge,
},
// WebP - Small
&imageserver.WebPFile{
Directory: path.Join(Root, "images/groups/small/"),
Width: GroupImageSmallWidth,
Height: GroupImageSmallHeight,
Quality: GroupImageWebPQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusSmall,
},
// WebP - Large
&imageserver.WebPFile{
Directory: path.Join(Root, "images/groups/large/"),
Width: GroupImageLargeWidth,
Height: GroupImageLargeHeight,
Quality: GroupImageWebPQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusLarge,
},
}
// Define the high DPI group image outputs
var groupImageOutputsHighDPI = []imageserver.Output{
// JPEG - Small
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/groups/small/"),
Width: GroupImageSmallWidth * 2,
Height: GroupImageSmallHeight * 2,
Quality: GroupImageJPEGQuality + GroupImageQualityBonusSmall,
},
// JPEG - Large
&imageserver.JPEGFile{
Directory: path.Join(Root, "images/groups/large/"),
Width: GroupImageLargeWidth * 2,
Height: GroupImageLargeHeight * 2,
Quality: GroupImageJPEGQuality + GroupImageQualityBonusLarge,
},
// WebP - Small
&imageserver.WebPFile{
Directory: path.Join(Root, "images/groups/small/"),
Width: GroupImageSmallWidth * 2,
Height: GroupImageSmallHeight * 2,
Quality: GroupImageWebPQuality + GroupImageQualityBonusSmall,
},
// WebP - Large
&imageserver.WebPFile{
Directory: path.Join(Root, "images/groups/large/"),
Width: GroupImageLargeWidth * 2,
Height: GroupImageLargeHeight * 2,
Quality: GroupImageWebPQuality + GroupImageQualityBonusLarge,
},
}
// GroupImage ...
type GroupImage AnimeImage
// SetImageBytes accepts a byte buffer that represents an image file and updates the group image.
func (group *Group) SetImageBytes(data []byte) error {
// Decode
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return err
}
return group.SetImage(&imageserver.MetaImage{
Image: img,
Format: format,
Data: data,
})
}
// SetImage sets the group image to the given MetaImage.
func (group *Group) SetImage(metaImage *imageserver.MetaImage) error {
var lastError error
// Save the different image formats and sizes in low DPI
for _, output := range groupImageOutputs {
err := output.Save(metaImage, group.ID)
if err != nil {
lastError = err
}
}
// Save the different image formats and sizes in high DPI
for _, output := range groupImageOutputsHighDPI {
err := output.Save(metaImage, group.ID+"@2")
if err != nil {
lastError = err
}
}
group.Image.Extension = metaImage.Extension()
group.Image.Width = metaImage.Image.Bounds().Dx()
group.Image.Height = metaImage.Image.Bounds().Dy()
group.Image.AverageColor = GetAverageColor(metaImage.Image)
group.Image.LastModified = time.Now().Unix()
return lastError
}
// HasImage returns true if the group has an image.
func (group *Group) HasImage() bool {
return group.Image.Extension != "" && group.Image.Width > 0
}

20
arn/GroupMember.go Normal file
View File

@ -0,0 +1,20 @@
package arn
// GroupMember ...
type GroupMember struct {
UserID UserID `json:"userId"`
Role string `json:"role"`
Joined string `json:"joined"`
user *User
}
// User returns the user.
func (member *GroupMember) User() *User {
if member.user != nil {
return member.user
}
member.user, _ = GetUser(member.UserID)
return member.user
}

102
arn/HSLColor.go Normal file
View File

@ -0,0 +1,102 @@
package arn
import (
"fmt"
"image"
"math"
)
// HSLColor ...
type HSLColor struct {
Hue float64 `json:"hue"`
Saturation float64 `json:"saturation"`
Lightness float64 `json:"lightness"`
}
// String returns a representation like hsl(0, 0%, 0%).
func (color HSLColor) String() string {
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", color.Hue*360, color.Saturation*100, color.Lightness*100)
}
// StringWithAlpha returns a representation like hsla(0, 0%, 0%, 0.5).
func (color HSLColor) StringWithAlpha(alpha float64) string {
return fmt.Sprintf("hsla(%.1f, %.1f%%, %.1f%%, %.2f)", color.Hue*360, color.Saturation*100, color.Lightness*100, alpha)
}
// GetAverageColor returns the average color of an image in HSL format.
func GetAverageColor(img image.Image) HSLColor {
width := img.Bounds().Dx()
height := img.Bounds().Dy()
totalR := uint64(0)
totalG := uint64(0)
totalB := uint64(0)
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
r, g, b, _ := img.At(x, y).RGBA()
totalR += uint64(r)
totalG += uint64(g)
totalB += uint64(b)
}
}
pixels := uint64(width * height)
const max = float64(65535)
averageR := float64(totalR/pixels) / max
averageG := float64(totalG/pixels) / max
averageB := float64(totalB/pixels) / max
h, s, l := RGBToHSL(averageR, averageG, averageB)
return HSLColor{h, s, l}
}
// RGBToHSL converts RGB to HSL (RGB input and HSL output are floats in the 0..1 range).
// Original source: https://github.com/gerow/go-color
func RGBToHSL(r, g, b float64) (h, s, l float64) {
max := math.Max(math.Max(r, g), b)
min := math.Min(math.Min(r, g), b)
// Luminosity is the average of the max and min rgb color intensities.
l = (max + min) / 2
// Saturation
delta := max - min
if delta == 0 {
// It's gray
return 0, 0, l
}
// It's not gray
if l < 0.5 {
s = delta / (max + min)
} else {
s = delta / (2 - max - min)
}
// Hue
r2 := (((max - r) / 6) + (delta / 2)) / delta
g2 := (((max - g) / 6) + (delta / 2)) / delta
b2 := (((max - b) / 6) + (delta / 2)) / delta
switch {
case r == max:
h = b2 - g2
case g == max:
h = (1.0 / 3.0) + r2 - b2
case b == max:
h = (2.0 / 3.0) + g2 - r2
}
// fix wraparounds
switch {
case h < 0:
h++
case h > 1:
h--
}
return h, s, l
}

38
arn/HasCreator.go Normal file
View File

@ -0,0 +1,38 @@
package arn
import (
"time"
)
// HasCreator includes user ID and date for the creation of this object.
type hasCreator struct {
Created string `json:"created"`
CreatedBy UserID `json:"createdBy"`
}
// Creator returns the user who created this object.
func (obj *hasCreator) Creator() *User {
user, _ := GetUser(obj.CreatedBy)
return user
}
// CreatorID returns the ID of the user who created this object.
func (obj *hasCreator) CreatorID() UserID {
return obj.CreatedBy
}
// GetCreated returns the creation time of the object.
func (obj *hasCreator) GetCreated() string {
return obj.Created
}
// GetCreatedBy returns the ID of the user who created this object.
func (obj *hasCreator) GetCreatedBy() UserID {
return obj.CreatedBy
}
// GetCreatedTime returns the creation time of the object as a time struct.
func (obj *hasCreator) GetCreatedTime() time.Time {
t, _ := time.Parse(time.RFC3339, obj.Created)
return t
}

16
arn/HasDraft.go Normal file
View File

@ -0,0 +1,16 @@
package arn
// HasDraft includes a boolean indicating whether the object is a draft.
type hasDraft struct {
IsDraft bool `json:"isDraft" editable:"true"`
}
// GetIsDraft tells you whether the object is a draft or not.
func (obj *hasDraft) GetIsDraft() bool {
return obj.IsDraft
}
// SetIsDraft sets the draft state for this object.
func (obj *hasDraft) SetIsDraft(isDraft bool) {
obj.IsDraft = isDraft
}

57
arn/HasEditing.go Normal file
View File

@ -0,0 +1,57 @@
package arn
// import (
// "errors"
// "reflect"
// "github.com/aerogo/aero"
// "github.com/aerogo/api"
// )
// // HasEditing implements basic API functionality for editing the fields in the struct.
// type hasEditing struct {
// Loggable
// }
// // Force interface implementations
// var (
// _ api.Editable = (*HasEditing)(nil)
// _ api.ArrayEventListener = (*HasEditing)(nil)
// )
// // Authorize returns an error if the given API POST request is not authorized.
// func (editable *hasEditing) Authorize(ctx aero.Context, action string) error {
// user := GetUserFromContext(ctx)
// if user == nil || (user.Role != "editor" && user.Role != "admin") {
// return errors.New("Not logged in or not authorized to edit")
// }
// return nil
// }
// // Edit creates an edit log entry.
// func (editable *hasEditing) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) {
// return edit(editable.Self(), ctx, key, value, newValue)
// }
// // OnAppend saves a log entry.
// func (editable *hasEditing) OnAppend(ctx aero.Context, key string, index int, obj interface{}) {
// onAppend(editable.Self(), ctx, key, index, obj)
// }
// // OnRemove saves a log entry.
// func (editable *hasEditing) OnRemove(ctx aero.Context, key string, index int, obj interface{}) {
// onRemove(editable.Self(), ctx, key, index, obj)
// }
// // Save saves the character in the database.
// func (editable *hasEditing) Save() {
// DB.Set(editable.TypeName(), editable.GetID(), editable.Self())
// }
// // Delete deletes the character list from the database.
// func (editable *hasEditing) Delete() error {
// DB.Delete(editable.TypeName(), editable.GetID())
// return nil
// }

13
arn/HasEditor.go Normal file
View File

@ -0,0 +1,13 @@
package arn
// HasEditor includes user ID and date for the last edit of this object.
type hasEditor struct {
Edited string `json:"edited"`
EditedBy string `json:"editedBy"`
}
// Editor returns the user who last edited this object.
func (obj *hasEditor) Editor() *User {
user, _ := GetUser(obj.EditedBy)
return user
}

11
arn/HasID.go Normal file
View File

@ -0,0 +1,11 @@
package arn
// hasID includes an object ID.
type hasID struct {
ID string `json:"id"`
}
// GetID returns the ID.
func (obj *hasID) GetID() string {
return obj.ID
}

43
arn/HasLikes.go Normal file
View File

@ -0,0 +1,43 @@
package arn
// HasLikes implements common like and unlike methods.
type hasLikes struct {
Likes []string `json:"likes"`
}
// Like makes the given user ID like the object.
func (obj *hasLikes) Like(userID UserID) {
for _, id := range obj.Likes {
if id == userID {
return
}
}
obj.Likes = append(obj.Likes, userID)
}
// Unlike makes the given user ID unlike the object.
func (obj *hasLikes) Unlike(userID UserID) {
for index, id := range obj.Likes {
if id == userID {
obj.Likes = append(obj.Likes[:index], obj.Likes[index+1:]...)
return
}
}
}
// LikedBy checks to see if the user has liked the object.
func (obj *hasLikes) LikedBy(userID UserID) bool {
for _, id := range obj.Likes {
if id == userID {
return true
}
}
return false
}
// CountLikes returns the number of likes the object has received.
func (obj *hasLikes) CountLikes() int {
return len(obj.Likes)
}

21
arn/HasLocked.go Normal file
View File

@ -0,0 +1,21 @@
package arn
// HasLocked implements common like and unlike methods.
type hasLocked struct {
Locked bool `json:"locked"`
}
// Lock locks the object.
func (obj *hasLocked) Lock(userID UserID) {
obj.Locked = true
}
// Unlock unlocks the object.
func (obj *hasLocked) Unlock(userID UserID) {
obj.Locked = false
}
// IsLocked implements the Lockable interface.
func (obj *hasLocked) IsLocked() bool {
return obj.Locked
}

51
arn/HasMappings.go Normal file
View File

@ -0,0 +1,51 @@
package arn
// HasMappings implements common mapping methods.
type hasMappings struct {
Mappings []*Mapping `json:"mappings" editable:"true"`
}
// SetMapping sets the ID of an external site to the obj.
func (obj *hasMappings) SetMapping(serviceName string, serviceID string) {
// Is the ID valid?
if serviceID == "" {
return
}
// If it already exists we don't need to add it
for _, external := range obj.Mappings {
if external.Service == serviceName {
external.ServiceID = serviceID
return
}
}
// Add the mapping
obj.Mappings = append(obj.Mappings, &Mapping{
Service: serviceName,
ServiceID: serviceID,
})
}
// GetMapping returns the external ID for the given service.
func (obj *hasMappings) GetMapping(name string) string {
for _, external := range obj.Mappings {
if external.Service == name {
return external.ServiceID
}
}
return ""
}
// RemoveMapping removes all mappings with the given service name and ID.
func (obj *hasMappings) RemoveMapping(name string) bool {
for index, external := range obj.Mappings {
if external.Service == name {
obj.Mappings = append(obj.Mappings[:index], obj.Mappings[index+1:]...)
return true
}
}
return false
}

65
arn/HasPosts.go Normal file
View File

@ -0,0 +1,65 @@
package arn
import (
"sort"
)
// HasPosts includes a list of Post IDs.
type hasPosts struct {
PostIDs []string `json:"posts"`
}
// AddPost adds a post to the object.
func (obj *hasPosts) AddPost(postID string) {
obj.PostIDs = append(obj.PostIDs, postID)
}
// RemovePost removes a post from the object.
func (obj *hasPosts) RemovePost(postID string) bool {
for index, item := range obj.PostIDs {
if item == postID {
obj.PostIDs = append(obj.PostIDs[:index], obj.PostIDs[index+1:]...)
return true
}
}
return false
}
// Posts returns a slice of all posts.
func (obj *hasPosts) Posts() []*Post {
objects := DB.GetMany("Post", obj.PostIDs)
posts := make([]*Post, 0, len(objects))
for _, post := range objects {
if post == nil {
continue
}
posts = append(posts, post.(*Post))
}
return posts
}
// PostsRelevantFirst returns a slice of all posts sorted by relevance.
func (obj *hasPosts) PostsRelevantFirst(count int) []*Post {
original := obj.Posts()
newPosts := make([]*Post, len(original))
copy(newPosts, original)
sort.Slice(newPosts, func(i, j int) bool {
return newPosts[i].Created > newPosts[j].Created
})
if count >= 0 && len(newPosts) > count {
newPosts = newPosts[:count]
}
return newPosts
}
// CountPosts returns the number of posts written for this object.
func (obj *hasPosts) CountPosts() int {
return len(obj.PostIDs)
}

11
arn/HasText.go Normal file
View File

@ -0,0 +1,11 @@
package arn
// HasText includes a text field.
type hasText struct {
Text string `json:"text" editable:"true" type:"textarea"`
}
// GetText returns the text of the object.
func (obj *hasText) GetText() string {
return obj.Text
}

54
arn/IDCollection.go Normal file
View File

@ -0,0 +1,54 @@
package arn
import (
"errors"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// IDCollection ...
type IDCollection interface {
Add(id string) error
Remove(id string) bool
Save()
}
// AddAction returns an API action that adds a new item to the IDCollection.
func AddAction() *api.Action {
return &api.Action{
Name: "add",
Route: "/add/:item-id",
Run: func(obj interface{}, ctx aero.Context) error {
list := obj.(IDCollection)
itemID := ctx.Get("item-id")
err := list.Add(itemID)
if err != nil {
return err
}
list.Save()
return nil
},
}
}
// RemoveAction returns an API action that removes an item from the IDCollection.
func RemoveAction() *api.Action {
return &api.Action{
Name: "remove",
Route: "/remove/:item-id",
Run: func(obj interface{}, ctx aero.Context) error {
list := obj.(IDCollection)
itemID := ctx.Get("item-id")
if !list.Remove(itemID) {
return errors.New("This item does not exist in the list")
}
list.Save()
return nil
},
}
}

20
arn/IDList.go Normal file
View File

@ -0,0 +1,20 @@
package arn
// IDList stores lists of IDs that are retrievable by name.
type IDList []string
// GetIDList ...
func GetIDList(id string) (IDList, error) {
obj, err := DB.Get("IDList", id)
if err != nil {
return nil, err
}
return *obj.(*IDList), nil
}
// Append appends the given ID to the end of the list and returns the new IDList.
func (idList IDList) Append(id string) IDList {
return append(idList, id)
}

View File

@ -0,0 +1,88 @@
package arn
import (
"fmt"
"github.com/aerogo/nano"
)
// IgnoreAnimeDifferenceEditorScore represents how many points you get for a diff ignore.
const IgnoreAnimeDifferenceEditorScore = 2
// IgnoreAnimeDifference saves which differences between anime databases can be ignored.
type IgnoreAnimeDifference struct {
// The ID is built like this: arn:323|mal:356|JapaneseTitle
ID string `json:"id"`
ValueHash uint64 `json:"valueHash"`
hasCreator
}
// GetIgnoreAnimeDifference ...
func GetIgnoreAnimeDifference(id string) (*IgnoreAnimeDifference, error) {
obj, err := DB.Get("IgnoreAnimeDifference", id)
if err != nil {
return nil, err
}
return obj.(*IgnoreAnimeDifference), nil
}
// CreateDifferenceID ...
func CreateDifferenceID(animeID string, dataProvider string, malAnimeID string, typeName string) string {
return fmt.Sprintf("arn:%s|%s:%s|%s", animeID, dataProvider, malAnimeID, typeName)
}
// IsAnimeDifferenceIgnored tells you whether the given difference is being ignored.
func IsAnimeDifferenceIgnored(animeID string, dataProvider string, malAnimeID string, typeName string, hash uint64) bool {
key := CreateDifferenceID(animeID, dataProvider, malAnimeID, typeName)
ignore, err := GetIgnoreAnimeDifference(key)
if err != nil {
return false
}
return ignore.ValueHash == hash
}
// StreamIgnoreAnimeDifferences returns a stream of all ignored differences.
func StreamIgnoreAnimeDifferences() <-chan *IgnoreAnimeDifference {
channel := make(chan *IgnoreAnimeDifference, nano.ChannelBufferSize)
go func() {
for obj := range DB.All("IgnoreAnimeDifference") {
channel <- obj.(*IgnoreAnimeDifference)
}
close(channel)
}()
return channel
}
// AllIgnoreAnimeDifferences returns a slice of all ignored differences.
func AllIgnoreAnimeDifferences() []*IgnoreAnimeDifference {
all := make([]*IgnoreAnimeDifference, 0, DB.Collection("IgnoreAnimeDifference").Count())
stream := StreamIgnoreAnimeDifferences()
for obj := range stream {
all = append(all, obj)
}
return all
}
// FilterIgnoreAnimeDifferences filters all ignored differences by a custom function.
func FilterIgnoreAnimeDifferences(filter func(*IgnoreAnimeDifference) bool) []*IgnoreAnimeDifference {
var filtered []*IgnoreAnimeDifference
for obj := range StreamIgnoreAnimeDifferences() {
if filter(obj) {
filtered = append(filtered, obj)
}
}
return filtered
}

View File

@ -0,0 +1,66 @@
package arn
import (
"errors"
"strconv"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Force interface implementations
var (
_ api.Newable = (*IgnoreAnimeDifference)(nil)
)
// Authorize returns an error if the given API POST request is not authorized.
func (ignore *IgnoreAnimeDifference) Authorize(ctx aero.Context, action string) error {
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
if user.Role != "editor" && user.Role != "admin" {
return errors.New("Not authorized")
}
return nil
}
// Create constructs the values for this new object with the data we received from the API request.
func (ignore *IgnoreAnimeDifference) Create(ctx aero.Context) error {
data, err := ctx.Request().Body().JSONObject()
if err != nil {
return err
}
user := GetUserFromContext(ctx)
if user == nil {
return errors.New("Not logged in")
}
hash, err := strconv.ParseUint(data["hash"].(string), 10, 64)
if err != nil {
return errors.New("Invalid hash: Not a number")
}
ignore.ID = data["id"].(string)
ignore.ValueHash = hash
ignore.Created = DateTimeUTC()
ignore.CreatedBy = user.ID
if ignore.ID == "" {
return errors.New("Invalid ID")
}
return nil
}
// Save saves the object in the database.
func (ignore *IgnoreAnimeDifference) Save() {
DB.Set("IgnoreAnimeDifference", ignore.ID, ignore)
}

88
arn/Inventory.go Normal file
View File

@ -0,0 +1,88 @@
package arn
import (
"errors"
)
// DefaultInventorySlotCount tells you how many slots are available by default in an inventory.
const DefaultInventorySlotCount = 24
// Inventory has inventory slots that store shop item IDs and their quantity.
type Inventory struct {
UserID UserID `json:"userId"`
Slots []*InventorySlot `json:"slots"`
}
// AddItem adds a given item to the inventory.
func (inventory *Inventory) AddItem(itemID string, quantity uint) error {
if itemID == "" {
return nil
}
// Find the slot with the item
for _, slot := range inventory.Slots {
if slot.ItemID == itemID {
slot.Quantity += quantity
return nil
}
}
// If the item doesn't exist in the inventory yet, add it to the first free slot
for _, slot := range inventory.Slots {
if slot.ItemID == "" {
slot.ItemID = itemID
slot.Quantity = quantity
return nil
}
}
// If there is no free slot, return an error
return errors.New("Inventory is full")
}
// ContainsItem checks if the inventory contains the item ID already.
func (inventory *Inventory) ContainsItem(itemID string) bool {
for _, slot := range inventory.Slots {
if slot.ItemID == itemID {
return true
}
}
return false
}
// SwapSlots swaps the slots with the given indices.
func (inventory *Inventory) SwapSlots(a, b int) error {
if a < 0 || b < 0 || a >= len(inventory.Slots) || b >= len(inventory.Slots) {
return errors.New("Inventory slot index out of bounds")
}
// Swap
inventory.Slots[a], inventory.Slots[b] = inventory.Slots[b], inventory.Slots[a]
return nil
}
// NewInventory creates a new inventory with the default number of slots.
func NewInventory(userID UserID) *Inventory {
inventory := &Inventory{
UserID: userID,
Slots: make([]*InventorySlot, DefaultInventorySlotCount),
}
for i := 0; i < len(inventory.Slots); i++ {
inventory.Slots[i] = &InventorySlot{}
}
return inventory
}
// GetInventory ...
func GetInventory(userID UserID) (*Inventory, error) {
obj, err := DB.Get("Inventory", userID)
if err != nil {
return nil, err
}
return obj.(*Inventory), nil
}

103
arn/InventoryAPI.go Normal file
View File

@ -0,0 +1,103 @@
package arn
import (
"errors"
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Actions
func init() {
API.RegisterActions("Inventory", []*api.Action{
// Use slot
{
Name: "use",
Route: "/use/:slot",
Run: func(obj interface{}, ctx aero.Context) error {
inventory := obj.(*Inventory)
slotIndex, err := ctx.GetInt("slot")
if err != nil {
return err
}
slot := inventory.Slots[slotIndex]
if slot.IsEmpty() {
return errors.New("No item in this slot")
}
if !slot.Item().Consumable {
return errors.New("This item is not consumable")
}
// Save item ID in case it gets deleted by slot.Decrease()
itemID := slot.ItemID
// Decrease quantity
err = slot.Decrease(1)
if err != nil {
return err
}
// Save inventory
inventory.Save()
user := GetUserFromContext(ctx)
err = user.ActivateItemEffect(itemID)
if err != nil {
// Refund item
slot.ItemID = itemID
slot.Increase(1)
inventory.Save()
return nil
}
return err
},
},
// Swap slots
{
Name: "swap",
Route: "/swap/:slot1/:slot2",
Run: func(obj interface{}, ctx aero.Context) error {
inventory := obj.(*Inventory)
a, err := ctx.GetInt("slot1")
if err != nil {
return err
}
b, err := ctx.GetInt("slot2")
if err != nil {
return err
}
err = inventory.SwapSlots(a, b)
if err != nil {
return err
}
inventory.Save()
return nil
},
},
})
}
// Authorize returns an error if the given API request is not authorized.
func (inventory *Inventory) Authorize(ctx aero.Context, action string) error {
return AuthorizeIfLoggedInAndOwnData(ctx, "id")
}
// Save saves the push items in the database.
func (inventory *Inventory) Save() {
DB.Set("Inventory", inventory.UserID, inventory)
}

44
arn/InventorySlot.go Normal file
View File

@ -0,0 +1,44 @@
package arn
import "errors"
// InventorySlot ...
type InventorySlot struct {
ItemID string `json:"itemId"`
Quantity uint `json:"quantity"`
}
// IsEmpty ...
func (slot *InventorySlot) IsEmpty() bool {
return slot.ItemID == ""
}
// Item ...
func (slot *InventorySlot) Item() *ShopItem {
if slot.ItemID == "" {
return nil
}
item, _ := GetShopItem(slot.ItemID)
return item
}
// Decrease reduces the quantity by the given number.
func (slot *InventorySlot) Decrease(count uint) error {
if slot.Quantity < count {
return errors.New("Not enough items")
}
slot.Quantity -= count
if slot.Quantity == 0 {
slot.ItemID = ""
}
return nil
}
// Increase increases the quantity by the given number.
func (slot *InventorySlot) Increase(count uint) {
slot.Quantity += count
}

20
arn/Inventory_test.go Normal file
View File

@ -0,0 +1,20 @@
package arn_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/animenotifier/notify.moe/arn"
)
func TestInventory(t *testing.T) {
inventory := arn.NewInventory("4J6qpK1ve")
assert.Len(t, inventory.Slots, arn.DefaultInventorySlotCount)
assert.False(t, inventory.ContainsItem("pro-account-3"))
err := inventory.AddItem("pro-account-3", 1)
assert.NoError(t, err)
assert.True(t, inventory.ContainsItem("pro-account-3"))
}

8
arn/JapaneseTokenizer.go Normal file
View File

@ -0,0 +1,8 @@
package arn
import "github.com/animenotifier/japanese/client"
// JapaneseTokenizer tokenizes a sentence via the HTTP API.
var JapaneseTokenizer = &client.Tokenizer{
Endpoint: "http://127.0.0.1:6000/",
}

53
arn/Joinable.go Normal file
View File

@ -0,0 +1,53 @@
package arn
import (
"github.com/aerogo/aero"
"github.com/aerogo/api"
)
// Joinable is an object where users can join and leave.
type Joinable interface {
Join(*User) error
Leave(*User) error
Save()
}
// JoinAction returns an API action that lets the user join the object.
func JoinAction() *api.Action {
return &api.Action{
Name: "join",
Route: "/join",
Run: func(obj interface{}, ctx aero.Context) error {
user := GetUserFromContext(ctx)
joinable := obj.(Joinable)
err := joinable.Join(user)
if err != nil {
return err
}
joinable.Save()
return nil
},
}
}
// LeaveAction returns an API action that unpublishes the object.
func LeaveAction() *api.Action {
return &api.Action{
Name: "leave",
Route: "/leave",
Run: func(obj interface{}, ctx aero.Context) error {
user := GetUserFromContext(ctx)
joinable := obj.(Joinable)
err := joinable.Leave(user)
if err != nil {
return err
}
joinable.Save()
return nil
},
}
}

151
arn/KitsuAnime.go Normal file
View File

@ -0,0 +1,151 @@
package arn
import (
"net/http"
"strings"
"github.com/aerogo/http/client"
"github.com/aerogo/nano"
"github.com/akyoto/color"
"github.com/animenotifier/kitsu"
)
// NewAnimeFromKitsuAnime ...
func NewAnimeFromKitsuAnime(kitsuAnime *kitsu.Anime) (*Anime, *AnimeCharacters, *AnimeRelations, *AnimeEpisodes) {
anime := NewAnime()
attr := kitsuAnime.Attributes
// General data
anime.Type = strings.ToLower(attr.ShowType)
anime.Title.Canonical = attr.CanonicalTitle
anime.Title.English = attr.Titles.En
anime.Title.Romaji = attr.Titles.EnJp
anime.Title.Japanese = attr.Titles.JaJp
anime.Title.Synonyms = attr.AbbreviatedTitles
anime.StartDate = attr.StartDate
anime.EndDate = attr.EndDate
anime.EpisodeCount = attr.EpisodeCount
anime.EpisodeLength = attr.EpisodeLength
anime.Status = attr.Status
anime.Summary = FixAnimeDescription(attr.Synopsis)
// Status "unreleased" means the same as "upcoming" so we should normalize it
if anime.Status == "unreleased" {
anime.Status = "upcoming"
}
// Kitsu mapping
anime.SetMapping("kitsu/anime", kitsuAnime.ID)
// Import mappings
mappings := FilterKitsuMappings(func(mapping *kitsu.Mapping) bool {
return mapping.Relationships.Item.Data.Type == "anime" && mapping.Relationships.Item.Data.ID == anime.ID
})
for _, mapping := range mappings {
anime.ImportKitsuMapping(mapping)
}
// Download image
response, err := client.Get(attr.PosterImage.Original).End()
if err == nil && response.StatusCode() == http.StatusOK {
err := anime.SetImageBytes(response.Bytes())
if err != nil {
color.Red("Couldn't set image for [%s] %s (%s)", anime.ID, anime, err.Error())
}
} else {
color.Red("No image for [%s] %s (%d)", anime.ID, anime, response.StatusCode())
}
// Rating
if anime.Rating.IsNotRated() {
anime.Rating.Reset()
}
// Trailers
if attr.YoutubeVideoID != "" {
anime.Trailers = append(anime.Trailers, &ExternalMedia{
Service: "Youtube",
ServiceID: attr.YoutubeVideoID,
})
}
// Characters
characters, _ := GetAnimeCharacters(anime.ID)
if characters == nil {
characters = &AnimeCharacters{
AnimeID: anime.ID,
Items: []*AnimeCharacter{},
}
}
// Episodes
episodes, _ := GetAnimeEpisodes(anime.ID)
if episodes == nil {
episodes = &AnimeEpisodes{
AnimeID: anime.ID,
Items: []*AnimeEpisode{},
}
}
// Relations
relations, _ := GetAnimeRelations(anime.ID)
if relations == nil {
relations = &AnimeRelations{
AnimeID: anime.ID,
Items: []*AnimeRelation{},
}
}
return anime, characters, relations, episodes
}
// StreamKitsuAnime returns a stream of all Kitsu anime.
func StreamKitsuAnime() <-chan *kitsu.Anime {
channel := make(chan *kitsu.Anime, nano.ChannelBufferSize)
go func() {
for obj := range Kitsu.All("Anime") {
channel <- obj.(*kitsu.Anime)
}
close(channel)
}()
return channel
}
// FilterKitsuAnime filters all Kitsu anime by a custom function.
func FilterKitsuAnime(filter func(*kitsu.Anime) bool) []*kitsu.Anime {
var filtered []*kitsu.Anime
channel := Kitsu.All("Anime")
for obj := range channel {
realObject := obj.(*kitsu.Anime)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}
// AllKitsuAnime returns a slice of all Kitsu anime.
func AllKitsuAnime() []*kitsu.Anime {
all := make([]*kitsu.Anime, 0, DB.Collection("kitsu.Anime").Count())
stream := StreamKitsuAnime()
for obj := range stream {
all = append(all, obj)
}
return all
}

38
arn/KitsuMappings.go Normal file
View File

@ -0,0 +1,38 @@
package arn
import (
"github.com/aerogo/nano"
"github.com/animenotifier/kitsu"
)
// StreamKitsuMappings returns a stream of all Kitsu mappings.
func StreamKitsuMappings() <-chan *kitsu.Mapping {
channel := make(chan *kitsu.Mapping, nano.ChannelBufferSize)
go func() {
for obj := range Kitsu.All("Mapping") {
channel <- obj.(*kitsu.Mapping)
}
close(channel)
}()
return channel
}
// FilterKitsuMappings filters all Kitsu mappings by a custom function.
func FilterKitsuMappings(filter func(*kitsu.Mapping) bool) []*kitsu.Mapping {
var filtered []*kitsu.Mapping
channel := Kitsu.All("Mapping")
for obj := range channel {
realObject := obj.(*kitsu.Mapping)
if filter(realObject) {
filtered = append(filtered, realObject)
}
}
return filtered
}

19
arn/KitsuMatch.go Normal file
View File

@ -0,0 +1,19 @@
package arn
import (
"github.com/animenotifier/kitsu"
jsoniter "github.com/json-iterator/go"
)
// KitsuMatch ...
type KitsuMatch struct {
KitsuItem *kitsu.LibraryEntry `json:"kitsuItem"`
ARNAnime *Anime `json:"arnAnime"`
}
// JSON ...
func (match *KitsuMatch) JSON() string {
b, err := jsoniter.Marshal(match)
PanicOnError(err)
return string(b)
}

21
arn/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Eduard Urbach
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More