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,109 @@
package core
import (
"context"
"seanime/internal/api/anilist"
"seanime/internal/events"
"seanime/internal/platforms/platform"
"seanime/internal/user"
)
// GetUser returns the currently logged-in user or a simulated one.
func (a *App) GetUser() *user.User {
if a.user == nil {
return user.NewSimulatedUser()
}
return a.user
}
func (a *App) GetUserAnilistToken() string {
if a.user == nil || a.user.Token == user.SimulatedUserToken {
return ""
}
return a.user.Token
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// UpdatePlatform changes the current platform to the provided one.
func (a *App) UpdatePlatform(platform platform.Platform) {
a.AnilistPlatform = platform
}
// UpdateAnilistClientToken will update the Anilist Client Wrapper token.
// This function should be called when a user logs in
func (a *App) UpdateAnilistClientToken(token string) {
a.AnilistClient = anilist.NewAnilistClient(token)
a.AnilistPlatform.SetAnilistClient(a.AnilistClient) // Update Anilist Client Wrapper in Platform
}
// GetAnimeCollection returns the user's Anilist collection if it in the cache, otherwise it queries Anilist for the user's collection.
// When bypassCache is true, it will always query Anilist for the user's collection
func (a *App) GetAnimeCollection(bypassCache bool) (*anilist.AnimeCollection, error) {
return a.AnilistPlatform.GetAnimeCollection(context.Background(), bypassCache)
}
// GetRawAnimeCollection is the same as GetAnimeCollection but returns the raw collection that includes custom lists
func (a *App) GetRawAnimeCollection(bypassCache bool) (*anilist.AnimeCollection, error) {
return a.AnilistPlatform.GetRawAnimeCollection(context.Background(), bypassCache)
}
// RefreshAnimeCollection queries Anilist for the user's collection
func (a *App) RefreshAnimeCollection() (*anilist.AnimeCollection, error) {
go func() {
a.OnRefreshAnilistCollectionFuncs.Range(func(key string, f func()) bool {
go f()
return true
})
}()
ret, err := a.AnilistPlatform.RefreshAnimeCollection(context.Background())
if err != nil {
return nil, err
}
// Save the collection to PlaybackManager
a.PlaybackManager.SetAnimeCollection(ret)
// Save the collection to AutoDownloader
a.AutoDownloader.SetAnimeCollection(ret)
// Save the collection to LocalManager
a.LocalManager.SetAnimeCollection(ret)
// Save the collection to DirectStreamManager
a.DirectStreamManager.SetAnimeCollection(ret)
a.WSEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, nil)
return ret, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// GetMangaCollection is the same as GetAnimeCollection but for manga
func (a *App) GetMangaCollection(bypassCache bool) (*anilist.MangaCollection, error) {
return a.AnilistPlatform.GetMangaCollection(context.Background(), bypassCache)
}
// GetRawMangaCollection does not exclude custom lists
func (a *App) GetRawMangaCollection(bypassCache bool) (*anilist.MangaCollection, error) {
return a.AnilistPlatform.GetRawMangaCollection(context.Background(), bypassCache)
}
// RefreshMangaCollection queries Anilist for the user's manga collection
func (a *App) RefreshMangaCollection() (*anilist.MangaCollection, error) {
mc, err := a.AnilistPlatform.RefreshMangaCollection(context.Background())
if err != nil {
return nil, err
}
a.LocalManager.SetMangaCollection(mc)
a.WSEventManager.SendEvent(events.RefreshedAnilistMangaCollection, nil)
return mc, nil
}

View File

@@ -0,0 +1,440 @@
package core
import (
"os"
"runtime"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/constants"
"seanime/internal/continuity"
"seanime/internal/database/db"
"seanime/internal/database/models"
debrid_client "seanime/internal/debrid/client"
"seanime/internal/directstream"
discordrpc_presence "seanime/internal/discordrpc/presence"
"seanime/internal/doh"
"seanime/internal/events"
"seanime/internal/extension_playground"
"seanime/internal/extension_repo"
"seanime/internal/hook"
"seanime/internal/library/autodownloader"
"seanime/internal/library/autoscanner"
"seanime/internal/library/fillermanager"
"seanime/internal/library/playbackmanager"
"seanime/internal/library/scanner"
"seanime/internal/local"
"seanime/internal/manga"
"seanime/internal/mediaplayers/iina"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/mediaplayers/mpchc"
"seanime/internal/mediaplayers/mpv"
"seanime/internal/mediaplayers/vlc"
"seanime/internal/mediastream"
"seanime/internal/nakama"
"seanime/internal/nativeplayer"
"seanime/internal/onlinestream"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/platforms/offline_platform"
"seanime/internal/platforms/platform"
"seanime/internal/platforms/simulated_platform"
"seanime/internal/plugin"
"seanime/internal/report"
"seanime/internal/torrent_clients/torrent_client"
"seanime/internal/torrents/torrent"
"seanime/internal/torrentstream"
"seanime/internal/updater"
"seanime/internal/user"
"seanime/internal/util"
"seanime/internal/util/filecache"
"seanime/internal/util/result"
"sync"
"github.com/rs/zerolog"
)
type (
App struct {
Config *Config
Database *db.Database
Logger *zerolog.Logger
TorrentClientRepository *torrent_client.Repository
TorrentRepository *torrent.Repository
DebridClientRepository *debrid_client.Repository
Watcher *scanner.Watcher
AnilistClient anilist.AnilistClient
AnilistPlatform platform.Platform
OfflinePlatform platform.Platform
LocalManager local.Manager
FillerManager *fillermanager.FillerManager
WSEventManager *events.WSEventManager
AutoDownloader *autodownloader.AutoDownloader
ExtensionRepository *extension_repo.Repository
ExtensionPlaygroundRepository *extension_playground.PlaygroundRepository
DirectStreamManager *directstream.Manager
NativePlayer *nativeplayer.NativePlayer
MediaPlayer struct {
VLC *vlc.VLC
MpcHc *mpchc.MpcHc
Mpv *mpv.Mpv
Iina *iina.Iina
}
MediaPlayerRepository *mediaplayer.Repository
Version string
Updater *updater.Updater
AutoScanner *autoscanner.AutoScanner
PlaybackManager *playbackmanager.PlaybackManager
FileCacher *filecache.Cacher
OnlinestreamRepository *onlinestream.Repository
MangaRepository *manga.Repository
MetadataProvider metadata.Provider
DiscordPresence *discordrpc_presence.Presence
MangaDownloader *manga.Downloader
ContinuityManager *continuity.Manager
Cleanups []func()
OnRefreshAnilistCollectionFuncs *result.Map[string, func()]
OnFlushLogs func()
MediastreamRepository *mediastream.Repository
TorrentstreamRepository *torrentstream.Repository
FeatureFlags FeatureFlags
Settings *models.Settings
SecondarySettings struct {
Mediastream *models.MediastreamSettings
Torrentstream *models.TorrentstreamSettings
Debrid *models.DebridSettings
} // Struct for other settings sent to clientN
SelfUpdater *updater.SelfUpdater
ReportRepository *report.Repository
TotalLibrarySize uint64 // Initialized in modules.go
LibraryDir string
IsDesktopSidecar bool
animeCollection *anilist.AnimeCollection
rawAnimeCollection *anilist.AnimeCollection // (retains custom lists)
mangaCollection *anilist.MangaCollection
rawMangaCollection *anilist.MangaCollection // (retains custom lists)
user *user.User
previousVersion string
moduleMu sync.Mutex
HookManager hook.Manager
ServerReady bool // Whether the Anilist data from the first request has been fetched
isOffline *bool
NakamaManager *nakama.Manager
ServerPasswordHash string // SHA-256 hash of the server password
}
)
// NewApp creates a new server instance
func NewApp(configOpts *ConfigOptions, selfupdater *updater.SelfUpdater) *App {
// Initialize logger with predefined format
logger := util.NewLogger()
// Log application version, OS, architecture and system info
logger.Info().Msgf("app: Seanime %s-%s", constants.Version, constants.VersionName)
logger.Info().Msgf("app: OS: %s", runtime.GOOS)
logger.Info().Msgf("app: Arch: %s", runtime.GOARCH)
logger.Info().Msgf("app: Processor count: %d", runtime.NumCPU())
// Initialize hook manager for plugin event system
hookManager := hook.NewHookManager(hook.NewHookManagerOptions{Logger: logger})
hook.SetGlobalHookManager(hookManager)
plugin.GlobalAppContext.SetLogger(logger)
// Store current version to detect version changes
previousVersion := constants.Version
// Add callback to track version changes
configOpts.OnVersionChange = append(configOpts.OnVersionChange, func(oldVersion string, newVersion string) {
logger.Info().Str("prev", oldVersion).Str("current", newVersion).Msg("app: Version change detected")
previousVersion = oldVersion
})
// Initialize configuration with provided options
// Creates config directory if it doesn't exist
cfg, err := NewConfig(configOpts, logger)
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize config")
}
// Compute SHA-256 hash of the server password
serverPasswordHash := ""
if cfg.Server.Password != "" {
serverPasswordHash = util.HashSHA256Hex(cfg.Server.Password)
}
// Create logs directory if it doesn't exist
_ = os.MkdirAll(cfg.Logs.Dir, 0755)
// Start background process to trim log files
go TrimLogEntries(cfg.Logs.Dir, logger)
logger.Info().Msgf("app: Data directory: %s", cfg.Data.AppDataDir)
logger.Info().Msgf("app: Working directory: %s", cfg.Data.WorkingDir)
// Log if running in desktop sidecar mode
if configOpts.IsDesktopSidecar {
logger.Info().Msg("app: Desktop sidecar mode enabled")
}
// Initialize database connection
database, err := db.NewDatabase(cfg.Data.AppDataDir, cfg.Database.Name, logger)
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize database")
}
HandleNewDatabaseEntries(database, logger)
// Clean up old database entries in background goroutines
database.TrimLocalFileEntries() // Remove old local file entries
database.TrimScanSummaryEntries() // Remove old scan summaries
database.TrimTorrentstreamHistory() // Remove old torrent stream history
// Get anime library paths for plugin context
animeLibraryPaths, _ := database.GetAllLibraryPathsFromSettings()
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
Database: database,
AnimeLibraryPaths: &animeLibraryPaths,
})
// Get Anilist token from database if available
anilistToken := database.GetAnilistToken()
// Initialize Anilist API client with the token
// If the token is empty, the client will not be authenticated
anilistCW := anilist.NewAnilistClient(anilistToken)
// Initialize WebSocket event manager for real-time communication
wsEventManager := events.NewWSEventManager(logger)
// Exit if no WebSocket connections in desktop sidecar mode
if configOpts.IsDesktopSidecar {
wsEventManager.ExitIfNoConnsAsDesktopSidecar()
}
// Initialize DNS-over-HTTPS service in background
go doh.HandleDoH(cfg.Server.DoHUrl, logger)
// Initialize file cache system for media and metadata
fileCacher, err := filecache.NewCacher(cfg.Cache.Dir)
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize file cacher")
}
// Initialize extension repository
extensionRepository := extension_repo.NewRepository(&extension_repo.NewRepositoryOptions{
Logger: logger,
ExtensionDir: cfg.Extensions.Dir,
WSEventManager: wsEventManager,
FileCacher: fileCacher,
HookManager: hookManager,
})
// Load extensions in background
go LoadExtensions(extensionRepository, logger, cfg)
// Initialize metadata provider for media information
metadataProvider := metadata.NewProvider(&metadata.NewProviderImplOptions{
Logger: logger,
FileCacher: fileCacher,
})
// Set initial metadata provider (will change if offline mode is enabled)
activeMetadataProvider := metadataProvider
// Initialize manga repository
mangaRepository := manga.NewRepository(&manga.NewRepositoryOptions{
Logger: logger,
FileCacher: fileCacher,
CacheDir: cfg.Cache.Dir,
ServerURI: cfg.GetServerURI(),
WsEventManager: wsEventManager,
DownloadDir: cfg.Manga.DownloadDir,
Database: database,
})
// Initialize Anilist platform
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistCW, logger)
// Update plugin context with new modules
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
AnilistPlatform: anilistPlatform,
WSEventManager: wsEventManager,
MetadataProvider: metadataProvider,
})
// Initialize sync manager for offline/online synchronization
localManager, err := local.NewManager(&local.NewManagerOptions{
LocalDir: cfg.Offline.Dir,
AssetDir: cfg.Offline.AssetDir,
Logger: logger,
MetadataProvider: metadataProvider,
MangaRepository: mangaRepository,
Database: database,
WSEventManager: wsEventManager,
IsOffline: cfg.Server.Offline,
AnilistPlatform: anilistPlatform,
})
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize sync manager")
}
// Use local metadata provider if in offline mode
if cfg.Server.Offline {
activeMetadataProvider = localManager.GetOfflineMetadataProvider()
}
// Initialize local platform for offline operations
offlinePlatform, err := offline_platform.NewOfflinePlatform(localManager, anilistCW, logger)
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize local platform")
}
// Initialize simulated platform for unauthenticated operations
simulatedPlatform, err := simulated_platform.NewSimulatedPlatform(localManager, anilistCW, logger)
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize simulated platform")
}
// Change active platform if offline mode is enabled
activePlatform := anilistPlatform
if cfg.Server.Offline {
activePlatform = offlinePlatform
} else if !anilistCW.IsAuthenticated() {
logger.Warn().Msg("app: Anilist client is not authenticated, using simulated platform")
activePlatform = simulatedPlatform
}
// Initialize online streaming repository
onlinestreamRepository := onlinestream.NewRepository(&onlinestream.NewRepositoryOptions{
Logger: logger,
FileCacher: fileCacher,
MetadataProvider: activeMetadataProvider,
Platform: activePlatform,
Database: database,
})
// Initialize extension playground for testing extensions
extensionPlaygroundRepository := extension_playground.NewPlaygroundRepository(logger, activePlatform, activeMetadataProvider)
isOffline := cfg.Server.Offline
// Create the main app instance with initialized components
app := &App{
Config: cfg,
Database: database,
AnilistClient: anilistCW,
AnilistPlatform: activePlatform,
OfflinePlatform: offlinePlatform,
LocalManager: localManager,
WSEventManager: wsEventManager,
Logger: logger,
Version: constants.Version,
Updater: updater.New(constants.Version, logger, wsEventManager),
FileCacher: fileCacher,
OnlinestreamRepository: onlinestreamRepository,
MetadataProvider: activeMetadataProvider,
MangaRepository: mangaRepository,
ExtensionRepository: extensionRepository,
ExtensionPlaygroundRepository: extensionPlaygroundRepository,
ReportRepository: report.NewRepository(logger),
TorrentRepository: nil, // Initialized in App.initModulesOnce
FillerManager: nil, // Initialized in App.initModulesOnce
MangaDownloader: nil, // Initialized in App.initModulesOnce
PlaybackManager: nil, // Initialized in App.initModulesOnce
AutoDownloader: nil, // Initialized in App.initModulesOnce
AutoScanner: nil, // Initialized in App.initModulesOnce
MediastreamRepository: nil, // Initialized in App.initModulesOnce
TorrentstreamRepository: nil, // Initialized in App.initModulesOnce
ContinuityManager: nil, // Initialized in App.initModulesOnce
DebridClientRepository: nil, // Initialized in App.initModulesOnce
DirectStreamManager: nil, // Initialized in App.initModulesOnce
NativePlayer: nil, // Initialized in App.initModulesOnce
NakamaManager: nil, // Initialized in App.initModulesOnce
TorrentClientRepository: nil, // Initialized in App.InitOrRefreshModules
MediaPlayerRepository: nil, // Initialized in App.InitOrRefreshModules
DiscordPresence: nil, // Initialized in App.InitOrRefreshModules
previousVersion: previousVersion,
FeatureFlags: NewFeatureFlags(cfg, logger),
IsDesktopSidecar: configOpts.IsDesktopSidecar,
SecondarySettings: struct {
Mediastream *models.MediastreamSettings
Torrentstream *models.TorrentstreamSettings
Debrid *models.DebridSettings
}{Mediastream: nil, Torrentstream: nil},
SelfUpdater: selfupdater,
moduleMu: sync.Mutex{},
OnRefreshAnilistCollectionFuncs: result.NewResultMap[string, func()](),
HookManager: hookManager,
isOffline: &isOffline,
ServerPasswordHash: serverPasswordHash,
}
// Run database migrations if version has changed
app.runMigrations()
// Initialize modules that only need to be initialized once
app.initModulesOnce()
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
IsOffline: app.IsOffline(),
ContinuityManager: app.ContinuityManager,
AutoScanner: app.AutoScanner,
AutoDownloader: app.AutoDownloader,
FileCacher: app.FileCacher,
OnlinestreamRepository: app.OnlinestreamRepository,
MediastreamRepository: app.MediastreamRepository,
TorrentstreamRepository: app.TorrentstreamRepository,
})
if !*app.IsOffline() {
go app.Updater.FetchAnnouncements()
}
// Initialize all modules that depend on settings
app.InitOrRefreshModules()
// Load built-in extensions into extension consumers
app.AddExtensionBankToConsumers()
// Initialize Anilist data if not in offline mode
if !*app.IsOffline() {
app.InitOrRefreshAnilistData()
} else {
app.ServerReady = true
}
// Initialize mediastream settings (for streaming media)
app.InitOrRefreshMediastreamSettings()
// Initialize torrentstream settings (for torrent streaming)
app.InitOrRefreshTorrentstreamSettings()
// Initialize debrid settings (for debrid services)
app.InitOrRefreshDebridSettings()
// Register Nakama manager cleanup
app.AddCleanupFunction(app.NakamaManager.Cleanup)
// Run one-time initialization actions
app.performActionsOnce()
return app
}
func (a *App) IsOffline() *bool {
return a.isOffline
}
func (a *App) AddCleanupFunction(f func()) {
a.Cleanups = append(a.Cleanups, f)
}
func (a *App) AddOnRefreshAnilistCollectionFunc(key string, f func()) {
if key == "" {
return
}
a.OnRefreshAnilistCollectionFuncs.Set(key, f)
}
func (a *App) Cleanup() {
for _, f := range a.Cleanups {
f()
}
}

