node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,89 @@
package discordrpc_presence
import (
discordrpc_client "seanime/internal/discordrpc/client"
"seanime/internal/hook_resolver"
)
// DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right before the activity is sent to queue.
// There is no guarantee as to when or if the activity will be successfully sent to discord.
// Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed.
// Prevent default to stop the activity from being sent to discord.
type DiscordPresenceAnimeActivityRequestedEvent struct {
hook_resolver.Event
// Anime activity object used to generate the activity
AnimeActivity *AnimeActivity `json:"animeActivity"`
// Name of the activity
Name string `json:"name"`
// Details of the activity
Details string `json:"details"`
DetailsURL string `json:"detailsUrl"`
// State of the activity
State string `json:"state"`
// Timestamps of the activity
StartTimestamp *int64 `json:"startTimestamp"`
EndTimestamp *int64 `json:"endTimestamp"`
// Assets of the activity
LargeImage string `json:"largeImage"`
LargeText string `json:"largeText"`
LargeURL string `json:"largeUrl,omitempty"` // URL to large image, if any
SmallImage string `json:"smallImage"`
SmallText string `json:"smallText"`
SmallURL string `json:"smallUrl,omitempty"` // URL to small image, if any
// Buttons of the activity
Buttons []*discordrpc_client.Button `json:"buttons"`
// Whether the activity is an instance
Instance bool `json:"instance"`
// Type of the activity
Type int `json:"type"`
// StatusDisplayType controls formatting
StatusDisplayType int `json:"statusDisplayType,omitempty"`
}
// DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right before the activity is sent to queue.
// There is no guarantee as to when or if the activity will be successfully sent to discord.
// Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed.
// Prevent default to stop the activity from being sent to discord.
type DiscordPresenceMangaActivityRequestedEvent struct {
hook_resolver.Event
// Manga activity object used to generate the activity
MangaActivity *MangaActivity `json:"mangaActivity"`
// Name of the activity
Name string `json:"name"`
// Details of the activity
Details string `json:"details"`
DetailsURL string `json:"detailsUrl"`
// State of the activity
State string `json:"state"`
// Timestamps of the activity
StartTimestamp *int64 `json:"startTimestamp"`
EndTimestamp *int64 `json:"endTimestamp"`
// Assets of the activity
LargeImage string `json:"largeImage"`
LargeText string `json:"largeText"`
LargeURL string `json:"largeUrl,omitempty"` // URL to large image, if any
SmallImage string `json:"smallImage"`
SmallText string `json:"smallText"`
SmallURL string `json:"smallUrl,omitempty"` // URL to small image, if any
// Buttons of the activity
Buttons []*discordrpc_client.Button `json:"buttons"`
// Whether the activity is an instance
Instance bool `json:"instance"`
// Type of the activity
Type int `json:"type"`
// StatusDisplayType controls formatting
StatusDisplayType int `json:"statusDisplayType,omitempty"`
}
// DiscordPresenceClientClosedEvent is triggered when the discord rpc client is closed.
type DiscordPresenceClientClosedEvent struct {
hook_resolver.Event
}

View File

