stash/internal/manager/manager.go

727 lines
19 KiB
Go

package manager
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime/pprof"
"strconv"
"strings"
"sync"
"time"
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/dlna"
"github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file"
file_image "github.com/stashapp/stash/pkg/file/image"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/sqlite"
"github.com/stashapp/stash/pkg/utils"
"github.com/stashapp/stash/ui"
// register custom migrations
_ "github.com/stashapp/stash/pkg/sqlite/migrations"
)
type SystemStatus struct {
DatabaseSchema *int `json:"databaseSchema"`
DatabasePath *string `json:"databasePath"`
ConfigPath *string `json:"configPath"`
AppSchema int `json:"appSchema"`
Status SystemStatusEnum `json:"status"`
}
type SystemStatusEnum string
const (
SystemStatusEnumSetup SystemStatusEnum = "SETUP"
SystemStatusEnumNeedsMigration SystemStatusEnum = "NEEDS_MIGRATION"
SystemStatusEnumOk SystemStatusEnum = "OK"
)
var AllSystemStatusEnum = []SystemStatusEnum{
SystemStatusEnumSetup,
SystemStatusEnumNeedsMigration,
SystemStatusEnumOk,
}
func (e SystemStatusEnum) IsValid() bool {
switch e {
case SystemStatusEnumSetup, SystemStatusEnumNeedsMigration, SystemStatusEnumOk:
return true
}
return false
}
func (e SystemStatusEnum) String() string {
return string(e)
}
func (e *SystemStatusEnum) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = SystemStatusEnum(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid SystemStatusEnum", str)
}
return nil
}
func (e SystemStatusEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type SetupInput struct {
// Empty to indicate $HOME/.stash/config.yml default
ConfigLocation string `json:"configLocation"`
Stashes []*config.StashConfigInput `json:"stashes"`
// Empty to indicate default
DatabaseFile string `json:"databaseFile"`
// Empty to indicate default
GeneratedLocation string `json:"generatedLocation"`
}
type Manager struct {
Config *config.Instance
Logger *log.Logger
Paths *paths.Paths
FFMPEG ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe
ReadLockManager *fsutil.ReadLockManager
SessionStore *session.Store
JobManager *job.Manager
PluginCache *plugin.Cache
ScraperCache *scraper.Cache
DownloadStore *DownloadStore
DLNAService *dlna.Service
Database *sqlite.Database
Repository Repository
SceneService SceneService
ImageService ImageService
GalleryService GalleryService
Scanner *file.Scanner
Cleaner *file.Cleaner
scanSubs *subscriptionManager
}
var instance *Manager
var once sync.Once
func GetInstance() *Manager {
if _, err := Initialize(); err != nil {
panic(err)
}
return instance
}
func Initialize() (*Manager, error) {
var err error
once.Do(func() {
err = initialize()
})
return instance, err
}
func initialize() error {
ctx := context.TODO()
cfg, err := config.Initialize()
if err != nil {
return fmt.Errorf("initializing configuration: %w", err)
}
l := initLog()
initProfiling(cfg.GetCPUProfilePath())
db := sqlite.NewDatabase()
instance = &Manager{
Config: cfg,
Logger: l,
ReadLockManager: fsutil.NewReadLockManager(),
DownloadStore: NewDownloadStore(),
PluginCache: plugin.NewCache(cfg),
Database: db,
Repository: sqliteRepository(db),
scanSubs: &subscriptionManager{},
}
instance.SceneService = &scene.Service{
File: db.File,
Repository: db.Scene,
MarkerDestroyer: instance.Repository.SceneMarker,
}
instance.ImageService = &image.Service{
File: db.File,
Repository: db.Image,
}
instance.GalleryService = &gallery.Service{
Repository: db.Gallery,
ImageFinder: db.Image,
ImageService: instance.ImageService,
File: db.File,
Folder: db.Folder,
}
instance.JobManager = initJobManager()
sceneServer := SceneServer{
TxnManager: instance.Repository,
SceneCoverGetter: instance.Repository.Scene,
}
instance.DLNAService = dlna.NewService(instance.Repository, dlna.Repository{
SceneFinder: instance.Repository.Scene,
FileFinder: instance.Repository.File,
StudioFinder: instance.Repository.Studio,
TagFinder: instance.Repository.Tag,
PerformerFinder: instance.Repository.Performer,
MovieFinder: instance.Repository.Movie,
}, instance.Config, &sceneServer)
if !cfg.IsNewSystem() {
logger.Infof("using config file: %s", cfg.GetConfigFile())
if err == nil {
err = cfg.Validate()
}
if err != nil {
return fmt.Errorf("error initializing configuration: %w", err)
}
if err := instance.PostInit(ctx); err != nil {
var migrationNeededErr *sqlite.MigrationNeededError
if errors.As(err, &migrationNeededErr) {
logger.Warn(err.Error())
} else {
return err
}
}
initSecurity(cfg)
} else {
cfgFile := cfg.GetConfigFile()
if 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)
}
if err = initFFMPEG(ctx); err != nil {
logger.Warnf("could not initialize FFMPEG subsystem: %v", err)
}
instance.Scanner = makeScanner(db, instance.PluginCache)
instance.Cleaner = makeCleaner(db, instance.PluginCache)
// if DLNA is enabled, start it now
if instance.Config.GetDLNADefaultEnabled() {
if err := instance.DLNAService.Start(nil); err != nil {
logger.Warnf("could not start DLNA service: %v", err)
}
}
return nil
}
func videoFileFilter(ctx context.Context, f file.File) bool {
return isVideo(f.Base().Basename)
}
func imageFileFilter(ctx context.Context, f file.File) bool {
return isImage(f.Base().Basename)
}
func galleryFileFilter(ctx context.Context, f file.File) bool {
return isZip(f.Base().Basename)
}
type coverGenerator struct {
}
func (g *coverGenerator) GenerateCover(ctx context.Context, scene *models.Scene, f *file.VideoFile) error {
gg := generate.Generator{
Encoder: instance.FFMPEG,
LockManager: instance.ReadLockManager,
ScenePaths: instance.Paths.Scene,
}
return gg.Screenshot(ctx, f.Path, scene.GetHash(instance.Config.GetVideoFileNamingAlgorithm()), f.Width, f.Duration, generate.ScreenshotOptions{})
}
func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner {
return &file.Scanner{
Repository: file.Repository{
Manager: db,
DatabaseProvider: db,
Store: db.File,
FolderStore: db.Folder,
},
FileDecorators: []file.Decorator{
&file.FilteredDecorator{
Decorator: &video.Decorator{
FFProbe: instance.FFProbe,
},
Filter: file.FilterFunc(videoFileFilter),
},
&file.FilteredDecorator{
Decorator: &file_image.Decorator{},
Filter: file.FilterFunc(imageFileFilter),
},
},
FingerprintCalculator: &fingerprintCalculator{instance.Config},
FS: &file.OsFS{},
}
}
func makeCleaner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Cleaner {
return &file.Cleaner{
FS: &file.OsFS{},
Repository: file.Repository{
Manager: db,
DatabaseProvider: db,
Store: db.File,
FolderStore: db.Folder,
},
Handlers: []file.CleanHandler{
&cleanHandler{},
},
}
}
func initJobManager() *job.Manager {
ret := job.NewManager()
// desktop notifications
ctx := context.Background()
c := ret.Subscribe(context.Background())
go func() {
for {
select {
case j := <-c.RemovedJob:
if instance.Config.GetNotificationsEnabled() {
cleanDesc := strings.TrimRight(j.Description, ".")
if j.StartTime == nil {
// Task was never started
return
}
timeElapsed := j.EndTime.Sub(*j.StartTime)
desktop.SendNotification("Task Finished", "Task \""+cleanDesc+"\" is finished in "+formatDuration(timeElapsed)+".")
}
case <-ctx.Done():
return
}
}
}()
return ret
}
func formatDuration(t time.Duration) string {
return fmt.Sprintf("%02.f:%02.f:%02.f", t.Hours(), t.Minutes(), t.Seconds())
}
func initSecurity(cfg *config.Instance) {
if err := session.CheckExternalAccessTripwire(cfg); err != nil {
session.LogExternalAccessError(*err)
}
}
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
if err = pprof.StartCPUProfile(f); err != nil {
logger.Warnf("could not start CPU profiling: %v", err)
}
}
func initFFMPEG(ctx context.Context) 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(ctx, 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.FFMPEG = ffmpeg.FFMpeg(ffmpegPath)
instance.FFProbe = ffmpeg.FFProbe(ffprobePath)
}
return nil
}
func initLog() *log.Logger {
config := config.GetInstance()
l := log.NewLogger()
l.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel())
logger.Logger = l
return l
}
// 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 *Manager) PostInit(ctx context.Context) error {
if err := s.Config.SetInitialConfig(); err != nil {
logger.Warnf("could not set initial configuration: %v", err)
}
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()
writeStashIcon()
// 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() {
if err := fsutil.EmptyDir(instance.Paths.Generated.Downloads); err != nil {
logger.Warnf("could not empty Downloads directory: %v", err)
}
if err := fsutil.EnsureDir(instance.Paths.Generated.Tmp); err != nil {
logger.Warnf("could not create Tmp directory: %v", err)
} else {
if err := fsutil.EmptyDir(instance.Paths.Generated.Tmp); err != nil {
logger.Warnf("could not empty Tmp directory: %v", err)
}
}
}, deleteTimeout, func(done chan struct{}) {
logger.Info("Please wait. Deleting temporary files...") // print
<-done // and wait for deletion
logger.Info("Temporary files deleted.")
})
}
database := s.Database
if err := database.Open(s.Config.GetDatabasePath()); err != nil {
return err
}
return nil
}
func writeStashIcon() {
p := FaviconProvider{
UIBox: ui.UIBox,
}
iconPath := filepath.Join(instance.Config.GetConfigPath(), "icon.png")
err := os.WriteFile(iconPath, p.GetFaviconPng(), 0644)
if err != nil {
logger.Errorf("Couldn't write icon file: %s", err.Error())
}
}
// initScraperCache initializes a new scraper cache and returns it.
func (s *Manager) initScraperCache() *scraper.Cache {
ret, err := scraper.NewCache(config.GetInstance(), s.Repository, scraper.Repository{
SceneFinder: s.Repository.Scene,
GalleryFinder: s.Repository.Gallery,
TagFinder: s.Repository.Tag,
PerformerFinder: s.Repository.Performer,
MovieFinder: s.Repository.Movie,
StudioFinder: s.Repository.Studio,
})
if err != nil {
logger.Errorf("Error reading scraper configs: %s", err.Error())
}
return ret
}
func (s *Manager) RefreshConfig() {
s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
config := s.Config
if config.Validate() == nil {
if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil {
logger.Warnf("could not create directory for Screenshots: %v", err)
}
if err := fsutil.EnsureDir(s.Paths.Generated.Vtt); err != nil {
logger.Warnf("could not create directory for VTT: %v", err)
}
if err := fsutil.EnsureDir(s.Paths.Generated.Markers); err != nil {
logger.Warnf("could not create directory for Markers: %v", err)
}
if err := fsutil.EnsureDir(s.Paths.Generated.Transcodes); err != nil {
logger.Warnf("could not create directory for Transcodes: %v", err)
}
if err := fsutil.EnsureDir(s.Paths.Generated.Downloads); err != nil {
logger.Warnf("could not create directory for Downloads: %v", err)
}
if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil {
logger.Warnf("could not create directory for Interactive Heatmaps: %v", err)
}
}
}
// RefreshScraperCache refreshes the scraper cache. Call this when scraper
// configuration changes.
func (s *Manager) RefreshScraperCache() {
s.ScraperCache = s.initScraperCache()
}
func setSetupDefaults(input *SetupInput) {
if input.ConfigLocation == "" {
input.ConfigLocation = filepath.Join(fsutil.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 *Manager) Setup(ctx context.Context, input SetupInput) error {
setSetupDefaults(&input)
c := s.Config
// create the config directory if it does not exist
// don't do anything if config is already set in the environment
if !config.FileEnvSet() {
configDir := filepath.Dir(input.ConfigLocation)
if exists, _ := fsutil.DirExists(configDir); !exists {
if err := os.Mkdir(configDir, 0755); err != nil {
return fmt.Errorf("error creating config directory: %v", err)
}
}
if err := fsutil.Touch(input.ConfigLocation); err != nil {
return fmt.Errorf("error creating config file: %v", err)
}
s.Config.SetConfigFile(input.ConfigLocation)
}
// create the generated directory if it does not exist
if !c.HasOverride(config.Generated) {
if exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists {
if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil {
return fmt.Errorf("error creating generated directory: %v", err)
}
}
s.Config.Set(config.Generated, input.GeneratedLocation)
}
// set the configuration
if !c.HasOverride(config.Database) {
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: %v", err)
}
// initialise the database
if err := s.PostInit(ctx); err != nil {
var migrationNeededErr *sqlite.MigrationNeededError
if errors.As(err, &migrationNeededErr) {
logger.Warn(err.Error())
} else {
return fmt.Errorf("error initializing the database: %v", err)
}
}
s.Config.FinalizeSetup()
if err := initFFMPEG(ctx); err != nil {
return fmt.Errorf("error initializing FFMPEG subsystem: %v", err)
}
instance.Scanner = makeScanner(instance.Database, instance.PluginCache)
return nil
}
func (s *Manager) validateFFMPEG() error {
if s.FFMPEG == "" || s.FFProbe == "" {
return errors.New("missing ffmpeg and/or ffprobe")
}
return nil
}
type MigrateInput struct {
BackupPath string `json:"backupPath"`
}
func (s *Manager) Migrate(ctx context.Context, input MigrateInput) error {
database := s.Database
// always backup so that we can roll back to the previous version if
// migration fails
backupPath := input.BackupPath
if backupPath == "" {
backupPath = database.DatabaseBackupPath(s.Config.GetBackupDirectoryPath())
} else {
// check if backup path is a filename or path
// filename goes into backup directory, path is kept as is
filename := filepath.Base(backupPath)
if backupPath == filename {
backupPath = filepath.Join(s.Config.GetBackupDirectoryPathOrDefault(), filename)
}
}
// perform database backup
if err := database.Backup(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)
}
// 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 *Manager) GetSystemStatus() *SystemStatus {
database := s.Database
status := SystemStatusEnumOk
dbSchema := int(database.Version())
dbPath := database.DatabasePath()
appSchema := int(database.AppSchemaVersion())
configFile := s.Config.GetConfigFile()
if s.Config.IsNewSystem() {
status = SystemStatusEnumSetup
} else if dbSchema < appSchema {
status = SystemStatusEnumNeedsMigration
}
return &SystemStatus{
DatabaseSchema: &dbSchema,
DatabasePath: &dbPath,
AppSchema: appSchema,
Status: status,
ConfigPath: &configFile,
}
}
// Shutdown gracefully stops the manager
func (s *Manager) Shutdown(code int) {
// stop any profiling at exit
pprof.StopCPUProfile()
// TODO: Each part of the manager needs to gracefully stop at some point
// for now, we just close the database.
err := s.Database.Close()
if err != nil {
logger.Errorf("Error closing database: %s", err)
if code == 0 {
os.Exit(1)
}
}
os.Exit(code)
}