View File

@@ -0,0 +1,439 @@
package core
import (
"errors"
"fmt"
"os"
"path/filepath"
"seanime/internal/constants"
"seanime/internal/util"
"strconv"
"github.com/rs/zerolog"
"github.com/spf13/viper"
)
type Config struct {
Version string
Server struct {
Host string
Port int
Offline bool
UseBinaryPath bool // Makes $SEANIME_WORKING_DIR point to the binary's directory
Systray bool
DoHUrl string
Password string
}
Database struct {
Name string
}
Web struct {
AssetDir string
}
Logs struct {
Dir string
}
Cache struct {
Dir string
TranscodeDir string
}
Offline struct {
Dir string
AssetDir string
}
Manga struct {
DownloadDir string
LocalDir string
}
Data struct { // Hydrated after config is loaded
AppDataDir string
WorkingDir string
}
Extensions struct {
Dir string
}
Anilist struct {
ClientID string
}
Experimental struct {
MainServerTorrentStreaming bool
}
}
type ConfigOptions struct {
DataDir string // The path to the Seanime data directory, if any
OnVersionChange []func(oldVersion string, newVersion string)
EmbeddedLogo []byte // The embedded logo
IsDesktopSidecar bool // Run as the desktop sidecar
}
// NewConfig initializes the config
func NewConfig(options *ConfigOptions, logger *zerolog.Logger) (*Config, error) {
logger.Debug().Msg("app: Initializing config")
// Set Seanime's environment variables
if os.Getenv("SEANIME_DATA_DIR") != "" {
options.DataDir = os.Getenv("SEANIME_DATA_DIR")
}
defaultHost := "127.0.0.1"
defaultPort := 43211
if os.Getenv("SEANIME_SERVER_HOST") != "" {
defaultHost = os.Getenv("SEANIME_SERVER_HOST")
}
if os.Getenv("SEANIME_SERVER_PORT") != "" {
var err error
defaultPort, err = strconv.Atoi(os.Getenv("SEANIME_SERVER_PORT"))
if err != nil {
return nil, fmt.Errorf("invalid SEANIME_SERVER_PORT environment variable: %s", os.Getenv("SEANIME_SERVER_PORT"))
}
}
// Initialize the app data directory
dataDir, configPath, err := initAppDataDir(options.DataDir, logger)
if err != nil {
return nil, err
}
// Set Seanime's default custom environment variables
if err = setDataDirEnv(dataDir); err != nil {
return nil, err
}
// Configure viper
viper.SetConfigName(constants.ConfigFileName)
viper.SetConfigType("toml")
viper.SetConfigFile(configPath)
// Set default values
viper.SetDefault("version", constants.Version)
viper.SetDefault("server.host", defaultHost)
viper.SetDefault("server.port", defaultPort)
viper.SetDefault("server.offline", false)
// Use the binary's directory as the working directory environment variable on macOS
viper.SetDefault("server.useBinaryPath", true)
//viper.SetDefault("server.systray", true)
viper.SetDefault("database.name", "seanime")
viper.SetDefault("web.assetDir", "$SEANIME_DATA_DIR/assets")
viper.SetDefault("cache.dir", "$SEANIME_DATA_DIR/cache")
viper.SetDefault("cache.transcodeDir", "$SEANIME_DATA_DIR/cache/transcode")
viper.SetDefault("manga.downloadDir", "$SEANIME_DATA_DIR/manga")
viper.SetDefault("manga.localDir", "$SEANIME_DATA_DIR/manga-local")
viper.SetDefault("logs.dir", "$SEANIME_DATA_DIR/logs")
viper.SetDefault("offline.dir", "$SEANIME_DATA_DIR/offline")
viper.SetDefault("offline.assetDir", "$SEANIME_DATA_DIR/offline/assets")
viper.SetDefault("extensions.dir", "$SEANIME_DATA_DIR/extensions")
// Create and populate the config file if it doesn't exist
if err = createConfigFile(configPath); err != nil {
return nil, err
}
// Read the config file
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
// Unmarshal the config values
cfg := &Config{}
if err := viper.Unmarshal(cfg); err != nil {
return nil, err
}
// Update the config if the version has changed
if err := updateVersion(cfg, options); err != nil {
return nil, err
}
// Before expanding the values, check if we need to override the working directory
if err = setWorkingDirEnv(cfg.Server.UseBinaryPath); err != nil {
return nil, err
}
// Expand the values, replacing environment variables
expandEnvironmentValues(cfg)
cfg.Data.AppDataDir = dataDir
cfg.Data.WorkingDir = os.Getenv("SEANIME_WORKING_DIR")
// Check validity of the config
if err := validateConfig(cfg, logger); err != nil {
return nil, err
}
go loadLogo(options.EmbeddedLogo, dataDir)
return cfg, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (cfg *Config) GetServerAddr(df ...string) string {
return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
}
func (cfg *Config) GetServerURI(df ...string) string {
pAddr := fmt.Sprintf("http://%s", cfg.GetServerAddr(df...))
if cfg.Server.Host == "" || cfg.Server.Host == "0.0.0.0" {
pAddr = fmt.Sprintf(":%d", cfg.Server.Port)
if len(df) > 0 {
pAddr = fmt.Sprintf("http://%s:%d", df[0], cfg.Server.Port)
}
}
return pAddr
}
func getWorkingDir(useBinaryPath bool) (string, error) {
// Get the working directory
wd, err := os.Getwd()
if err != nil {
return "", err
}
binaryDir := ""
if exe, err := os.Executable(); err == nil {
if p, err := filepath.EvalSymlinks(exe); err == nil {
binaryDir = filepath.Dir(p)
binaryDir = filepath.FromSlash(binaryDir)
}
}
if useBinaryPath && binaryDir != "" {
return binaryDir, nil
}
//// Use the binary's directory as the working directory if needed
//if useBinaryPath {
// exe, err := os.Executable()
// if err != nil {
// return wd, nil // Fallback to working dir
// }
// p, err := filepath.EvalSymlinks(exe)
// if err != nil {
// return wd, nil // Fallback to working dir
// }
// wd = filepath.Dir(p) // Set the binary's directory as the working directory
// return wd, nil
//}
return wd, nil
}
func setDataDirEnv(dataDir string) error {
// Set the data directory environment variable
if os.Getenv("SEANIME_DATA_DIR") == "" {
if err := os.Setenv("SEANIME_DATA_DIR", dataDir); err != nil {
return err
}
}
return nil
}
func setWorkingDirEnv(useBinaryPath bool) error {
// Set the working directory environment variable
wd, err := getWorkingDir(useBinaryPath)
if err != nil {
return err
}
if err = os.Setenv("SEANIME_WORKING_DIR", filepath.FromSlash(wd)); err != nil {
return err
}
return nil
}
// validateConfig checks if the config values are valid
func validateConfig(cfg *Config, logger *zerolog.Logger) error {
if cfg.Server.Host == "" {
return errInvalidConfigValue("server.host", "cannot be empty")
}
if cfg.Server.Port == 0 {
return errInvalidConfigValue("server.port", "cannot be 0")
}
if cfg.Database.Name == "" {
return errInvalidConfigValue("database.name", "cannot be empty")
}
if cfg.Web.AssetDir == "" {
return errInvalidConfigValue("web.assetDir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Web.AssetDir); err != nil {
return wrapInvalidConfigValue("web.assetDir", err)
}
if cfg.Cache.Dir == "" {
return errInvalidConfigValue("cache.dir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Cache.Dir); err != nil {
return wrapInvalidConfigValue("cache.dir", err)
}
if cfg.Cache.TranscodeDir == "" {
return errInvalidConfigValue("cache.transcodeDir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Cache.TranscodeDir); err != nil {
return wrapInvalidConfigValue("cache.transcodeDir", err)
}
if cfg.Logs.Dir == "" {
return errInvalidConfigValue("logs.dir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Logs.Dir); err != nil {
return wrapInvalidConfigValue("logs.dir", err)
}
if cfg.Manga.DownloadDir == "" {
return errInvalidConfigValue("manga.downloadDir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Manga.DownloadDir); err != nil {
return wrapInvalidConfigValue("manga.downloadDir", err)
}
if cfg.Manga.LocalDir == "" {
return errInvalidConfigValue("manga.localDir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Manga.LocalDir); err != nil {
return wrapInvalidConfigValue("manga.localDir", err)
}
if cfg.Extensions.Dir == "" {
return errInvalidConfigValue("extensions.dir", "cannot be empty")
}
if err := checkIsValidPath(cfg.Extensions.Dir); err != nil {
return wrapInvalidConfigValue("extensions.dir", err)
}
// Uncomment if "MainServerTorrentStreaming" is no longer an experimental feature
if cfg.Experimental.MainServerTorrentStreaming {
logger.Warn().Msgf("app: 'Main Server Torrent Streaming' feature is no longer experimental, remove the flag from your config file")
}
return nil
}
func checkIsValidPath(path string) error {
ok := filepath.IsAbs(path)
if !ok {
return errors.New("path is not an absolute path")
}
return nil
}
// errInvalidConfigValue returns an error for an invalid config value
func errInvalidConfigValue(s string, s2 string) error {
return fmt.Errorf("invalid config value: \"%s\" %s", s, s2)
}
func wrapInvalidConfigValue(s string, err error) error {
return fmt.Errorf("invalid config value: \"%s\" %w", s, err)
}
func updateVersion(cfg *Config, opts *ConfigOptions) error {
defer func() {
if r := recover(); r != nil {
// Do nothing
}
}()
if cfg.Version != constants.Version {
for _, f := range opts.OnVersionChange {
f(cfg.Version, constants.Version)
}
cfg.Version = constants.Version
}
viper.Set("version", constants.Version)
return viper.WriteConfig()
}
func expandEnvironmentValues(cfg *Config) {
defer func() {
if r := recover(); r != nil {
// Do nothing
}
}()
cfg.Web.AssetDir = filepath.FromSlash(os.ExpandEnv(cfg.Web.AssetDir))
cfg.Cache.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Cache.Dir))
cfg.Cache.TranscodeDir = filepath.FromSlash(os.ExpandEnv(cfg.Cache.TranscodeDir))
cfg.Logs.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Logs.Dir))
cfg.Manga.DownloadDir = filepath.FromSlash(os.ExpandEnv(cfg.Manga.DownloadDir))
cfg.Manga.LocalDir = filepath.FromSlash(os.ExpandEnv(cfg.Manga.LocalDir))
cfg.Offline.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Offline.Dir))
cfg.Offline.AssetDir = filepath.FromSlash(os.ExpandEnv(cfg.Offline.AssetDir))
cfg.Extensions.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Extensions.Dir))
}
// createConfigFile creates a default config file if it doesn't exist
func createConfigFile(configPath string) error {
_, err := os.Stat(configPath)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
return err
}
if err := viper.WriteConfig(); err != nil {
return err
}
}
return nil
}
func initAppDataDir(definedDataDir string, logger *zerolog.Logger) (dataDir string, configPath string, err error) {
// User defined data directory
if definedDataDir != "" {
// Expand environment variables
definedDataDir = filepath.FromSlash(os.ExpandEnv(definedDataDir))
if !filepath.IsAbs(definedDataDir) {
return "", "", errors.New("app: Data directory path must be absolute")
}
// Replace the default data directory
dataDir = definedDataDir
logger.Trace().Str("dataDir", dataDir).Msg("app: Overriding default data directory")
} else {
// Default OS data directory
// windows: %APPDATA%
// unix: $XDG_CONFIG_HOME or $HOME
// darwin: $HOME/Library/Application Support
dataDir, err = os.UserConfigDir()
if err != nil {
return "", "", err
}
// Get the app directory
dataDir = filepath.Join(dataDir, "Seanime")
}
// Create data dir if it doesn't exist
if err := os.MkdirAll(dataDir, 0700); err != nil {
return "", "", err
}
// Get the config file path
// Normalize the config file path
configPath = filepath.FromSlash(filepath.Join(dataDir, constants.ConfigFileName))
// Normalize the data directory path
dataDir = filepath.FromSlash(dataDir)
return
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func loadLogo(embeddedLogo []byte, dataDir string) (err error) {
defer util.HandlePanicInModuleWithError("core/loadLogo", &err)
if len(embeddedLogo) == 0 {
return nil
}
logoPath := filepath.Join(dataDir, "logo.png")
if _, err = os.Stat(logoPath); os.IsNotExist(err) {
if err = os.WriteFile(logoPath, embeddedLogo, 0644); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,93 @@
package core
import (
"embed"
"io/fs"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func NewEchoApp(app *App, webFS *embed.FS) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.HidePort = true
e.Debug = false
e.JSONSerializer = &CustomJSONSerializer{}
distFS, err := fs.Sub(webFS, "web")
if err != nil {
log.Fatal(err)
}
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: http.FS(distFS),
Browse: true,
HTML5: true,
Skipper: func(c echo.Context) bool {
cUrl := c.Request().URL
if strings.HasPrefix(cUrl.RequestURI(), "/api") ||
strings.HasPrefix(cUrl.RequestURI(), "/events") ||
strings.HasPrefix(cUrl.RequestURI(), "/assets") ||
strings.HasPrefix(cUrl.RequestURI(), "/manga-downloads") ||
strings.HasPrefix(cUrl.RequestURI(), "/offline-assets") {
return true // Continue to the next handler
}
if !strings.HasSuffix(cUrl.Path, ".html") && filepath.Ext(cUrl.Path) == "" {
cUrl.Path = cUrl.Path + ".html"
}
if cUrl.Path == "/.html" {
cUrl.Path = "/index.html"
}
return false // Continue to the filesystem handler
},
}))
app.Logger.Info().Msgf("app: Serving embedded web interface")
// Serve web assets
app.Logger.Info().Msgf("app: Web assets path: %s", app.Config.Web.AssetDir)
e.Static("/assets", app.Config.Web.AssetDir)
// Serve manga downloads
if app.Config.Manga.DownloadDir != "" {
app.Logger.Info().Msgf("app: Manga downloads path: %s", app.Config.Manga.DownloadDir)
e.Static("/manga-downloads", app.Config.Manga.DownloadDir)
}
// Serve offline assets
app.Logger.Info().Msgf("app: Offline assets path: %s", app.Config.Offline.AssetDir)
e.Static("/offline-assets", app.Config.Offline.AssetDir)
return e
}
type CustomJSONSerializer struct{}
func (j *CustomJSONSerializer) Serialize(c echo.Context, i interface{}, indent string) error {
enc := json.NewEncoder(c.Response())
return enc.Encode(i)
}
func (j *CustomJSONSerializer) Deserialize(c echo.Context, i interface{}) error {
dec := json.NewDecoder(c.Request().Body)
return dec.Decode(i)
}
func RunEchoServer(app *App, e *echo.Echo) {
app.Logger.Info().Msgf("app: Server Address: %s", app.Config.GetServerAddr())
// Start the server
go func() {
log.Fatal(e.Start(app.Config.GetServerAddr()))
}()
time.Sleep(100 * time.Millisecond)
app.Logger.Info().Msg("app: Seanime started at " + app.Config.GetServerURI())
}

