Added arn to the main repository

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

267
arn/AMV.go Normal file
View File

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

139
arn/AMVAPI.go Normal file
View File

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

32
arn/AMVTitle.go Normal file
View File

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

121
arn/APIKeys.go Normal file
View File

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

81
arn/Activity.go Normal file
View File

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

View File

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

View File

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

63
arn/ActivityCreate.go Normal file
View File

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

22
arn/ActivityCreateAPI.go Normal file
View File

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

43
arn/AiringDate.go Normal file
View File

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

78
arn/Analytics.go Normal file
View File

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

41
arn/AnalyticsAPI.go Normal file
View File

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

95
arn/AniList.go Normal file
View File

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

19
arn/AniListMatch.go Normal file
View File

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

1015
arn/Anime.go Normal file

File diff suppressed because it is too large Load Diff

210
arn/AnimeAPI.go Normal file
View File

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

21
arn/AnimeCharacter.go Normal file
View File

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

17
arn/AnimeCharacterAPI.go Normal file
View File

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

134
arn/AnimeCharacters.go Normal file
View File

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