package manager import ( "errors" "fmt" "os" "path/filepath" "runtime/pprof" "sync" "time" "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/dlna" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/manager/paths" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/utils" ) type singleton struct { Config *config.Instance Paths *paths.Paths FFMPEGPath string FFProbePath string SessionStore *session.Store JobManager *job.Manager PluginCache *plugin.Cache ScraperCache *scraper.Cache DownloadStore *DownloadStore DLNAService *dlna.Service TxnManager models.TransactionManager scanSubs *subscriptionManager } var instance *singleton var once sync.Once func GetInstance() *singleton { Initialize() return instance } func Initialize() *singleton { once.Do(func() { cfg, err := config.Initialize() if err != nil { panic(fmt.Sprintf("error initializing configuration: %s", err.Error())) } initLog() initProfiling(cfg.GetCPUProfilePath()) instance = &singleton{ Config: cfg, JobManager: job.NewManager(), DownloadStore: NewDownloadStore(), PluginCache: plugin.NewCache(cfg), TxnManager: sqlite.NewTransactionManager(), scanSubs: &subscriptionManager{}, } sceneServer := SceneServer{ TXNManager: instance.TxnManager, } instance.DLNAService = dlna.NewService(instance.TxnManager, instance.Config, &sceneServer) if !cfg.IsNewSystem() { logger.Infof("using config file: %s", cfg.GetConfigFile()) if err == nil { err = cfg.Validate() } if err != nil { panic(fmt.Sprintf("error initializing configuration: %s", err.Error())) } else { if err := instance.PostInit(); err != nil { panic(err) } } } else { cfgFile := cfg.GetConfigFile() if cfgFile != "" { cfgFile = cfgFile + " " } // create temporary session store - this will be re-initialised // after config is complete instance.SessionStore = session.NewStore(cfg) logger.Warnf("config file %snot found. Assuming new system...", cfgFile) } initFFMPEG() // if DLNA is enabled, start it now if instance.Config.GetDLNADefaultEnabled() { instance.DLNAService.Start(nil) } }) return instance } func initProfiling(cpuProfilePath string) { if cpuProfilePath == "" { return } f, err := os.Create(cpuProfilePath) if err != nil { logger.Fatalf("unable to create cpu profile file: %s", err.Error()) } logger.Infof("profiling to %s", cpuProfilePath) // StopCPUProfile is defer called in main pprof.StartCPUProfile(f) } func initFFMPEG() error { // only do this if we have a config file set if instance.Config.GetConfigFile() != "" { // use same directory as config path configDirectory := instance.Config.GetConfigPath() paths := []string{ configDirectory, paths.GetStashHomeDirectory(), } ffmpegPath, ffprobePath := ffmpeg.GetPaths(paths) if ffmpegPath == "" || ffprobePath == "" { logger.Infof("couldn't find FFMPEG, attempting to download it") if err := ffmpeg.Download(configDirectory); err != nil { msg := `Unable to locate / automatically download FFMPEG Check the readme for download links. The FFMPEG and FFProbe binaries should be placed in %s The error was: %s ` logger.Errorf(msg, configDirectory, err) return err } else { // After download get new paths for ffmpeg and ffprobe ffmpegPath, ffprobePath = ffmpeg.GetPaths(paths) } } instance.FFMPEGPath = ffmpegPath instance.FFProbePath = ffprobePath } return nil } func initLog() { config := config.GetInstance() logger.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel()) } // PostInit initialises the paths, caches and txnManager after the initial // configuration has been set. Should only be called if the configuration // is valid. func (s *singleton) PostInit() error { s.Config.SetInitialConfig() s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) s.RefreshConfig() s.SessionStore = session.NewStore(s.Config) s.PluginCache.RegisterSessionStore(s.SessionStore) if err := s.PluginCache.LoadPlugins(); err != nil { logger.Errorf("Error reading plugin configs: %s", err.Error()) } s.ScraperCache = instance.initScraperCache() // clear the downloads and tmp directories // #1021 - only clear these directories if the generated folder is non-empty if s.Config.GetGeneratedPath() != "" { const deleteTimeout = 1 * time.Second utils.Timeout(func() { utils.EmptyDir(instance.Paths.Generated.Downloads) utils.EmptyDir(instance.Paths.Generated.Tmp) }, deleteTimeout, func(done chan struct{}) { logger.Info("Please wait. Deleting temporary files...") // print <-done // and wait for deletion logger.Info("Temporary files deleted.") }) } if err := database.Initialize(s.Config.GetDatabasePath()); err != nil { return err } if database.Ready() == nil { s.PostMigrate() } return nil } // initScraperCache initializes a new scraper cache and returns it. func (s *singleton) initScraperCache() *scraper.Cache { ret, err := scraper.NewCache(config.GetInstance(), s.TxnManager) if err != nil { logger.Errorf("Error reading scraper configs: %s", err.Error()) } return ret } func (s *singleton) RefreshConfig() { s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) config := s.Config if config.Validate() == nil { utils.EnsureDir(s.Paths.Generated.Screenshots) utils.EnsureDir(s.Paths.Generated.Vtt) utils.EnsureDir(s.Paths.Generated.Markers) utils.EnsureDir(s.Paths.Generated.Transcodes) utils.EnsureDir(s.Paths.Generated.Downloads) } } // RefreshScraperCache refreshes the scraper cache. Call this when scraper // configuration changes. func (s *singleton) RefreshScraperCache() { s.ScraperCache = s.initScraperCache() } func setSetupDefaults(input *models.SetupInput) { if input.ConfigLocation == "" { input.ConfigLocation = filepath.Join(utils.GetHomeDirectory(), ".stash", "config.yml") } configDir := filepath.Dir(input.ConfigLocation) if input.GeneratedLocation == "" { input.GeneratedLocation = filepath.Join(configDir, "generated") } if input.DatabaseFile == "" { input.DatabaseFile = filepath.Join(configDir, "stash-go.sqlite") } } func (s *singleton) Setup(input models.SetupInput) error { setSetupDefaults(&input) // create the config directory if it does not exist configDir := filepath.Dir(input.ConfigLocation) if exists, _ := utils.DirExists(configDir); !exists { if err := os.Mkdir(configDir, 0755); err != nil { return fmt.Errorf("abc: %s", err.Error()) } } // create the generated directory if it does not exist if exists, _ := utils.DirExists(input.GeneratedLocation); !exists { if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil { return fmt.Errorf("error creating generated directory: %s", err.Error()) } } if err := utils.Touch(input.ConfigLocation); err != nil { return fmt.Errorf("error creating config file: %s", err.Error()) } s.Config.SetConfigFile(input.ConfigLocation) // set the configuration s.Config.Set(config.Generated, input.GeneratedLocation) s.Config.Set(config.Database, input.DatabaseFile) s.Config.Set(config.Stash, input.Stashes) if err := s.Config.Write(); err != nil { return fmt.Errorf("error writing configuration file: %s", err.Error()) } // initialise the database if err := s.PostInit(); err != nil { return fmt.Errorf("error initializing the database: %s", err.Error()) } s.Config.FinalizeSetup() initFFMPEG() return nil } func (s *singleton) validateFFMPEG() error { if s.FFMPEGPath == "" || s.FFProbePath == "" { return errors.New("missing ffmpeg and/or ffprobe") } return nil } func (s *singleton) Migrate(input models.MigrateInput) error { // always backup so that we can roll back to the previous version if // migration fails backupPath := input.BackupPath if backupPath == "" { backupPath = database.DatabaseBackupPath() } // perform database backup if err := database.Backup(database.DB, backupPath); err != nil { return fmt.Errorf("error backing up database: %s", err) } if err := database.RunMigrations(); err != nil { errStr := fmt.Sprintf("error performing migration: %s", err) // roll back to the backed up version restoreErr := database.RestoreFromBackup(backupPath) if restoreErr != nil { errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr) } else { errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr } return errors.New(errStr) } // perform post-migration operations s.PostMigrate() // if no backup path was provided, then delete the created backup if input.BackupPath == "" { if err := os.Remove(backupPath); err != nil { logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error()) } } return nil } func (s *singleton) GetSystemStatus() *models.SystemStatus { status := models.SystemStatusEnumOk dbSchema := int(database.Version()) dbPath := database.DatabasePath() appSchema := int(database.AppSchemaVersion()) configFile := s.Config.GetConfigFile() if s.Config.IsNewSystem() { status = models.SystemStatusEnumSetup } else if dbSchema < appSchema { status = models.SystemStatusEnumNeedsMigration } return &models.SystemStatus{ DatabaseSchema: &dbSchema, DatabasePath: &dbPath, AppSchema: appSchema, Status: status, ConfigPath: &configFile, } } // Shutdown gracefully stops the manager func (s *singleton) Shutdown() error { // TODO: Each part of the manager needs to gracefully stop at some point // for now, we just close the database. return database.Close() }