View File

@@ -0,0 +1,263 @@
package core
import (
"seanime/internal/extension"
"seanime/internal/extension_repo"
manga_providers "seanime/internal/manga/providers"
onlinestream_providers "seanime/internal/onlinestream/providers"
"seanime/internal/torrents/animetosho"
"seanime/internal/torrents/nyaa"
"seanime/internal/torrents/seadex"
"seanime/internal/util"
"github.com/rs/zerolog"
)
func LoadExtensions(extensionRepository *extension_repo.Repository, logger *zerolog.Logger, config *Config) {
//
// Built-in manga providers
//
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "comick",
Name: "ComicK",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Description: "",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp",
}, manga_providers.NewComicK(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "comick-multi",
Name: "ComicK (Multi)",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Description: "",
Lang: "multi",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp",
}, manga_providers.NewComicKMulti(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "mangapill",
Name: "Mangapill",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangapill.png",
}, manga_providers.NewMangapill(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "weebcentral",
Name: "WeebCentral",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/weebcentral.png",
}, manga_providers.NewWeebCentral(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "mangadex",
Name: "Mangadex",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangadex.png",
}, manga_providers.NewMangadex(logger))
//extensionRepository.ReloadBuiltInExtension(extension.Extension{
// ID: "manganato",
// Name: "Manganato",
// Version: "",
// ManifestURI: "builtin",
// Language: extension.LanguageGo,
// Type: extension.TypeMangaProvider,
// Author: "Seanime",
// Lang: "en",
// Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/manganato.png",
//}, manga_providers.NewManganato(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: manga_providers.LocalProvider,
Name: "Local",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "multi",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/local-manga.png",
}, manga_providers.NewLocal(config.Manga.LocalDir, logger))
//
// Built-in online stream providers
//
//extensionRepository.LoadBuiltInOnlinestreamProviderExtension(extension.Extension{
// ID: "gogoanime",
// Name: "Gogoanime",
// Version: "",
// ManifestURI: "builtin",
// Language: extension.LanguageGo,
// Type: extension.TypeOnlinestreamProvider,
// Author: "Seanime",
// Lang: "en",
// Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/gogoanime.png",
//}, onlinestream_providers.NewGogoanime(logger))
//extensionRepository.LoadBuiltInOnlinestreamProviderExtension(extension.Extension{
// ID: "zoro",
// Name: "Hianime",
// Version: "",
// ManifestURI: "builtin",
// Language: extension.LanguageGo,
// Type: extension.TypeOnlinestreamProvider,
// Author: "Seanime",
// Lang: "en",
// Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/hianime.png",
//}, onlinestream_providers.NewZoro(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "animepahe",
Name: "Animepahe",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageTypescript,
Type: extension.TypeOnlinestreamProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/animepahe.png",
Payload: onlinestream_providers.AnimepahePayload,
}, nil)
//
// Built-in torrent providers
//
nyaaUserConfig := extension.UserConfig{
Version: 1,
Fields: []extension.ConfigField{
{
Name: "apiUrl",
Label: "API URL",
Type: extension.ConfigFieldTypeText,
Default: util.Decode("aHR0cHM6Ly9ueWFhLnNpLz9wYWdlPXJzcyZxPSs="),
},
},
}
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "nyaa",
Name: "Nyaa",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png",
UserConfig: &nyaaUserConfig,
}, nyaa.NewProvider(logger, "anime-eng"))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "nyaa-non-eng",
Name: "Nyaa (Non-English)",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "multi",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png",
UserConfig: &nyaaUserConfig,
}, nyaa.NewProvider(logger, "anime-non-eng"))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "nyaa-sukebei",
Name: "Nyaa Sukebei",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png",
UserConfig: &extension.UserConfig{
Version: 1,
Fields: []extension.ConfigField{
{
Name: "apiUrl",
Label: "API URL",
Type: extension.ConfigFieldTypeText,
Default: util.Decode("aHR0cHM6Ly9zdWtlYmVpLm55YWEuc2kvP3BhZ2U9cnNzJnE9Kw=="),
},
},
},
}, nyaa.NewSukebeiProvider(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "animetosho",
Name: "AnimeTosho",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/animetosho.png",
}, animetosho.NewProvider(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "seadex",
Name: "SeaDex",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/seadex.png",
UserConfig: &extension.UserConfig{
Version: 1,
Fields: []extension.ConfigField{
{
Name: "apiUrl",
Label: "API URL",
Type: extension.ConfigFieldTypeText,
Default: util.Decode("aHR0cHM6Ly9yZWxlYXNlcy5tb2UvYXBpL2NvbGxlY3Rpb25zL2VudHJpZXMvcmVjb3Jkcw=="),
},
},
},
}, seadex.NewProvider(logger))
extensionRepository.ReloadExternalExtensions()
}
func (a *App) AddExtensionBankToConsumers() {
var consumers = []extension.Consumer{
a.MangaRepository,
a.OnlinestreamRepository,
a.TorrentRepository,
}
for _, consumer := range consumers {
consumer.InitExtensionBank(a.ExtensionRepository.GetExtensionBank())
}
}

