Added arn to the main repository
This commit is contained in:
parent
cf258573a8
commit
29a48d94a5
267
arn/AMV.go
Normal file
267
arn/AMV.go
Normal 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
139
arn/AMVAPI.go
Normal 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
32
arn/AMVTitle.go
Normal 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
121
arn/APIKeys.go
Normal 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
81
arn/Activity.go
Normal 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
|
||||||
|
}
|
97
arn/ActivityConsumeAnime.go
Normal file
97
arn/ActivityConsumeAnime.go
Normal 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,
|
||||||
|
// })
|
||||||
|
// }()
|
||||||
|
// }
|
80
arn/ActivityConsumeAnimeAPI.go
Normal file
80
arn/ActivityConsumeAnimeAPI.go
Normal 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
63
arn/ActivityCreate.go
Normal 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
22
arn/ActivityCreateAPI.go
Normal 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
43
arn/AiringDate.go
Normal 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
78
arn/Analytics.go
Normal 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
41
arn/AnalyticsAPI.go
Normal 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
95
arn/AniList.go
Normal 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
19
arn/AniListMatch.go
Normal 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
1015
arn/Anime.go
Normal file
File diff suppressed because it is too large
Load Diff
210
arn/AnimeAPI.go
Normal file
210
arn/AnimeAPI.go
Normal 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
21
arn/AnimeCharacter.go
Normal 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
17
arn/AnimeCharacterAPI.go
Normal 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
134
arn/AnimeCharacters.go
Normal 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
54
arn/AnimeCharactersAPI.go
Normal 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
78
arn/AnimeEpisode.go
Normal 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
157
arn/AnimeEpisodes.go
Normal 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
54
arn/AnimeEpisodesAPI.go
Normal 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
30
arn/AnimeFinder.go
Normal 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
213
arn/AnimeImage.go
Normal 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
507
arn/AnimeList.go
Normal 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
33
arn/AnimeListAPI.go
Normal 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
104
arn/AnimeListItem.go
Normal 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
95
arn/AnimeListItemAPI.go
Normal 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
|
||||||
|
}
|
57
arn/AnimeListItemRating.go
Normal file
57
arn/AnimeListItemRating.go
Normal 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
13
arn/AnimeList_test.go
Normal 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
15
arn/AnimePopularity.go
Normal 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
31
arn/AnimeRating.go
Normal 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
62
arn/AnimeRelation.go
Normal 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
127
arn/AnimeRelations.go
Normal 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
54
arn/AnimeRelationsAPI.go
Normal 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
107
arn/AnimeSort.go
Normal 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
19
arn/AnimeSort_test.go
Normal 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
43
arn/AnimeTitle.go
Normal 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
72
arn/Anime_test.go
Normal 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
40
arn/AuthorizeHelper.go
Normal 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
42
arn/Avatar.go
Normal 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
4
arn/Bot.go
Normal 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
262
arn/Character.go
Normal 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
150
arn/CharacterAPI.go
Normal 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)
|
||||||
|
}
|
7
arn/CharacterAttribute.go
Normal file
7
arn/CharacterAttribute.go
Normal 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
37
arn/CharacterFinder.go
Normal 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
219
arn/CharacterImage.go
Normal 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
35
arn/CharacterName.go
Normal 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
43
arn/ClientErrorReport.go
Normal 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
|
||||||
|
}
|
54
arn/ClientErrorReportAPI.go
Normal file
54
arn/ClientErrorReportAPI.go
Normal 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
52
arn/CollectionUtils.go
Normal 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
161
arn/Company.go
Normal 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
139
arn/CompanyAPI.go
Normal 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
8
arn/CompanyName.go
Normal 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
48
arn/CompanySort.go
Normal 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
11
arn/DataLists.go
Normal 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
71
arn/Database.go
Normal 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
12
arn/Database_test.go
Normal 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
61
arn/DraftIndex.go
Normal 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
13
arn/DraftIndexAPI.go
Normal 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
7
arn/Draftable.go
Normal 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
168
arn/EditLogEntry.go
Normal 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
6
arn/EditLogEntryAPI.go
Normal 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
7
arn/EmailToUser.go
Normal 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
32
arn/ExternalMedia.go
Normal 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
17
arn/ExternalMediaAPI.go
Normal 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
4
arn/FacebookToUser.go
Normal 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
22
arn/ForumIcons.go
Normal 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
61
arn/Genres.go
Normal 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
7
arn/GoogleToUser.go
Normal 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
310
arn/Group.go
Normal 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
137
arn/GroupAPI.go
Normal 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
171
arn/GroupImage.go
Normal 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
20
arn/GroupMember.go
Normal 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
102
arn/HSLColor.go
Normal 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
38
arn/HasCreator.go
Normal 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
16
arn/HasDraft.go
Normal 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
57
arn/HasEditing.go
Normal 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
13
arn/HasEditor.go
Normal 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
11
arn/HasID.go
Normal 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
43
arn/HasLikes.go
Normal 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
21
arn/HasLocked.go
Normal 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
51
arn/HasMappings.go
Normal 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
65
arn/HasPosts.go
Normal 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
11
arn/HasText.go
Normal 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
54
arn/IDCollection.go
Normal 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
20
arn/IDList.go
Normal 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)
|
||||||
|
}
|
88
arn/IgnoreAnimeDifference.go
Normal file
88
arn/IgnoreAnimeDifference.go
Normal 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
|
||||||
|
}
|
66
arn/IgnoreAnimeDifferenceAPI.go
Normal file
66
arn/IgnoreAnimeDifferenceAPI.go
Normal 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
88
arn/Inventory.go
Normal 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
103
arn/InventoryAPI.go
Normal 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
44
arn/InventorySlot.go
Normal 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
20
arn/Inventory_test.go
Normal 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
8
arn/JapaneseTokenizer.go
Normal 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
53
arn/Joinable.go
Normal 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
151
arn/KitsuAnime.go
Normal 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
38
arn/KitsuMappings.go
Normal 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
19
arn/KitsuMatch.go
Normal 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
21
arn/LICENSE
Normal 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
Loading…
Reference in New Issue
Block a user