@@ -0,0 +1,667 @@
package discordrpc_presence
import (
"context"
"fmt"
"seanime/internal/constants"
"seanime/internal/database/models"
discordrpc_client "seanime/internal/discordrpc/client"
"seanime/internal/hook"
"seanime/internal/util"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
type Presence struct {
client *discordrpc_client.Client
settings *models.DiscordSettings
logger *zerolog.Logger
hasSent bool
username string
mu sync.RWMutex
animeActivity *AnimeActivity
lastAnimeActivityUpdateSent time.Time
lastSent time.Time
eventQueue chan func()
cancelFunc context.CancelFunc // Cancel function for the event loop context
}
// New creates a new Presence instance.
// If rich presence is enabled, it sets up a new discord rpc client.
func New(settings *models.DiscordSettings, logger *zerolog.Logger) *Presence {
var client *discordrpc_client.Client
if settings != nil && settings.EnableRichPresence {
var err error
client, err = discordrpc_client.New(constants.DiscordApplicationId)
if err != nil {
logger.Error().Err(err).Msg("discordrpc: rich presence enabled but failed to create discord rpc client")
}
}
p := &Presence{
client: client,
settings: settings,
logger: logger,
lastAnimeActivityUpdateSent: time.Now().Add(5 * time.Second),
lastSent: time.Now().Add(-5 * time.Second),
hasSent: false,
eventQueue: make(chan func(), 100),
}
if settings != nil && settings.EnableRichPresence {
p.startEventLoop()
}
return p
}
func (p *Presence) startEventLoop() {
// Cancel any existing goroutine
if p.cancelFunc != nil {
p.cancelFunc()
}
// Create new context with cancel
ctx, cancel := context.WithCancel(context.Background())
p.cancelFunc = cancel
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
p.logger.Debug().Msg("discordrpc: Event loop stopped")
return
case <-ticker.C:
select {
case job := <-p.eventQueue:
p.mu.RLock()
if p.client == nil {
p.mu.RUnlock()
continue
}
job()
p.lastSent = time.Now()
p.mu.RUnlock()
default:
}
}
}
}()
}
// Close closes the discord rpc client.
// If the client is nil, it does nothing.
func (p *Presence) Close() {
p.close()
p.animeActivity = nil
}
func (p *Presence) close() {
defer util.HandlePanicInModuleThen("discordrpc/presence/Close", func() {})
p.clearEventQueue()
// Cancel the event loop goroutine
if p.cancelFunc != nil {
p.cancelFunc()
p.cancelFunc = nil
}
if p.client == nil {
return
}
p.client.Close()
p.client = nil
_ = hook.GlobalHookManager.OnDiscordPresenceClientClosed().Trigger(&DiscordPresenceClientClosedEvent{})
}
func (p *Presence) SetSettings(settings *models.DiscordSettings) {
p.mu.Lock()
defer p.mu.Unlock()
defer util.HandlePanicInModuleThen("discordrpc/presence/SetSettings", func() {})
// Close the current client and stop event loop
p.Close()
settings.RichPresenceUseMediaTitleStatus = false // Devnote: Not used anymore, disable
settings.RichPresenceShowAniListMediaButton = false // Devnote: Not used anymore, disable
p.settings = settings
// Create a new client if rich presence is enabled
if settings.EnableRichPresence {
p.logger.Info().Msg("discordrpc: Discord Rich Presence enabled")
p.setClient()
} else {
p.client = nil
}
}
func (p *Presence) SetUsername(username string) {
p.mu.Lock()
defer p.mu.Unlock()
p.username = username
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (p *Presence) setClient() {
defer util.HandlePanicInModuleThen("discordrpc/presence/setClient", func() {})
if p.client == nil {
client, err := discordrpc_client.New(constants.DiscordApplicationId)
if err != nil {
p.logger.Error().Err(err).Msg("discordrpc: Rich presence enabled but failed to create discord rpc client")
return
}
p.client = client
p.startEventLoop()
p.logger.Debug().Msg("discordrpc: RPC client initialized and event loop started")
}
}
var isChecking bool
// check executes multiple checks to determine if the presence should be set.
// It returns true if the presence should be set.
func (p *Presence) check() (proceed bool) {
defer util.HandlePanicInModuleThen("discordrpc/presence/check", func() {
proceed = false
})
if isChecking {
return false
}
isChecking = true
defer func() {
isChecking = false
}()
// If the client is nil, return false
if p.settings == nil {
return false
}
// If rich presence is disabled, return false
if !p.settings.EnableRichPresence {
return false
}
// If the client is nil, create a new client
if p.client == nil {
p.setClient()
}
// If the client is still nil, return false
if p.client == nil {
return false
}
// If this is the first time setting the presence, return true
if !p.hasSent {
p.hasSent = true
return true
}
// // If the last sent time is less than 5 seconds ago, return false
// if time.Since(p.lastSent) < 5*time.Second {
// rest := 5*time.Second - time.Since(p.lastSent)
// time.Sleep(rest)
// }
return true
}
var (
defaultActivity = discordrpc_client.Activity{
Name: "Seanime",
Details: "",
State: "",
Assets: &discordrpc_client.Assets{
LargeImage: "",
LargeText: "",
SmallImage: "https://seanime.app/images/circular-logo.png",
SmallText: "Seanime v" + constants.Version,
SmallURL: "https://seanime.app",
},
Timestamps: &discordrpc_client.Timestamps{
Start: &discordrpc_client.Epoch{
Time: time.Now(),
},
},
Buttons: []*discordrpc_client.Button{
{
Label: "Seanime",
Url: "https://seanime.app",
},
},
Instance: true,
Type: 3,
StatusDisplayType: 2,
}
)
func isSeanimeButtonPresent(activity *discordrpc_client.Activity) bool {
if activity == nil || activity.Buttons == nil {
return false
}
for _, button := range activity.Buttons {
if button.Label == "Seanime" && button.Url == "https://seanime.app" {
return true
}
}
return false
}
type AnimeActivity struct {
ID int `json:"id"`
Title string `json:"title"`
Image string `json:"image"`
IsMovie bool `json:"isMovie"`
EpisodeNumber int `json:"episodeNumber"`
Paused bool `json:"paused"`
Progress int `json:"progress"`
Duration int `json:"duration"`
TotalEpisodes *int `json:"totalEpisodes,omitempty"`
CurrentEpisodeCount *int `json:"currentEpisodeCount,omitempty"`
EpisodeTitle *string `json:"episodeTitle,omitempty"`
}
func animeActivityKey(a *AnimeActivity) string {
return fmt.Sprintf("%d:%d", a.ID, a.EpisodeNumber)
}
func (p *Presence) SetAnimeActivity(a *AnimeActivity) {
p.mu.Lock()
defer p.mu.Unlock()
defer util.HandlePanicInModuleThen("discordrpc/presence/SetAnimeActivity", func() {})
if !p.check() {
return
}
if !p.settings.EnableAnimeRichPresence {
return
}
// Clear the queue if the anime activity is different
if p.animeActivity != nil && animeActivityKey(a) != animeActivityKey(p.animeActivity) {
p.clearEventQueue()
}
event := &DiscordPresenceAnimeActivityRequestedEvent{}
state := fmt.Sprintf("Watching Episode %d", a.EpisodeNumber)
//if a.TotalEpisodes != nil {
// state += fmt.Sprintf(" of %d", *a.TotalEpisodes)
//}
if a.IsMovie {
state = "Watching Movie"
}
activity := defaultActivity
activity.Details = a.Title
activity.DetailsURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID)
activity.State = state
activity.Assets.LargeImage = a.Image
activity.Assets.LargeText = a.Title
activity.Assets.LargeURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID)
// Calculate the start time
startTime := time.Now()
if a.Progress > 0 {
startTime = startTime.Add(-time.Duration(a.Progress) * time.Second)
}
activity.Timestamps.Start.Time = startTime
event.StartTimestamp = lo.ToPtr(startTime.Unix())
endTime := startTime.Add(time.Duration(a.Duration) * time.Second)
activity.Timestamps.End = &discordrpc_client.Epoch{
Time: endTime,
}
event.EndTimestamp = lo.ToPtr(endTime.Unix())
// Hide the end timestamp if the anime is paused
if a.Paused {
activity.Timestamps.End = nil
event.EndTimestamp = nil
}
activity.Buttons = make([]*discordrpc_client.Button, 0)
if p.settings.RichPresenceShowAniListProfileButton {
activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{
Label: "View Profile",
Url: fmt.Sprintf("https://anilist.co/user/%s", p.username),
})
}
if !(p.settings.RichPresenceHideSeanimeRepositoryButton || len(activity.Buttons) > 1) {
activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{
Label: "Seanime",
Url: "https://seanime.app",
})
}
// p.logger.Debug().Msgf("discordrpc: Setting anime activity: %s", a.Title)
p.animeActivity = a
event.AnimeActivity = a
event.Name = activity.Name
event.Details = activity.Details
event.DetailsURL = activity.DetailsURL
event.State = state
event.LargeImage = activity.Assets.LargeImage
event.LargeText = activity.Assets.LargeText
event.LargeURL = activity.Assets.LargeURL
event.SmallImage = activity.Assets.SmallImage
event.SmallText = activity.Assets.SmallText
event.SmallURL = activity.Assets.SmallURL
event.Buttons = activity.Buttons
event.Instance = defaultActivity.Instance
event.Type = defaultActivity.Type
_ = hook.GlobalHookManager.OnDiscordPresenceAnimeActivityRequested().Trigger(event)
if event.DefaultPrevented {
return
}
// Update the activity
activity.Name = event.Name
activity.Details = event.Details
activity.DetailsURL = event.DetailsURL
activity.State = event.State
activity.Assets.LargeImage = event.LargeImage
activity.Assets.LargeText = event.LargeText
activity.Assets.LargeURL = event.LargeURL
activity.Buttons = event.Buttons
// Only allow changing small image and text if Seanime button is present
if isSeanimeButtonPresent(&activity) {
activity.Assets.SmallImage = event.SmallImage
activity.Assets.SmallText = event.SmallText
activity.Assets.SmallURL = event.SmallURL
}
// Update start timestamp
if event.StartTimestamp != nil {
activity.Timestamps.Start.Time = time.Unix(*event.StartTimestamp, 0)
} else {
activity.Timestamps.Start = nil
}
// Update end timestamp
if event.EndTimestamp != nil {
activity.Timestamps.End = &discordrpc_client.Epoch{
Time: time.Unix(*event.EndTimestamp, 0),
}
} else {
activity.Timestamps.End = nil
}
// Reset timestamps if both are nil
if event.StartTimestamp == nil && event.EndTimestamp == nil {
activity.Timestamps = nil
}
activity.Instance = event.Instance
activity.Type = event.Type
select {
case p.eventQueue <- func() {
_ = p.client.SetActivity(activity)
// p.logger.Debug().Int("progress", a.Progress).Int("duration", a.Duration).Msgf("discordrpc: Anime activity set for %s", a.Title)
}:
default:
//p.logger.Error().Msgf("discordrpc: event queue is full for %s", a.Title)
}
}
// clearEventQueue drains the event queue channel
func (p *Presence) clearEventQueue() {
//p.logger.Debug().Msg("discordrpc: Clearing event queue")
for {
select {
case <-p.eventQueue:
default:
return
}
}
}
func (p *Presence) UpdateAnimeActivity(progress int, duration int, paused bool) {
// do not lock, we call SetAnimeActivity
defer util.HandlePanicInModuleThen("discordrpc/presence/UpdateWatching", func() {})
if p.animeActivity == nil {
return
}
p.animeActivity.Progress = progress
p.animeActivity.Duration = duration
// Pause status changed
if p.animeActivity.Paused != paused {
// p.logger.Debug().Msgf("discordrpc: Pause status changed to %t for %s", paused, p.animeActivity.Title)
p.animeActivity.Paused = paused
p.lastAnimeActivityUpdateSent = time.Now()
// Clear the event queue to ensure pause/unpause takes precedence
p.clearEventQueue()
if paused {
// p.logger.Debug().Msgf("discordrpc: Stopping activity for %s", p.animeActivity.Title)
// Stop the current activity if paused
// but do not erase the current activity
// p.close()
// edit: just switch to default timestamp
p.SetAnimeActivity(p.animeActivity)
} else {
// p.logger.Debug().Msgf("discordrpc: Restarting activity for %s", p.animeActivity.Title)
// Restart the current activity if unpaused
p.SetAnimeActivity(p.animeActivity)
}
return
}
// Handles seeking
if !p.animeActivity.Paused {
// If the last update was more than 5 seconds ago, update the activity
if time.Since(p.lastAnimeActivityUpdateSent) > 6*time.Second {
// p.logger.Debug().Msgf("discordrpc: Updating activity for %s", p.animeActivity.Title)
p.lastAnimeActivityUpdateSent = time.Now()
p.SetAnimeActivity(p.animeActivity)
}
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type LegacyAnimeActivity struct {
ID int `json:"id"`
Title string `json:"title"`
Image string `json:"image"`
IsMovie bool `json:"isMovie"`
EpisodeNumber int `json:"episodeNumber"`
}
// LegacySetAnimeActivity sets the presence to watching anime.
func (p *Presence) LegacySetAnimeActivity(a *LegacyAnimeActivity) {
p.mu.Lock()
defer p.mu.Unlock()
defer util.HandlePanicInModuleThen("discordrpc/presence/SetAnimeActivity", func() {})
if !p.check() {
return
}
if !p.settings.EnableAnimeRichPresence {
return
}
state := fmt.Sprintf("Watching Episode %d", a.EpisodeNumber)
if a.IsMovie {
state = "Watching Movie"
}
activity := defaultActivity
activity.Details = a.Title
activity.DetailsURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID)
activity.State = state
activity.Assets.LargeImage = a.Image
activity.Assets.LargeText = a.Title
activity.Assets.LargeURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID)
activity.Timestamps.Start.Time = time.Now()
activity.Timestamps.End = nil
activity.Buttons = make([]*discordrpc_client.Button, 0)
if p.settings.RichPresenceShowAniListProfileButton {
activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{
Label: "View Profile",
Url: fmt.Sprintf("https://anilist.co/user/%s", p.username),
})
}
if !(p.settings.RichPresenceHideSeanimeRepositoryButton || len(activity.Buttons) > 1) {
activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{
Label: "Seanime",
Url: "https://seanime.app",
})
}
// p.logger.Debug().Msgf("discordrpc: Setting anime activity: %s", a.Title)
p.eventQueue <- func() {
_ = p.client.SetActivity(activity)
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type MangaActivity struct {
ID int `json:"id"`
Title string `json:"title"`
Image string `json:"image"`
Chapter string `json:"chapter"`
}
// SetMangaActivity sets the presence to watching anime.
func (p *Presence) SetMangaActivity(a *MangaActivity) {
p.mu.Lock()
defer p.mu.Unlock()
defer util.HandlePanicInModuleThen("discordrpc/presence/SetMangaActivity", func() {})
if !p.check() {
return
}
if !p.settings.EnableMangaRichPresence {
return
}
event := &DiscordPresenceMangaActivityRequestedEvent{}
activity := defaultActivity
activity.Details = a.Title
activity.DetailsURL = fmt.Sprintf("https://anilist.co/manga/%d", a.ID)
activity.State = fmt.Sprintf("Reading Chapter %s", a.Chapter)
activity.Assets.LargeImage = a.Image
activity.Assets.LargeText = a.Title
activity.Assets.LargeURL = fmt.Sprintf("https://anilist.co/manga/%d", a.ID)
now := time.Now()
activity.Timestamps.Start.Time = now
event.StartTimestamp = lo.ToPtr(now.Unix())
activity.Timestamps.End = nil
event.EndTimestamp = nil
activity.Buttons = make([]*discordrpc_client.Button, 0)
if p.settings.RichPresenceShowAniListProfileButton && p.username != "" {
activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{
Label: "View Profile",
Url: fmt.Sprintf("https://anilist.co/user/%s", p.username),
})
}
if !(p.settings.RichPresenceHideSeanimeRepositoryButton || len(activity.Buttons) > 1) {
activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{
Label: "Seanime",
Url: "https://seanime.app",
})
}
event.MangaActivity = a
event.Name = activity.Name
event.Details = activity.Details
event.DetailsURL = activity.DetailsURL
event.State = activity.State
event.LargeImage = activity.Assets.LargeImage
event.LargeText = activity.Assets.LargeText
event.LargeURL = activity.Assets.LargeURL
event.SmallImage = activity.Assets.SmallImage
event.SmallText = activity.Assets.SmallText
event.SmallURL = activity.Assets.SmallURL
event.Buttons = activity.Buttons
event.Instance = activity.Instance
event.Type = activity.Type
_ = hook.GlobalHookManager.OnDiscordPresenceMangaActivityRequested().Trigger(event)
if event.DefaultPrevented {
return
}
// Update the activity
activity.Name = event.Name
activity.Details = event.Details
activity.DetailsURL = event.DetailsURL
activity.State = event.State
activity.Assets.LargeImage = event.LargeImage
activity.Assets.LargeText = event.LargeText
activity.Assets.LargeURL = event.LargeURL
activity.Buttons = event.Buttons
// Only allow changing small image and text if Seanime button is present
if isSeanimeButtonPresent(&activity) {
activity.Assets.SmallImage = event.SmallImage
activity.Assets.SmallText = event.SmallText
activity.Assets.SmallURL = event.SmallURL
}
activity.Instance = event.Instance
activity.Type = event.Type
// Update start timestamp
if event.StartTimestamp != nil {
activity.Timestamps.Start.Time = time.Unix(*event.StartTimestamp, 0)
} else {
activity.Timestamps.Start = nil
}
// Update end timestamp
if event.EndTimestamp != nil {
activity.Timestamps.End = &discordrpc_client.Epoch{
Time: time.Unix(*event.EndTimestamp, 0),
}
} else {
activity.Timestamps.End = nil
}
// Reset timestamps if both are nil
if event.StartTimestamp == nil && event.EndTimestamp == nil {
activity.Timestamps = nil
}
p.logger.Debug().Msgf("discordrpc: Setting manga activity: %s", a.Title)
p.eventQueue <- func() {
_ = p.client.SetActivity(activity)
}
}