View File

@@ -0,0 +1,36 @@
package core
import (
"github.com/rs/zerolog"
"github.com/spf13/viper"
)
type (
FeatureFlags struct {
MainServerTorrentStreaming bool
}
ExperimentalFeatureFlags struct {
}
)
// NewFeatureFlags initializes the feature flags
func NewFeatureFlags(cfg *Config, logger *zerolog.Logger) FeatureFlags {
ff := FeatureFlags{
MainServerTorrentStreaming: viper.GetBool("experimental.mainServerTorrentStreaming"),
}
checkExperimentalFeatureFlags(&ff, cfg, logger)
return ff
}
func checkExperimentalFeatureFlags(ff *FeatureFlags, cfg *Config, logger *zerolog.Logger) {
if ff.MainServerTorrentStreaming {
logger.Warn().Msg("app: [Feature flag] 'Main Server Torrent Streaming' experimental feature is enabled")
}
}
func (ff *FeatureFlags) IsMainServerTorrentStreamingEnabled() bool {
return ff.MainServerTorrentStreaming
}

View File

@@ -0,0 +1,43 @@
package core
import (
"flag"
"fmt"
"strings"
)
type (
SeanimeFlags struct {
DataDir string
Update bool
IsDesktopSidecar bool
}
)
func GetSeanimeFlags() SeanimeFlags {
// Help flag
flag.Usage = func() {
fmt.Printf("Self-hosted, user-friendly, media server for anime and manga enthusiasts.\n\n")
fmt.Printf("Usage:\n seanime [flags]\n\n")
fmt.Printf("Flags:\n")
fmt.Printf(" -datadir, --datadir string")
fmt.Printf(" directory that contains all Seanime data\n")
fmt.Printf(" -update")
fmt.Printf(" update the application\n")
fmt.Printf(" -h show this help message\n")
}
// Parse flags
var dataDir string
flag.StringVar(&dataDir, "datadir", "", "Directory that contains all Seanime data")
var update bool
flag.BoolVar(&update, "update", false, "Update the application")
var isDesktopSidecar bool
flag.BoolVar(&isDesktopSidecar, "desktop-sidecar", false, "Run as the desktop sidecar")
flag.Parse()
return SeanimeFlags{
DataDir: strings.TrimSpace(dataDir),
Update: update,
IsDesktopSidecar: isDesktopSidecar,
}
}

View File

@@ -0,0 +1,19 @@
package core
import (
"seanime/internal/util"
"time"
)
// GetServerPasswordHMACAuth returns an HMAC authenticator using the hashed server password as the base secret
// This is used for server endpoints that don't use Nakama
func (a *App) GetServerPasswordHMACAuth() *util.HMACAuth {
var secret string
if a.Config != nil && a.Config.Server.Password != "" {
secret = a.ServerPasswordHash
} else {
secret = "seanime-default-secret"
}
return util.NewHMACAuth(secret, 24*time.Hour)
}

View File

@@ -0,0 +1,83 @@
package core
import (
"github.com/rs/zerolog"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func TrimLogEntries(dir string, logger *zerolog.Logger) {
// Get all log files in the directory
entries, err := os.ReadDir(dir)
if err != nil {
logger.Error().Err(err).Msg("core: Failed to read log directory")
return
}
// Get the total size of all log entries
var totalSize int64
for _, file := range entries {
if file.IsDir() {
continue
}
info, err := file.Info()
if err != nil {
continue
}
totalSize += info.Size()
}
var files []os.FileInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, info)
}
var serverLogFiles []os.FileInfo
var scanLogFiles []os.FileInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "seanime-") {
serverLogFiles = append(serverLogFiles, file)
} else if strings.Contains(file.Name(), "-scan") {
scanLogFiles = append(scanLogFiles, file)
}
}
for _, _files := range [][]os.FileInfo{serverLogFiles, scanLogFiles} {
files := _files
if len(files) <= 1 {
continue
}
// Sort from newest to oldest
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime().After(files[j].ModTime())
})
// Delete all log files older than 14 days
deleted := 0
for i := 1; i < len(files); i++ {
if time.Since(files[i].ModTime()) > 14*24*time.Hour {
err := os.Remove(filepath.Join(dir, files[i].Name()))
if err != nil {
continue
}
deleted++
}
}
if deleted > 0 {
logger.Info().Msgf("app: Deleted %d log files older than 14 days", deleted)
}
}
}

View File