View File

@@ -0,0 +1,60 @@
package discordrpc_presence
import (
"seanime/internal/database/models"
"seanime/internal/util"
"testing"
"time"
)
func TestPresence(t *testing.T) {
settings := &models.DiscordSettings{
EnableRichPresence: true,
EnableAnimeRichPresence: true,
EnableMangaRichPresence: true,
}
presence := New(nil, util.NewLogger())
presence.SetSettings(settings)
presence.SetUsername("test")
defer presence.Close()
presence.SetMangaActivity(&MangaActivity{
Title: "Boku no Kokoro no Yabai Yatsu",
Image: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg",
Chapter: "30",
})
time.Sleep(10 * time.Second)
// Simulate settings being updated
settings.EnableMangaRichPresence = false
presence.SetSettings(settings)
presence.SetUsername("test")
time.Sleep(5 * time.Second)
presence.SetMangaActivity(&MangaActivity{
Title: "Boku no Kokoro no Yabai Yatsu",
Image: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg",
Chapter: "31",
})
// Simulate settings being updated
settings.EnableMangaRichPresence = true
presence.SetSettings(settings)
presence.SetUsername("test")
time.Sleep(5 * time.Second)
presence.SetMangaActivity(&MangaActivity{
Title: "Boku no Kokoro no Yabai Yatsu",
Image: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg",
Chapter: "31",
})
time.Sleep(10 * time.Second)
}