@@ -0,0 +1,117 @@
package core
import (
"seanime/internal/constants"
"seanime/internal/util"
"strings"
"github.com/Masterminds/semver/v3"
)
func (a *App) runMigrations() {
go func() {
done := false
defer func() {
if done {
a.Logger.Info().Msg("app: Version migration complete")
}
}()
defer util.HandlePanicThen(func() {
a.Logger.Error().Msg("app: runMigrations failed")
})
previousVersion, err := semver.NewVersion(a.previousVersion)
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to parse previous version")
return
}
if a.previousVersion != constants.Version {
hasUpdated := util.VersionIsOlderThan(a.previousVersion, constants.Version)
//-----------------------------------------------------------------------------------------
// DEVNOTE: 1.2.0 uses an incorrect manga cache format for MangaSee pages
// This migration will remove all manga cache files that start with "manga_"
if a.previousVersion == "1.2.0" && hasUpdated {
a.Logger.Debug().Msg("app: Executing version migration task")
err := a.FileCacher.RemoveAllBy(func(filename string) bool {
return strings.HasPrefix(filename, "manga_")
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS")
a.Logger.Error().Msg("app: Failed to remove 'manga' cache files, please clear them manually by going to the settings. Ignore this message if you have no manga cache files.")
}
done = true
}
//-----------------------------------------------------------------------------------------
c1, _ := semver.NewConstraint("<= 1.3.0, >= 1.2.0")
if c1.Check(previousVersion) {
a.Logger.Debug().Msg("app: Executing version migration task")
err := a.FileCacher.RemoveAllBy(func(filename string) bool {
return strings.HasPrefix(filename, "manga_")
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS")
a.Logger.Error().Msg("app: Failed to remove 'manga' cache files, please clear them manually by going to the settings. Ignore this message if you have no manga cache files.")
}
done = true
}
//-----------------------------------------------------------------------------------------
// DEVNOTE: 1.5.6 uses a different cache format for media streaming info
// -> Delete the cache files when updated from any version between 1.5.0 and 1.5.5
c2, _ := semver.NewConstraint("<= 1.5.5, >= 1.5.0")
if c2.Check(previousVersion) {
a.Logger.Debug().Msg("app: Executing version migration task")
err := a.FileCacher.RemoveAllBy(func(filename string) bool {
return strings.HasPrefix(filename, "mediastream_mediainfo_")
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS")
a.Logger.Error().Msg("app: Failed to remove transcoding cache files, please clear them manually by going to the settings. Ignore this message if you have no transcoding cache files.")
}
done = true
}
//-----------------------------------------------------------------------------------------
// DEVNOTE: 2.0.0 uses a different cache format for online streaming
// -> Delete the cache files when updated from a version older than 2.0.0 and newer than 1.5.0
c3, _ := semver.NewConstraint("< 2.0.0, >= 1.5.0")
if c3.Check(previousVersion) {
a.Logger.Debug().Msg("app: Executing version migration task")
err := a.FileCacher.RemoveAllBy(func(filename string) bool {
return strings.HasPrefix(filename, "onlinestream_")
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS")
a.Logger.Error().Msg("app: Failed to remove online streaming cache files, please clear them manually by going to the settings. Ignore this message if you have no online streaming cache files.")
}
done = true
}
//-----------------------------------------------------------------------------------------
// DEVNOTE: 2.1.0 refactored the manga cache format
// -> Delete the cache files when updated from a version older than 2.1.0
c4, _ := semver.NewConstraint("< 2.1.0")
if c4.Check(previousVersion) {
a.Logger.Debug().Msg("app: Executing version migration task")
err := a.FileCacher.RemoveAllBy(func(filename string) bool {
return strings.HasPrefix(filename, "manga_")
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS")
a.Logger.Error().Msg("app: Failed to remove 'manga' cache files, please clear them manually by going to the settings. Ignore this message if you have no manga cache files.")
}
done = true
}
}
}()
}

View File

@@ -0,0 +1,727 @@
package core
import (
"runtime"
"seanime/internal/api/anilist"
"seanime/internal/continuity"
"seanime/internal/database/db"
"seanime/internal/database/db_bridge"
"seanime/internal/database/models"
debrid_client "seanime/internal/debrid/client"
"seanime/internal/directstream"
discordrpc_presence "seanime/internal/discordrpc/presence"
"seanime/internal/events"
"seanime/internal/library/anime"
"seanime/internal/library/autodownloader"
"seanime/internal/library/autoscanner"
"seanime/internal/library/fillermanager"
"seanime/internal/library/playbackmanager"
"seanime/internal/manga"
"seanime/internal/mediaplayers/iina"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/mediaplayers/mpchc"
"seanime/internal/mediaplayers/mpv"
"seanime/internal/mediaplayers/vlc"
"seanime/internal/mediastream"
"seanime/internal/nakama"
"seanime/internal/nativeplayer"
"seanime/internal/notifier"
"seanime/internal/plugin"
"seanime/internal/torrent_clients/qbittorrent"
"seanime/internal/torrent_clients/torrent_client"
"seanime/internal/torrent_clients/transmission"
"seanime/internal/torrents/torrent"
"seanime/internal/torrentstream"
"seanime/internal/user"
"github.com/cli/browser"
"github.com/rs/zerolog"
)
// initModulesOnce will initialize modules that need to persist.
// This function is called once after the App instance is created.
// The settings of these modules will be set/refreshed in InitOrRefreshModules.
func (a *App) initModulesOnce() {
a.LocalManager.SetRefreshAnilistCollectionsFunc(func() {
_, _ = a.RefreshAnimeCollection()
_, _ = a.RefreshMangaCollection()
})
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
OnRefreshAnilistAnimeCollection: func() {
_, _ = a.RefreshAnimeCollection()
},
OnRefreshAnilistMangaCollection: func() {
_, _ = a.RefreshMangaCollection()
},
})
// +---------------------+
// | Discord RPC |
// +---------------------+
a.DiscordPresence = discordrpc_presence.New(nil, a.Logger)
a.AddCleanupFunction(func() {
a.DiscordPresence.Close()
})
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
DiscordPresence: a.DiscordPresence,
})
// +---------------------+
// | Filler |
// +---------------------+
a.FillerManager = fillermanager.New(&fillermanager.NewFillerManagerOptions{
DB: a.Database,
Logger: a.Logger,
})
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
FillerManager: a.FillerManager,
})
// +---------------------+
// | Continuity |
// +---------------------+
a.ContinuityManager = continuity.NewManager(&continuity.NewManagerOptions{
FileCacher: a.FileCacher,
Logger: a.Logger,
Database: a.Database,
})
// +---------------------+
// | Playback Manager |
// +---------------------+
// Playback Manager
a.PlaybackManager = playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
Logger: a.Logger,
WSEventManager: a.WSEventManager,
Platform: a.AnilistPlatform,
MetadataProvider: a.MetadataProvider,
Database: a.Database,
DiscordPresence: a.DiscordPresence,
IsOffline: a.IsOffline(),
ContinuityManager: a.ContinuityManager,
RefreshAnimeCollectionFunc: func() {
_, _ = a.RefreshAnimeCollection()
},
})
// +---------------------+
// | Torrent Repository |
// +---------------------+
a.TorrentRepository = torrent.NewRepository(&torrent.NewRepositoryOptions{
Logger: a.Logger,
MetadataProvider: a.MetadataProvider,
})
// +---------------------+
// | Manga Downloader |
// +---------------------+
a.MangaDownloader = manga.NewDownloader(&manga.NewDownloaderOptions{
Database: a.Database,
Logger: a.Logger,
WSEventManager: a.WSEventManager,
DownloadDir: a.Config.Manga.DownloadDir,
Repository: a.MangaRepository,
IsOffline: a.IsOffline(),
})
a.MangaDownloader.Start()
// +---------------------+
// | Media Stream |
// +---------------------+
a.MediastreamRepository = mediastream.NewRepository(&mediastream.NewRepositoryOptions{
Logger: a.Logger,
WSEventManager: a.WSEventManager,
FileCacher: a.FileCacher,
})
a.AddCleanupFunction(func() {
a.MediastreamRepository.OnCleanup()
})
// +---------------------+
// | Native Player |
// +---------------------+
a.NativePlayer = nativeplayer.New(nativeplayer.NewNativePlayerOptions{
WsEventManager: a.WSEventManager,
Logger: a.Logger,
})
// +---------------------+
// | Direct Stream |
// +---------------------+
a.DirectStreamManager = directstream.NewManager(directstream.NewManagerOptions{
Logger: a.Logger,
WSEventManager: a.WSEventManager,
ContinuityManager: a.ContinuityManager,
MetadataProvider: a.MetadataProvider,
DiscordPresence: a.DiscordPresence,
Platform: a.AnilistPlatform,
RefreshAnimeCollectionFunc: func() {
_, _ = a.RefreshAnimeCollection()
},
IsOffline: a.IsOffline(),
NativePlayer: a.NativePlayer,
})
// +---------------------+
// | Torrent Stream |
// +---------------------+
a.TorrentstreamRepository = torrentstream.NewRepository(&torrentstream.NewRepositoryOptions{
Logger: a.Logger,
BaseAnimeCache: anilist.NewBaseAnimeCache(),
CompleteAnimeCache: anilist.NewCompleteAnimeCache(),
MetadataProvider: a.MetadataProvider,
TorrentRepository: a.TorrentRepository,
Platform: a.AnilistPlatform,
PlaybackManager: a.PlaybackManager,
WSEventManager: a.WSEventManager,
Database: a.Database,
DirectStreamManager: a.DirectStreamManager,
NativePlayer: a.NativePlayer,
})
// +---------------------+
// | Debrid Client Repo |
// +---------------------+
a.DebridClientRepository = debrid_client.NewRepository(&debrid_client.NewRepositoryOptions{
Logger: a.Logger,
WSEventManager: a.WSEventManager,
Database: a.Database,
MetadataProvider: a.MetadataProvider,
Platform: a.AnilistPlatform,
PlaybackManager: a.PlaybackManager,
TorrentRepository: a.TorrentRepository,
DirectStreamManager: a.DirectStreamManager,
})
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
PlaybackManager: a.PlaybackManager,
MangaRepository: a.MangaRepository,
})
// +---------------------+
// | Auto Downloader |
// +---------------------+
a.AutoDownloader = autodownloader.New(&autodownloader.NewAutoDownloaderOptions{
Logger: a.Logger,
TorrentClientRepository: a.TorrentClientRepository,
TorrentRepository: a.TorrentRepository,
Database: a.Database,
WSEventManager: a.WSEventManager,
MetadataProvider: a.MetadataProvider,
DebridClientRepository: a.DebridClientRepository,
IsOffline: a.IsOffline(),
})
// This is run in a goroutine
a.AutoDownloader.Start()
// +---------------------+
// | Auto Scanner |
// +---------------------+
a.AutoScanner = autoscanner.New(&autoscanner.NewAutoScannerOptions{
Database: a.Database,
Platform: a.AnilistPlatform,
Logger: a.Logger,
WSEventManager: a.WSEventManager,
Enabled: false, // Will be set in InitOrRefreshModules
AutoDownloader: a.AutoDownloader,
MetadataProvider: a.MetadataProvider,
LogsDir: a.Config.Logs.Dir,
})
// This is run in a goroutine
a.AutoScanner.Start()
// +---------------------+
// | Nakama |
// +---------------------+
a.NakamaManager = nakama.NewManager(&nakama.NewManagerOptions{
Logger: a.Logger,
WSEventManager: a.WSEventManager,
PlaybackManager: a.PlaybackManager,
TorrentstreamRepository: a.TorrentstreamRepository,
DebridClientRepository: a.DebridClientRepository,
Platform: a.AnilistPlatform,
ServerHost: a.Config.Server.Host,
ServerPort: a.Config.Server.Port,
})
}
// HandleNewDatabaseEntries initializes essential database collections.
// It creates an empty local files collection if one does not already exist.
func HandleNewDatabaseEntries(database *db.Database, logger *zerolog.Logger) {
// Create initial empty local files collection if none exists
if _, _, err := db_bridge.GetLocalFiles(database); err != nil {
_, err := db_bridge.InsertLocalFiles(database, make([]*anime.LocalFile, 0))
if err != nil {
logger.Fatal().Err(err).Msgf("app: Failed to initialize local files in the database")
}
}
}
// InitOrRefreshModules will initialize or refresh modules that depend on settings.
// This function is called:
// - After the App instance is created
// - After settings are updated.
func (a *App) InitOrRefreshModules() {
a.moduleMu.Lock()
defer a.moduleMu.Unlock()
a.Logger.Debug().Msgf("app: Refreshing modules")
// Stop watching if already watching
if a.Watcher != nil {
a.Watcher.StopWatching()
}
// If Discord presence is already initialized, close it
if a.DiscordPresence != nil {
a.DiscordPresence.Close()
}
// Get settings from database
settings, err := a.Database.GetSettings()
if err != nil || settings == nil {
a.Logger.Warn().Msg("app: Did not initialize modules, no settings found")
return
}
a.Settings = settings // Store settings instance in app
if settings.Library != nil {
a.LibraryDir = settings.GetLibrary().LibraryPath
}
// +---------------------+
// | Module settings |
// +---------------------+
// Refresh settings of modules that were initialized in initModulesOnce
notifier.GlobalNotifier.SetSettings(a.Config.Data.AppDataDir, a.Settings.GetNotifications(), a.Logger)
// Refresh updater settings
if settings.Library != nil {
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
AnimeLibraryPaths: a.Database.AllLibraryPathsFromSettings(settings),
})
if a.Updater != nil {
a.Updater.SetEnabled(!settings.Library.DisableUpdateCheck)
}
// Refresh auto scanner settings
if a.AutoScanner != nil {
a.AutoScanner.SetSettings(*settings.Library)
}
// Torrent Repository
a.TorrentRepository.SetSettings(&torrent.RepositorySettings{
DefaultAnimeProvider: settings.Library.TorrentProvider,
})
}
if settings.MediaPlayer != nil {
a.MediaPlayer.VLC = &vlc.VLC{
Host: settings.MediaPlayer.Host,
Port: settings.MediaPlayer.VlcPort,
Password: settings.MediaPlayer.VlcPassword,
Path: settings.MediaPlayer.VlcPath,
Logger: a.Logger,
}
a.MediaPlayer.MpcHc = &mpchc.MpcHc{
Host: settings.MediaPlayer.Host,
Port: settings.MediaPlayer.MpcPort,
Path: settings.MediaPlayer.MpcPath,
Logger: a.Logger,
}
a.MediaPlayer.Mpv = mpv.New(a.Logger, settings.MediaPlayer.MpvSocket, settings.MediaPlayer.MpvPath, settings.MediaPlayer.MpvArgs)
a.MediaPlayer.Iina = iina.New(a.Logger, settings.MediaPlayer.IinaSocket, settings.MediaPlayer.IinaPath, settings.MediaPlayer.IinaArgs)
// Set media player repository
a.MediaPlayerRepository = mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
Logger: a.Logger,
Default: settings.MediaPlayer.Default,
VLC: a.MediaPlayer.VLC,
MpcHc: a.MediaPlayer.MpcHc,
Mpv: a.MediaPlayer.Mpv, // Socket
Iina: a.MediaPlayer.Iina,
WSEventManager: a.WSEventManager,
ContinuityManager: a.ContinuityManager,
})
a.PlaybackManager.SetMediaPlayerRepository(a.MediaPlayerRepository)
a.PlaybackManager.SetSettings(&playbackmanager.Settings{
AutoPlayNextEpisode: a.Settings.GetLibrary().AutoPlayNextEpisode,
})
a.DirectStreamManager.SetSettings(&directstream.Settings{
AutoPlayNextEpisode: a.Settings.GetLibrary().AutoPlayNextEpisode,
AutoUpdateProgress: a.Settings.GetLibrary().AutoUpdateProgress,
})
a.TorrentstreamRepository.SetMediaPlayerRepository(a.MediaPlayerRepository)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
MediaPlayerRepository: a.MediaPlayerRepository,
})
} else {
a.Logger.Warn().Msg("app: Did not initialize media player module, no settings found")
}
// +---------------------+
// | Torrents |
// +---------------------+
if settings.Torrent != nil {
// Init qBittorrent
qbit := qbittorrent.NewClient(&qbittorrent.NewClientOptions{
Logger: a.Logger,
Username: settings.Torrent.QBittorrentUsername,
Password: settings.Torrent.QBittorrentPassword,
Port: settings.Torrent.QBittorrentPort,
Host: settings.Torrent.QBittorrentHost,
Path: settings.Torrent.QBittorrentPath,
Tags: settings.Torrent.QBittorrentTags,
})
// Login to qBittorrent
go func() {
if settings.Torrent.Default == "qbittorrent" {
err = qbit.Login()
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to login to qBittorrent")
} else {
a.Logger.Info().Msg("app: Logged in to qBittorrent")
}
}
}()
// Init Transmission
trans, err := transmission.New(&transmission.NewTransmissionOptions{
Logger: a.Logger,
Username: settings.Torrent.TransmissionUsername,
Password: settings.Torrent.TransmissionPassword,
Port: settings.Torrent.TransmissionPort,
Host: settings.Torrent.TransmissionHost,
Path: settings.Torrent.TransmissionPath,
})
if err != nil && settings.Torrent.TransmissionUsername != "" && settings.Torrent.TransmissionPassword != "" { // Only log error if username and password are set
a.Logger.Error().Err(err).Msg("app: Failed to initialize transmission client")
}
// Shutdown torrent client first
if a.TorrentClientRepository != nil {
a.TorrentClientRepository.Shutdown()
}
// Torrent Client Repository
a.TorrentClientRepository = torrent_client.NewRepository(&torrent_client.NewRepositoryOptions{
Logger: a.Logger,
QbittorrentClient: qbit,
Transmission: trans,
TorrentRepository: a.TorrentRepository,
Provider: settings.Torrent.Default,
MetadataProvider: a.MetadataProvider,
})
a.TorrentClientRepository.InitActiveTorrentCount(settings.Torrent.ShowActiveTorrentCount, a.WSEventManager)
// Set AutoDownloader qBittorrent client
a.AutoDownloader.SetTorrentClientRepository(a.TorrentClientRepository)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
TorrentClientRepository: a.TorrentClientRepository,
AutoDownloader: a.AutoDownloader,
})
} else {
a.Logger.Warn().Msg("app: Did not initialize torrent client module, no settings found")
}
// +---------------------+
// | AutoDownloader |
// +---------------------+
// Update Auto Downloader - This runs in a goroutine
if settings.AutoDownloader != nil {
a.AutoDownloader.SetSettings(settings.AutoDownloader, settings.Library.TorrentProvider)
}
// +---------------------+
// | Library Watcher |
// +---------------------+
// Initialize library watcher
if settings.Library != nil && len(settings.Library.LibraryPath) > 0 {
go func() {
a.initLibraryWatcher(settings.Library.GetLibraryPaths())
}()
}
// +---------------------+
// | Discord |
// +---------------------+
if settings.Discord != nil && a.DiscordPresence != nil {
a.DiscordPresence.SetSettings(settings.Discord)
}
// +---------------------+
// | Continuity |
// +---------------------+
if settings.Library != nil {
a.ContinuityManager.SetSettings(&continuity.Settings{
WatchContinuityEnabled: settings.Library.EnableWatchContinuity,
})
}
if settings.Manga != nil {
a.MangaRepository.SetSettings(settings)
}
// +---------------------+
// | Nakama |
// +---------------------+
if settings.Nakama != nil {
a.NakamaManager.SetSettings(settings.Nakama)
}
runtime.GC()
a.Logger.Info().Msg("app: Refreshed modules")
}
// InitOrRefreshMediastreamSettings will initialize or refresh the mediastream settings.
// It is called after the App instance is created and after settings are updated.
func (a *App) InitOrRefreshMediastreamSettings() {
var settings *models.MediastreamSettings
var found bool
settings, found = a.Database.GetMediastreamSettings()
if !found {
var err error
settings, err = a.Database.UpsertMediastreamSettings(&models.MediastreamSettings{
BaseModel: models.BaseModel{
ID: 1,
},
TranscodeEnabled: false,
TranscodeHwAccel: "cpu",
TranscodePreset: "fast",
PreTranscodeEnabled: false,
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to initialize mediastream module")
return
}
}
a.MediastreamRepository.InitializeModules(settings, a.Config.Cache.Dir, a.Config.Cache.TranscodeDir)
// Cleanup cache
go func() {
if settings.TranscodeEnabled {
// If transcoding is enabled, trim files
_ = a.FileCacher.TrimMediastreamVideoFiles()
} else {
// If transcoding is disabled, clear all files
_ = a.FileCacher.ClearMediastreamVideoFiles()
}
}()
a.SecondarySettings.Mediastream = settings
}
// InitOrRefreshTorrentstreamSettings will initialize or refresh the mediastream settings.
// It is called after the App instance is created and after settings are updated.
func (a *App) InitOrRefreshTorrentstreamSettings() {
var settings *models.TorrentstreamSettings
var found bool
settings, found = a.Database.GetTorrentstreamSettings()
if !found {
var err error
settings, err = a.Database.UpsertTorrentstreamSettings(&models.TorrentstreamSettings{
BaseModel: models.BaseModel{
ID: 1,
},
Enabled: false,
AutoSelect: true,
PreferredResolution: "",
DisableIPV6: false,
DownloadDir: "",
AddToLibrary: false,
TorrentClientHost: "",
TorrentClientPort: 43213,
StreamingServerHost: "0.0.0.0",
StreamingServerPort: 43214,
IncludeInLibrary: false,
StreamUrlAddress: "",
SlowSeeding: false,
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to initialize mediastream module")
return
}
}
err := a.TorrentstreamRepository.InitModules(settings, a.Config.Server.Host, a.Config.Server.Port)
if err != nil && settings.Enabled {
a.Logger.Error().Err(err).Msg("app: Failed to initialize Torrent streaming module")
//_, _ = a.Database.UpsertTorrentstreamSettings(&models.TorrentstreamSettings{
// BaseModel: models.BaseModel{
// ID: 1,
// },
// Enabled: false,
//})
}
a.Cleanups = append(a.Cleanups, func() {
a.TorrentstreamRepository.Shutdown()
})
// Set torrent streaming settings in secondary settings
// so the client can use them
a.SecondarySettings.Torrentstream = settings
}
func (a *App) InitOrRefreshDebridSettings() {
settings, found := a.Database.GetDebridSettings()
if !found {
var err error
settings, err = a.Database.UpsertDebridSettings(&models.DebridSettings{
BaseModel: models.BaseModel{
ID: 1,
},
Enabled: false,
Provider: "",
ApiKey: "",
IncludeDebridStreamInLibrary: false,
StreamAutoSelect: false,
StreamPreferredResolution: "",
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to initialize debrid module")
return
}
}
a.SecondarySettings.Debrid = settings
err := a.DebridClientRepository.InitializeProvider(settings)
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to initialize debrid provider")
return
}
}
// InitOrRefreshAnilistData will initialize the Anilist anime collection and the account.
// This function should be called after App.Database is initialized and after settings are updated.
func (a *App) InitOrRefreshAnilistData() {
a.Logger.Debug().Msg("app: Fetching Anilist data")
var currUser *user.User
acc, err := a.Database.GetAccount()
if err != nil || acc.Username == "" {
a.ServerReady = true
currUser = user.NewSimulatedUser() // Create a simulated user if no account is found
} else {
currUser, err = user.NewUser(acc)
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to create user from account")
return
}
}
a.user = currUser
// Set username to Anilist platform
a.AnilistPlatform.SetUsername(currUser.Viewer.Name)
a.Logger.Info().Msg("app: Authenticated to AniList")
go func() {
_, err = a.RefreshAnimeCollection()
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to fetch Anilist anime collection")
}
a.ServerReady = true
a.WSEventManager.SendEvent(events.ServerReady, nil)
_, err = a.RefreshMangaCollection()
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to fetch Anilist manga collection")
}
}()
go func(username string) {
a.DiscordPresence.SetUsername(username)
}(currUser.Viewer.Name)
a.Logger.Info().Msg("app: Fetched Anilist data")
}
func (a *App) performActionsOnce() {
go func() {
if a.Settings == nil || a.Settings.Library == nil {
return
}
if a.Settings.GetLibrary().OpenWebURLOnStart {
// Open the web URL
err := browser.OpenURL(a.Config.GetServerURI("127.0.0.1"))
if err != nil {
a.Logger.Warn().Err(err).Msg("app: Failed to open web URL, please open it manually in your browser")
} else {
a.Logger.Info().Msg("app: Opened web URL")
}
}
if a.Settings.GetLibrary().RefreshLibraryOnStart {
go func() {
a.Logger.Debug().Msg("app: Refreshing library")
a.AutoScanner.RunNow()
a.Logger.Info().Msg("app: Refreshed library")
}()
}
if a.Settings.GetLibrary().OpenTorrentClientOnStart && a.TorrentClientRepository != nil {
// Start the torrent client
ok := a.TorrentClientRepository.Start()
if !ok {
a.Logger.Warn().Msg("app: Failed to open torrent client")
} else {
a.Logger.Info().Msg("app: Started torrent client")
}
}
}()
}

View File

@@ -0,0 +1,39 @@
package core
import (
"seanime/internal/api/metadata"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/platforms/offline_platform"
"github.com/spf13/viper"
)
// SetOfflineMode changes the offline mode.
// It updates the config and active AniList platform.
func (a *App) SetOfflineMode(enabled bool) {
// Update the config
a.Config.Server.Offline = enabled
viper.Set("server.offline", enabled)
err := viper.WriteConfig()
if err != nil {
a.Logger.Err(err).Msg("app: Failed to write config after setting offline mode")
}
a.Logger.Info().Bool("enabled", enabled).Msg("app: Offline mode set")
a.isOffline = &enabled
// Update the platform and metadata provider
if enabled {
a.AnilistPlatform, _ = offline_platform.NewOfflinePlatform(a.LocalManager, a.AnilistClient, a.Logger)
a.MetadataProvider = a.LocalManager.GetOfflineMetadataProvider()
} else {
// DEVNOTE: We don't handle local platform since the feature doesn't allow offline mode
a.AnilistPlatform = anilist_platform.NewAnilistPlatform(a.AnilistClient, a.Logger)
a.MetadataProvider = metadata.NewProvider(&metadata.NewProviderImplOptions{
Logger: a.Logger,
FileCacher: a.FileCacher,
})
a.InitOrRefreshAnilistData()
}
a.InitOrRefreshModules()
}

View File

@@ -0,0 +1,122 @@
package core
import (
"fmt"
"os"
"seanime/internal/constants"
"strings"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
func PrintHeader() {
// Get terminal width
physicalWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
// Color scheme
// primary := lipgloss.Color("#7B61FF")
// secondary := lipgloss.Color("#5243CB")
// highlight := lipgloss.Color("#14F9D5")
// versionBgColor := lipgloss.Color("#8A2BE2")
subtle := lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
// Base styles
docStyle := lipgloss.NewStyle().Padding(1, 2)
if physicalWidth > 0 {
docStyle = docStyle.MaxWidth(physicalWidth)
}
// Build the header
doc := strings.Builder{}
// Logo with gradient effect
logoStyle := lipgloss.NewStyle().Bold(true)
logoLines := strings.Split(asciiLogo(), "\n")
// Create a gradient effect for the logo
gradientColors := []string{"#9370DB", "#8A2BE2", "#7B68EE", "#6A5ACD", "#5243CB"}
for i, line := range logoLines {
colorIdx := i % len(gradientColors)
coloredLine := logoStyle.Foreground(lipgloss.Color(gradientColors[colorIdx])).Render(line)
doc.WriteString(coloredLine + "\n")
}
// App name and version with box
titleBox := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(subtle).
Foreground(lipgloss.Color("#FFF7DB")).
// Background(secondary).
Padding(0, 1).
Bold(true).
Render("Seanime")
versionBox := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(subtle).
Foreground(lipgloss.Color("#ed4760")).
// Background(versionBgColor).
Padding(0, 1).
Bold(true).
Render(constants.Version)
// Version name with different style
versionName := lipgloss.NewStyle().
Italic(true).
Border(lipgloss.NormalBorder()).
BorderForeground(subtle).
Foreground(lipgloss.Color("#FFF7DB")).
// Background(versionBgColor).
Padding(0, 1).
Render(constants.VersionName)
// Combine title elements
titleRow := lipgloss.JoinHorizontal(lipgloss.Center, titleBox, versionBox, versionName)
// Add a decorative line
// lineWidth := min(80, physicalWidth-4)
// line := lipgloss.NewStyle().
// Foreground(subtle).
// Render(strings.Repeat("─", lineWidth))
// Put it all together
doc.WriteString("\n" +
lipgloss.NewStyle().Align(lipgloss.Center).Render(titleRow))
// Print the result
fmt.Println(docStyle.Render(doc.String()))
}
// func asciiLogo() string {
// return `⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⠀⠀⠀⢠⣾⣧⣤⡖⠀⠀⠀⠀⠀⠀⠀
// ⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⠋⠀⠉⠀⢄⣸⣿⣿⣿⣿⣿⣥⡤⢶⣿⣦⣀⡀
// ⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⡆⠀⠀⠀⣙⣛⣿⣿⣿⣿⡏⠀⠀⣀⣿⣿⣿⡟
// ⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⠷⣦⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣟⠛⠿⠋⠀
// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠋⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⡆⠀⠀
// ⠀⠀⠀⠀⣠⣶⣶⣶⣿⣦⡀⠘⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⠈⢹⡏⠁⠀⠀
// ⠀⠀⠀⢀⣿⡏⠉⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡆⠀⢀⣿⡇⠀⠀⠀
// ⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡘⣿⣿⣃⠀⠀⠀
// ⣴⣷⣀⣸⣿⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⠹⣿⣯⣤⣾⠏⠉⠉⠉⠙⠢⠀
// ⠈⠙⢿⣿⡟⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣄⠛⠉⢩⣷⣴⡆⠀⠀⠀⠀⠀
// ⠀⠀⠀⠋⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣀⡠⠋⠈⢿⣇⠀⠀⠀⠀⠀
// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⠿⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
// `
// }
func asciiLogo() string {
return `
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣴⡇⠀⠀⠀
⠀⢸⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣶⣿⣿⣿⣿⣿⡇⠀⠀⠀
⠀⠘⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⠀⠀
⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀
⠀⠀⠀⠘⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠉⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⡻⣿⣿⣿⠟⠋⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣿⣿⣿⣿⣿⡌⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⣠⣤⣴⣶⣶⣶⣦⣤⣤⣄⣉⡉⠛⠷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢀⣴⣾⣿⣿⣿⣿⡿⠿⠿⠿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀
⠉⠉⠀⠀⠉⠉⠀⠀ ⠉ ⠉⠉⠉⠉⠉⠉⠉⠛⠛⠛⠲⠦⠄`
}

View File

@@ -0,0 +1,59 @@
package core
import (
"seanime/internal/library/scanner"
"seanime/internal/util"
"sync"
)
// initLibraryWatcher will initialize the library watcher.
// - Used by AutoScanner
func (a *App) initLibraryWatcher(paths []string) {
// Create a new watcher
watcher, err := scanner.NewWatcher(&scanner.NewWatcherOptions{
Logger: a.Logger,
WSEventManager: a.WSEventManager,
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to initialize watcher")
return
}
// Initialize library file watcher
err = watcher.InitLibraryFileWatcher(&scanner.WatchLibraryFilesOptions{
LibraryPaths: paths,
})
if err != nil {
a.Logger.Error().Err(err).Msg("app: Failed to watch library files")
return
}
var dirSize uint64 = 0
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, path := range paths {
wg.Add(1)
go func(path string) {
defer wg.Done()
ds, _ := util.DirSize(path)
mu.Lock()
dirSize += ds
mu.Unlock()
}(path)
}
wg.Wait()
a.TotalLibrarySize = dirSize
a.Logger.Info().Msgf("watcher: Library size: %s", util.Bytes(dirSize))
// Set the watcher
a.Watcher = watcher
// Start watching
a.Watcher.StartWatching(
func() {
// Notify the auto scanner when a file action occurs
a.AutoScanner.Notify()
})
}