diff --git a/Makefile b/Makefile index 18b886265..555f03d1f 100644 --- a/Makefile +++ b/Makefile @@ -9,9 +9,11 @@ endif ifdef IS_WIN_SHELL RM := del /s /q RMDIR := rmdir /s /q + NOOP := @@ else RM := rm -f RMDIR := rm -rf + NOOP := @: endif # set LDFLAGS environment variable to any extra ldflags required @@ -54,38 +56,36 @@ release: pre-ui generate ui build-release # for a static-pie release build: `make flags-static-pie flags-release stash` # for a static windows debug build: `make flags-static-windows stash` -# shell noop: prevents "nothing to be done" warnings -.PHONY: flags -flags: -ifdef IS_WIN_SHELL - @@ -else - @: -endif +# $(NOOP) prevents "nothing to be done" warnings .PHONY: flags-release -flags-release: flags +flags-release: + $(NOOP) $(eval LDFLAGS += -s -w) $(eval GO_BUILD_FLAGS += -trimpath) .PHONY: flags-pie -flags-pie: flags +flags-pie: + $(NOOP) $(eval GO_BUILD_FLAGS += -buildmode=pie) .PHONY: flags-static -flags-static: flags +flags-static: + $(NOOP) $(eval LDFLAGS += -extldflags=-static) $(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo netgo) .PHONY: flags-static-pie -flags-static-pie: flags +flags-static-pie: + $(NOOP) $(eval LDFLAGS += -extldflags=-static-pie) $(eval GO_BUILD_FLAGS += -buildmode=pie) $(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo netgo) # identical to flags-static-pie, but excluding netgo, which is not needed on windows .PHONY: flags-static-windows -flags-static-windows: flags +flags-static-windows: + $(NOOP) $(eval LDFLAGS += -extldflags=-static-pie) $(eval GO_BUILD_FLAGS += -buildmode=pie) $(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo) diff --git a/README.md b/README.md index 897a0c016..93b68ec0d 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ On Windows or macOS, running the app might present a security prompt since the b On Windows, bypass this by clicking "more info" and then the "run anyway" button. On macOS, Control+Click the app, click "Open", and then "Open" again. -#### FFMPEG -Stash requires ffmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager. +#### FFmpeg +Stash requires FFmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager. # Usage diff --git a/cmd/phasher/main.go b/cmd/phasher/main.go index 99a749192..059a1427d 100644 --- a/cmd/phasher/main.go +++ b/cmd/phasher/main.go @@ -2,7 +2,6 @@ package main import ( - "context" "fmt" "os" @@ -66,13 +65,13 @@ func main() { } if len(args) > 1 { - fmt.Fprintln(os.Stderr, "Files will be processed sequentially! Consier using GNU Parallel.") + fmt.Fprintln(os.Stderr, "Files will be processed sequentially! If required, use e.g. GNU Parallel to run concurrently.") fmt.Fprintf(os.Stderr, "Example: parallel %v ::: *.mp4\n", os.Args[0]) } ffmpegPath, ffprobePath := ffmpeg.GetPaths(nil) encoder := ffmpeg.NewEncoder(ffmpegPath) - encoder.InitHWSupport(context.TODO()) + // don't need to InitHWSupport, phashing doesn't use hw acceleration ffprobe := ffmpeg.FFProbe(ffprobePath) for _, item := range args { diff --git a/cmd/stash/main.go b/cmd/stash/main.go index 0fbdf2108..e37164171 100644 --- a/cmd/stash/main.go +++ b/cmd/stash/main.go @@ -2,67 +2,154 @@ package main import ( + "errors" "fmt" + "net/http" "os" "os/signal" + "runtime/debug" + "runtime/pprof" "syscall" + "github.com/spf13/pflag" + "github.com/stashapp/stash/internal/api" + "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/desktop" + "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/ui" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" ) +var exitCode = 0 + func main() { - defer recoverPanic() - - _, err := manager.Initialize() - if err != nil { - panic(err) - } - - go func() { - defer recoverPanic() - if err := api.Start(); err != nil { - handleError(err) - } else { - manager.GetInstance().Shutdown(0) + defer func() { + if exitCode != 0 { + os.Exit(exitCode) } }() - go handleSignals() - desktop.Start(manager.GetInstance(), &ui.FaviconProvider) + defer recoverPanic() - blockForever() + helpFlag := false + pflag.BoolVarP(&helpFlag, "help", "h", false, "show this help text and exit") + + versionFlag := false + pflag.BoolVarP(&versionFlag, "version", "v", false, "show version number and exit") + + cpuProfilePath := "" + pflag.StringVar(&cpuProfilePath, "cpuprofile", "", "write cpu profile to file") + + pflag.Parse() + + if helpFlag { + pflag.Usage() + return + } + + if versionFlag { + fmt.Println(build.VersionString()) + return + } + + cfg, err := config.Initialize() + if err != nil { + exitError(fmt.Errorf("config initialization error: %w", err)) + return + } + + l := initLog(cfg) + + if cpuProfilePath != "" { + if err := initProfiling(cpuProfilePath); err != nil { + exitError(err) + return + } + defer pprof.StopCPUProfile() + } + + mgr, err := manager.Initialize(cfg, l) + if err != nil { + exitError(fmt.Errorf("manager initialization error: %w", err)) + return + } + defer mgr.Shutdown() + + server, err := api.Initialize() + if err != nil { + exitError(fmt.Errorf("api initialization error: %w", err)) + return + } + defer server.Shutdown() + + exit := make(chan int) + + go func() { + err := server.Start() + if !errors.Is(err, http.ErrServerClosed) { + exitError(fmt.Errorf("http server error: %w", err)) + exit <- 1 + } + }() + + go handleSignals(exit) + desktop.Start(exit, &ui.FaviconProvider) + + exitCode = <-exit +} + +func initLog(cfg *config.Config) *log.Logger { + l := log.NewLogger() + l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel()) + logger.Logger = l + + return l +} + +func initProfiling(path string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("unable to create CPU profile file: %v", err) + } + + if err = pprof.StartCPUProfile(f); err != nil { + return fmt.Errorf("could not start CPU profiling: %v", err) + } + + logger.Infof("profiling to %s", path) + + return nil } func recoverPanic() { - if p := recover(); p != nil { - handleError(fmt.Errorf("Panic: %v", p)) + if err := recover(); err != nil { + exitCode = 1 + logger.Errorf("panic: %v\n%s", err, debug.Stack()) + if desktop.IsDesktop() { + desktop.FatalError(fmt.Errorf("Panic: %v", err)) + } } } -func handleError(err error) { +func exitError(err error) { + exitCode = 1 + logger.Error(err) if desktop.IsDesktop() { desktop.FatalError(err) - manager.GetInstance().Shutdown(0) - } else { - panic(err) } } -func handleSignals() { +func handleSignals(exit chan<- int) { // handle signals signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) <-signals - manager.GetInstance().Shutdown(0) -} - -func blockForever() { - select {} + exit <- 0 } diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 6ae835837..547f55edc 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -505,19 +505,10 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAIn c.Set(config.DLNAVideoSortOrder, input.VideoSortOrder) } - currentDLNAEnabled := c.GetDLNADefaultEnabled() - if input.Enabled != nil && *input.Enabled != currentDLNAEnabled { + refresh := false + if input.Enabled != nil { c.Set(config.DLNADefaultEnabled, *input.Enabled) - - // start/stop the DLNA service as needed - dlnaService := manager.GetInstance().DLNAService - if !*input.Enabled && dlnaService.IsRunning() { - dlnaService.Stop(nil) - } else if *input.Enabled && !dlnaService.IsRunning() { - if err := dlnaService.Start(nil); err != nil { - logger.Warnf("error starting DLNA service: %v", err) - } - } + refresh = true } if input.Interfaces != nil { @@ -528,6 +519,10 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAIn return makeConfigDLNAResult(), err } + if refresh { + manager.GetInstance().RefreshDLNA() + } + return makeConfigDLNAResult(), nil } diff --git a/internal/api/resolver_mutation_metadata.go b/internal/api/resolver_mutation_metadata.go index 46e28581d..f4342e41a 100644 --- a/internal/api/resolver_mutation_metadata.go +++ b/internal/api/resolver_mutation_metadata.go @@ -3,8 +3,6 @@ package api import ( "context" "fmt" - "os" - "path/filepath" "strconv" "sync" "time" @@ -12,7 +10,6 @@ import ( "github.com/stashapp/stash/internal/identify" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" - "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) @@ -110,31 +107,10 @@ func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatab // if download is true, then backup to temporary file and return a link download := input.Download != nil && *input.Download mgr := manager.GetInstance() - database := mgr.Database - var backupPath string - if download { - if err := fsutil.EnsureDir(mgr.Paths.Generated.Downloads); err != nil { - return nil, fmt.Errorf("could not create backup directory %v: %w", mgr.Paths.Generated.Downloads, err) - } - f, err := os.CreateTemp(mgr.Paths.Generated.Downloads, "backup*.sqlite") - if err != nil { - return nil, err - } - backupPath = f.Name() - f.Close() - } else { - backupDirectoryPath := mgr.Config.GetBackupDirectoryPathOrDefault() - if backupDirectoryPath != "" { - if err := fsutil.EnsureDir(backupDirectoryPath); err != nil { - return nil, fmt.Errorf("could not create backup directory %v: %w", backupDirectoryPath, err) - } - } - backupPath = database.DatabaseBackupPath(backupDirectoryPath) - } - - err := database.Backup(backupPath) + backupPath, backupName, err := mgr.BackupDatabase(download) if err != nil { + logger.Errorf("Error backing up database: %v", err) return nil, err } @@ -147,8 +123,7 @@ func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatab baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - fn := filepath.Base(database.DatabaseBackupPath("")) - ret := baseURL + "/downloads/" + downloadHash + "/" + fn + ret := baseURL + "/downloads/" + downloadHash + "/" + backupName return &ret, nil } else { logger.Infof("Successfully backed up database to: %s", backupPath) @@ -158,33 +133,11 @@ func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatab } func (r *mutationResolver) AnonymiseDatabase(ctx context.Context, input AnonymiseDatabaseInput) (*string, error) { - // if download is true, then backup to temporary file and return a link + // if download is true, then save to temporary file and return a link download := input.Download != nil && *input.Download mgr := manager.GetInstance() - database := mgr.Database - var outPath string - if download { - if err := fsutil.EnsureDir(mgr.Paths.Generated.Downloads); err != nil { - return nil, fmt.Errorf("could not create backup directory %v: %w", mgr.Paths.Generated.Downloads, err) - } - f, err := os.CreateTemp(mgr.Paths.Generated.Downloads, "anonymous*.sqlite") - if err != nil { - return nil, err - } - outPath = f.Name() - f.Close() - } else { - backupDirectoryPath := mgr.Config.GetBackupDirectoryPathOrDefault() - if backupDirectoryPath != "" { - if err := fsutil.EnsureDir(backupDirectoryPath); err != nil { - return nil, fmt.Errorf("could not create backup directory %v: %w", backupDirectoryPath, err) - } - } - outPath = database.AnonymousDatabasePath(backupDirectoryPath) - } - - err := database.Anonymise(outPath) + outPath, outName, err := mgr.AnonymiseDatabase(download) if err != nil { logger.Errorf("Error anonymising database: %v", err) return nil, err @@ -199,8 +152,7 @@ func (r *mutationResolver) AnonymiseDatabase(ctx context.Context, input Anonymis baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - fn := filepath.Base(database.DatabaseBackupPath("")) - ret := baseURL + "/downloads/" + downloadHash + "/" + fn + ret := baseURL + "/downloads/" + downloadHash + "/" + outName return &ret, nil } else { logger.Infof("Successfully anonymised database to: %s", outPath) diff --git a/internal/api/resolver_mutation_package.go b/internal/api/resolver_mutation_package.go index 477e74c32..8e36e6719 100644 --- a/internal/api/resolver_mutation_package.go +++ b/internal/api/resolver_mutation_package.go @@ -6,7 +6,6 @@ import ( "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/task" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -14,13 +13,9 @@ func refreshPackageType(typeArg PackageType) { mgr := manager.GetInstance() if typeArg == PackageTypePlugin { - if err := mgr.PluginCache.LoadPlugins(); err != nil { - logger.Errorf("Error reading plugin configs: %v", err) - } + mgr.RefreshPluginCache() } else if typeArg == PackageTypeScraper { - if err := mgr.ScraperCache.ReloadScrapers(); err != nil { - logger.Errorf("Error reading scraper configs: %v", err) - } + mgr.RefreshScraperCache() } } diff --git a/internal/api/resolver_mutation_plugin.go b/internal/api/resolver_mutation_plugin.go index dae12e0e6..8aa520c08 100644 --- a/internal/api/resolver_mutation_plugin.go +++ b/internal/api/resolver_mutation_plugin.go @@ -5,7 +5,6 @@ import ( "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/sliceutil" ) @@ -17,11 +16,7 @@ func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, t } func (r *mutationResolver) ReloadPlugins(ctx context.Context) (bool, error) { - err := manager.GetInstance().PluginCache.LoadPlugins() - if err != nil { - logger.Errorf("Error reading plugin configs: %v", err) - } - + manager.GetInstance().RefreshPluginCache() return true, nil } diff --git a/internal/api/resolver_mutation_scraper.go b/internal/api/resolver_mutation_scraper.go index a3b6ee8ab..30f998899 100644 --- a/internal/api/resolver_mutation_scraper.go +++ b/internal/api/resolver_mutation_scraper.go @@ -7,11 +7,6 @@ import ( ) func (r *mutationResolver) ReloadScrapers(ctx context.Context) (bool, error) { - err := manager.GetInstance().ScraperCache.ReloadScrapers() - - if err != nil { - return false, err - } - + manager.GetInstance().RefreshScraperCache() return true, nil } diff --git a/internal/api/routes_downloads.go b/internal/api/routes_downloads.go index 358ff1682..85d710384 100644 --- a/internal/api/routes_downloads.go +++ b/internal/api/routes_downloads.go @@ -11,10 +11,6 @@ import ( type downloadsRoutes struct{} -func getDownloadsRoutes() chi.Router { - return downloadsRoutes{}.Routes() -} - func (rs downloadsRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index b9f36850e..80c7880cf 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -31,14 +31,6 @@ type imageRoutes struct { fileGetter models.FileGetter } -func getImageRoutes(repo models.Repository) chi.Router { - return imageRoutes{ - routes: routes{txnManager: repo.TxnManager}, - imageFinder: repo.Image, - fileGetter: repo.File, - }.Routes() -} - func (rs imageRoutes) Routes() chi.Router { r := chi.NewRouter() @@ -76,7 +68,7 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { Preset: manager.GetInstance().Config.GetPreviewPreset().String(), } - encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG, manager.GetInstance().FFProbe, clipPreviewOptions) + encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMpeg, manager.GetInstance().FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for unsupported image format diff --git a/internal/api/routes_movie.go b/internal/api/routes_movie.go index 589beb8a1..cd4224681 100644 --- a/internal/api/routes_movie.go +++ b/internal/api/routes_movie.go @@ -25,13 +25,6 @@ type movieRoutes struct { movieFinder MovieFinder } -func getMovieRoutes(repo models.Repository) chi.Router { - return movieRoutes{ - routes: routes{txnManager: repo.TxnManager}, - movieFinder: repo.Movie, - }.Routes() -} - func (rs movieRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/routes_performer.go b/internal/api/routes_performer.go index e61a7b4c2..b27fdbd6c 100644 --- a/internal/api/routes_performer.go +++ b/internal/api/routes_performer.go @@ -23,13 +23,6 @@ type performerRoutes struct { performerFinder PerformerFinder } -func getPerformerRoutes(repo models.Repository) chi.Router { - return performerRoutes{ - routes: routes{txnManager: repo.TxnManager}, - performerFinder: repo.Performer, - }.Routes() -} - func (rs performerRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/routes_plugin.go b/internal/api/routes_plugin.go index a844552e9..108ecea03 100644 --- a/internal/api/routes_plugin.go +++ b/internal/api/routes_plugin.go @@ -15,12 +15,6 @@ type pluginRoutes struct { pluginCache *plugin.Cache } -func getPluginRoutes(pluginCache *plugin.Cache) chi.Router { - return pluginRoutes{ - pluginCache: pluginCache, - }.Routes() -} - func (rs pluginRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index febebffcb..95e7c9d44 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -51,17 +51,6 @@ type sceneRoutes struct { tagFinder SceneMarkerTagFinder } -func getSceneRoutes(repo models.Repository) chi.Router { - return sceneRoutes{ - routes: routes{txnManager: repo.TxnManager}, - sceneFinder: repo.Scene, - fileGetter: repo.File, - captionFinder: repo.File, - sceneMarkerFinder: repo.SceneMarker, - tagFinder: repo.Tag, - }.Routes() -} - func (rs sceneRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/routes_studio.go b/internal/api/routes_studio.go index 52b3617b4..c6a8f1bc1 100644 --- a/internal/api/routes_studio.go +++ b/internal/api/routes_studio.go @@ -24,13 +24,6 @@ type studioRoutes struct { studioFinder StudioFinder } -func getStudioRoutes(repo models.Repository) chi.Router { - return studioRoutes{ - routes: routes{txnManager: repo.TxnManager}, - studioFinder: repo.Studio, - }.Routes() -} - func (rs studioRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/routes_tag.go b/internal/api/routes_tag.go index 3661ebd26..1d31ed0bf 100644 --- a/internal/api/routes_tag.go +++ b/internal/api/routes_tag.go @@ -24,13 +24,6 @@ type tagRoutes struct { tagFinder TagFinder } -func getTagRoutes(repo models.Repository) chi.Router { - return tagRoutes{ - routes: routes{txnManager: repo.TxnManager}, - tagFinder: repo.Tag, - }.Routes() -} - func (rs tagRoutes) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/server.go b/internal/api/server.go index bd5bcc2c1..d563243e2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -46,25 +46,65 @@ const ( playgroundEndpoint = "/playground" ) -var uiBox = ui.UIBox -var loginUIBox = ui.LoginUIBox +type Server struct { + http.Server + displayAddress string -func Start() error { - c := config.GetInstance() + manager *manager.Manager +} - initCustomPerformerImages(c.GetCustomPerformerImageLocation()) +// Called at startup +func Initialize() (*Server, error) { + mgr := manager.GetInstance() + cfg := mgr.Config + + initCustomPerformerImages(cfg.GetCustomPerformerImageLocation()) + + displayHost := cfg.GetHost() + if displayHost == "0.0.0.0" { + displayHost = "localhost" + } + displayAddress := displayHost + ":" + strconv.Itoa(cfg.GetPort()) + + address := cfg.GetHost() + ":" + strconv.Itoa(cfg.GetPort()) + tlsConfig, err := makeTLSConfig(cfg) + if err != nil { + // assume we don't want to start with a broken TLS configuration + return nil, fmt.Errorf("error loading TLS config: %v", err) + } + + if tlsConfig != nil { + displayAddress = "https://" + displayAddress + "/" + } else { + displayAddress = "http://" + displayAddress + "/" + } r := chi.NewRouter() + server := &Server{ + Server: http.Server{ + Addr: address, + Handler: r, + TLSConfig: tlsConfig, + // disable http/2 support by default + // when http/2 is enabled, we are unable to hijack and close + // the connection/request. This is necessary to stop running + // streams when deleting a scene file. + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), + }, + displayAddress: displayAddress, + manager: mgr, + } + r.Use(middleware.Heartbeat("/healthz")) r.Use(cors.AllowAll().Handler) r.Use(authenticateHandler()) - visitedPluginHandler := manager.GetInstance().SessionStore.VisitedPluginHandler() + visitedPluginHandler := mgr.SessionStore.VisitedPluginHandler() r.Use(visitedPluginHandler) r.Use(middleware.Recoverer) - if c.GetLogAccess() { + if cfg.GetLogAccess() { httpLogger := httplog.NewLogger("Stash", httplog.Options{ Concise: true, }) @@ -83,7 +123,7 @@ func Start() error { return errors.New(message) } - repo := manager.GetInstance().Repository + repo := mgr.Repository dataloaders := loaders.Middleware{ Repository: repo, @@ -91,10 +131,10 @@ func Start() error { r.Use(dataloaders.Middleware) - pluginCache := manager.GetInstance().PluginCache - sceneService := manager.GetInstance().SceneService - imageService := manager.GetInstance().ImageService - galleryService := manager.GetInstance().GalleryService + pluginCache := mgr.PluginCache + sceneService := mgr.SceneService + imageService := mgr.ImageService + galleryService := mgr.GalleryService resolver := &Resolver{ repository: repo, sceneService: sceneService, @@ -117,7 +157,7 @@ func Start() error { gqlSrv.AddTransport(gqlTransport.GET{}) gqlSrv.AddTransport(gqlTransport.POST{}) gqlSrv.AddTransport(gqlTransport.MultipartForm{ - MaxUploadSize: c.GetMaxUploadSize(), + MaxUploadSize: cfg.GetMaxUploadSize(), }) gqlSrv.SetQueryCache(gqlLru.New(1000)) @@ -134,7 +174,7 @@ func Start() error { // chain the visited plugin handler // also requires the dataloader middleware gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc))) - manager.GetInstance().PluginCache.RegisterGQLHandler(gqlHandler) + pluginCache.RegisterGQLHandler(gqlHandler) r.HandleFunc(gqlEndpoint, gqlHandlerFunc) r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) { @@ -143,23 +183,23 @@ func Start() error { gqlPlayground.Handler("GraphQL playground", endpoint)(w, r) }) - r.Mount("/performer", getPerformerRoutes(repo)) - r.Mount("/scene", getSceneRoutes(repo)) - r.Mount("/image", getImageRoutes(repo)) - r.Mount("/studio", getStudioRoutes(repo)) - r.Mount("/movie", getMovieRoutes(repo)) - r.Mount("/tag", getTagRoutes(repo)) - r.Mount("/downloads", getDownloadsRoutes()) - r.Mount("/plugin", getPluginRoutes(pluginCache)) + r.Mount("/performer", server.getPerformerRoutes()) + r.Mount("/scene", server.getSceneRoutes()) + r.Mount("/image", server.getImageRoutes()) + r.Mount("/studio", server.getStudioRoutes()) + r.Mount("/movie", server.getMovieRoutes()) + r.Mount("/tag", server.getTagRoutes()) + r.Mount("/downloads", server.getDownloadsRoutes()) + r.Mount("/plugin", server.getPluginRoutes()) - r.HandleFunc("/css", cssHandler(c)) - r.HandleFunc("/javascript", javascriptHandler(c)) - r.HandleFunc("/customlocales", customLocalesHandler(c)) + r.HandleFunc("/css", cssHandler(cfg)) + r.HandleFunc("/javascript", javascriptHandler(cfg)) + r.HandleFunc("/customlocales", customLocalesHandler(cfg)) - staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS)) + staticLoginUI := statigz.FileServer(ui.LoginUIBox.(fs.ReadDirFS)) - r.Get(loginEndpoint, handleLogin(loginUIBox)) - r.Post(loginEndpoint, handleLoginPost(loginUIBox)) + r.Get(loginEndpoint, handleLogin()) + r.Post(loginEndpoint, handleLoginPost()) r.Get(logoutEndpoint, handleLogout()) r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint) @@ -168,13 +208,13 @@ func Start() error { }) // Serve static folders - customServedFolders := c.GetCustomServedFolders() + customServedFolders := cfg.GetCustomServedFolders() if customServedFolders != nil { r.Mount("/custom", getCustomRoutes(customServedFolders)) } - customUILocation := c.GetCustomUILocation() - staticUI := statigz.FileServer(uiBox.(fs.ReadDirFS)) + customUILocation := cfg.GetCustomUILocation() + staticUI := statigz.FileServer(ui.UIBox.(fs.ReadDirFS)) // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { @@ -190,8 +230,8 @@ func Start() error { } if ext == ".html" || ext == "" { - themeColor := c.GetThemeColor() - data, err := fs.ReadFile(uiBox, "index.html") + themeColor := cfg.GetThemeColor() + data, err := fs.ReadFile(ui.UIBox, "index.html") if err != nil { panic(err) } @@ -217,51 +257,91 @@ func Start() error { } }) - displayHost := c.GetHost() - if displayHost == "0.0.0.0" { - displayHost = "localhost" - } - displayAddress := displayHost + ":" + strconv.Itoa(c.GetPort()) - - address := c.GetHost() + ":" + strconv.Itoa(c.GetPort()) - tlsConfig, err := makeTLSConfig(c) - if err != nil { - // assume we don't want to start with a broken TLS configuration - panic(fmt.Errorf("error loading TLS config: %v", err)) - } - - server := &http.Server{ - Addr: address, - Handler: r, - TLSConfig: tlsConfig, - // disable http/2 support by default - // when http/2 is enabled, we are unable to hijack and close - // the connection/request. This is necessary to stop running - // streams when deleting a scene file. - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), - } - - logger.Infof("stash version: %s\n", build.VersionString()) + logger.Infof("stash version: %s", build.VersionString()) go printLatestVersion(context.TODO()) - logger.Infof("stash is listening on " + address) - if tlsConfig != nil { - displayAddress = "https://" + displayAddress + "/" + + return server, nil +} + +func (s *Server) Start() error { + logger.Infof("stash is listening on " + s.Addr) + logger.Infof("stash is running at " + s.displayAddress) + + if s.TLSConfig != nil { + return s.ListenAndServeTLS("", "") } else { - displayAddress = "http://" + displayAddress + "/" + return s.ListenAndServe() } +} - logger.Infof("stash is running at " + displayAddress) - if tlsConfig != nil { - err = server.ListenAndServeTLS("", "") - } else { - err = server.ListenAndServe() +func (s *Server) Shutdown() { + err := s.Server.Shutdown(context.TODO()) + if err != nil { + logger.Errorf("Error shutting down http server: %v", err) } +} - if !errors.Is(err, http.ErrServerClosed) { - return err - } +func (s *Server) getPerformerRoutes() chi.Router { + repo := s.manager.Repository + return performerRoutes{ + routes: routes{txnManager: repo.TxnManager}, + performerFinder: repo.Performer, + }.Routes() +} - return nil +func (s *Server) getSceneRoutes() chi.Router { + repo := s.manager.Repository + return sceneRoutes{ + routes: routes{txnManager: repo.TxnManager}, + sceneFinder: repo.Scene, + fileGetter: repo.File, + captionFinder: repo.File, + sceneMarkerFinder: repo.SceneMarker, + tagFinder: repo.Tag, + }.Routes() +} + +func (s *Server) getImageRoutes() chi.Router { + repo := s.manager.Repository + return imageRoutes{ + routes: routes{txnManager: repo.TxnManager}, + imageFinder: repo.Image, + fileGetter: repo.File, + }.Routes() +} + +func (s *Server) getStudioRoutes() chi.Router { + repo := s.manager.Repository + return studioRoutes{ + routes: routes{txnManager: repo.TxnManager}, + studioFinder: repo.Studio, + }.Routes() +} + +func (s *Server) getMovieRoutes() chi.Router { + repo := s.manager.Repository + return movieRoutes{ + routes: routes{txnManager: repo.TxnManager}, + movieFinder: repo.Movie, + }.Routes() +} + +func (s *Server) getTagRoutes() chi.Router { + repo := s.manager.Repository + return tagRoutes{ + routes: routes{txnManager: repo.TxnManager}, + tagFinder: repo.Tag, + }.Routes() +} + +func (s *Server) getDownloadsRoutes() chi.Router { + return downloadsRoutes{}.Routes() +} + +func (s *Server) getPluginRoutes() chi.Router { + return pluginRoutes{ + pluginCache: s.manager.PluginCache, + }.Routes() } func copyFile(w io.Writer, path string) error { @@ -290,7 +370,7 @@ func serveFiles(w http.ResponseWriter, r *http.Request, paths []string) { utils.ServeStaticContent(w, r, buffer.Bytes()) } -func cssHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) { +func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var paths []string @@ -308,7 +388,7 @@ func cssHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) } } -func javascriptHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) { +func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var paths []string @@ -326,7 +406,7 @@ func javascriptHandler(c *config.Instance) func(w http.ResponseWriter, r *http.R } } -func customLocalesHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) { +func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { buffer := bytes.Buffer{} @@ -351,7 +431,7 @@ func customLocalesHandler(c *config.Instance) func(w http.ResponseWriter, r *htt } } -func makeTLSConfig(c *config.Instance) (*tls.Config, error) { +func makeTLSConfig(c *config.Config) (*tls.Config, error) { c.InitTLS() certFile, keyFile := c.GetTLSFiles() diff --git a/internal/api/session.go b/internal/api/session.go index ac5b62b51..af31faab5 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -14,12 +14,13 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/utils" + "github.com/stashapp/stash/ui" ) const returnURLParam = "returnURL" -func getLoginPage(loginUIBox fs.FS) []byte { - data, err := fs.ReadFile(loginUIBox, "login.html") +func getLoginPage() []byte { + data, err := fs.ReadFile(ui.LoginUIBox, "login.html") if err != nil { panic(err) } @@ -31,8 +32,8 @@ type loginTemplateData struct { Error string } -func serveLoginPage(loginUIBox fs.FS, w http.ResponseWriter, r *http.Request, returnURL string, loginError string) { - loginPage := string(getLoginPage(loginUIBox)) +func serveLoginPage(w http.ResponseWriter, r *http.Request, returnURL string, loginError string) { + loginPage := string(getLoginPage()) prefix := getProxyPrefix(r) loginPage = strings.ReplaceAll(loginPage, "/%BASE_URL%", prefix) @@ -57,7 +58,7 @@ func serveLoginPage(loginUIBox fs.FS, w http.ResponseWriter, r *http.Request, re utils.ServeStaticContent(w, r, buffer.Bytes()) } -func handleLogin(loginUIBox fs.FS) http.HandlerFunc { +func handleLogin() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { returnURL := r.URL.Query().Get(returnURLParam) @@ -71,11 +72,11 @@ func handleLogin(loginUIBox fs.FS) http.HandlerFunc { return } - serveLoginPage(loginUIBox, w, r, returnURL, "") + serveLoginPage(w, r, returnURL, "") } } -func handleLoginPost(loginUIBox fs.FS) http.HandlerFunc { +func handleLoginPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { url := r.FormValue(returnURLParam) if url == "" { @@ -92,7 +93,7 @@ func handleLoginPost(loginUIBox fs.FS) http.HandlerFunc { if errors.As(err, &invalidCredentialsError) { // serve login page with an error - serveLoginPage(loginUIBox, w, r, url, "Username or password is invalid") + serveLoginPage(w, r, url, "Username or password is invalid") return } diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index ce5bf7b21..e74cb30aa 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "testing" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/txn" @@ -77,6 +78,9 @@ func runTests(m *testing.M) int { } func TestMain(m *testing.M) { + // initialise empty config - needed by some db migrations + _ = config.InitializeEmpty() + ret := runTests(m) os.Exit(ret) } diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index 1e69a6c76..52823b403 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -16,10 +16,6 @@ import ( "golang.org/x/term" ) -type ShutdownHandler interface { - Shutdown(code int) -} - type FaviconProvider interface { GetFavicon() []byte GetFaviconPng() []byte @@ -27,7 +23,7 @@ type FaviconProvider interface { // Start starts the desktop icon process. It blocks until the process exits. // MUST be run on the main goroutine or will have no effect on macOS -func Start(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { +func Start(exit chan<- int, faviconProvider FaviconProvider) { if IsDesktop() { hideConsole() @@ -36,7 +32,7 @@ func Start(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { openURLInBrowser("") } writeStashIcon(faviconProvider) - startSystray(shutdownHandler, faviconProvider) + startSystray(exit, faviconProvider) } } diff --git a/internal/desktop/systray_nixes.go b/internal/desktop/systray_nixes.go index 1252b6759..c6fbec890 100644 --- a/internal/desktop/systray_nixes.go +++ b/internal/desktop/systray_nixes.go @@ -2,7 +2,7 @@ package desktop -func startSystray(shutdownHandler ShutdownHandler, favicon FaviconProvider) { +func startSystray(exit chan<- int, favicon FaviconProvider) { // The systray is not available on Linux because the required libraries (libappindicator3 and gtk+3.0) // are not able to be statically compiled. Technically, the systray works perfectly fine when dynamically // linked, but we cannot distribute it for compatibility reasons. diff --git a/internal/desktop/systray_nonlinux.go b/internal/desktop/systray_nonlinux.go index f454b5cbe..4ce6aea51 100644 --- a/internal/desktop/systray_nonlinux.go +++ b/internal/desktop/systray_nonlinux.go @@ -14,7 +14,7 @@ import ( ) // MUST be run on the main goroutine or will have no effect on macOS -func startSystray(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { +func startSystray(exit chan<- int, faviconProvider FaviconProvider) { // Shows a small notification to inform that Stash will no longer show a terminal window, // and instead will be available in the tray. Will only show the first time a pre-desktop integration // system is started from a non-terminal method, e.g. double-clicking an icon. @@ -39,12 +39,12 @@ func startSystray(shutdownHandler ShutdownHandler, faviconProvider FaviconProvid for { systray.Run(func() { - systrayInitialize(shutdownHandler, faviconProvider) + systrayInitialize(exit, faviconProvider) }, nil) } } -func systrayInitialize(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { +func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) { favicon := faviconProvider.GetFavicon() systray.SetTemplateIcon(favicon, favicon) systray.SetTooltip("🟢 Stash is Running.") @@ -86,7 +86,7 @@ func systrayInitialize(shutdownHandler ShutdownHandler, faviconProvider FaviconP openURLInBrowser("") case <-quitStashButton.ClickedCh: systray.Quit() - shutdownHandler.Shutdown(0) + exit <- 0 } } }() diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 850548efc..f7c11e29d 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -282,7 +282,7 @@ func (s *StashBoxError) Error() string { return "Stash-box: " + s.msg } -type Instance struct { +type Config struct { // main instance - backed by config file main *viper.Viper @@ -290,8 +290,7 @@ type Instance struct { // not written to config file overrides *viper.Viper - cpuProfilePath string - isNewSystem bool + isNewSystem bool // configUpdates chan int certFile string keyFile string @@ -299,19 +298,26 @@ type Instance struct { // deadlock.RWMutex // for deadlock testing/issues } -var instance *Instance +var instance *Config -func (i *Instance) IsNewSystem() bool { +func GetInstance() *Config { + if instance == nil { + panic("config not initialized") + } + return instance +} + +func (i *Config) IsNewSystem() bool { return i.isNewSystem } -func (i *Instance) SetConfigFile(fn string) { +func (i *Config) SetConfigFile(fn string) { i.Lock() defer i.Unlock() i.main.SetConfigFile(fn) } -func (i *Instance) InitTLS() { +func (i *Config) InitTLS() { configDirectory := i.GetConfigPath() tlsPaths := []string{ configDirectory, @@ -322,27 +328,20 @@ func (i *Instance) InitTLS() { i.keyFile = fsutil.FindInPaths(tlsPaths, "stash.key") } -func (i *Instance) GetTLSFiles() (certFile, keyFile string) { +func (i *Config) GetTLSFiles() (certFile, keyFile string) { return i.certFile, i.keyFile } -func (i *Instance) HasTLSConfig() bool { +func (i *Config) HasTLSConfig() bool { certFile, keyFile := i.GetTLSFiles() return certFile != "" && keyFile != "" } -// GetCPUProfilePath returns the path to the CPU profile file to output -// profiling info to. This is set only via a commandline flag. Returns an -// empty string if not set. -func (i *Instance) GetCPUProfilePath() string { - return i.cpuProfilePath -} - -func (i *Instance) GetNoBrowser() bool { +func (i *Config) GetNoBrowser() bool { return i.getBool(NoBrowser) } -func (i *Instance) GetNotificationsEnabled() bool { +func (i *Config) GetNotificationsEnabled() bool { return i.getBool(NotificationsEnabled) } @@ -353,11 +352,11 @@ func (i *Instance) GetNotificationsEnabled() bool { // GetShowOneTimeMovedNotification shows whether a small notification to inform the user that Stash // will no longer show a terminal window, and instead will be available in the tray, should be shown. // It is true when an existing system is started after upgrading, and set to false forever after it is shown. -func (i *Instance) GetShowOneTimeMovedNotification() bool { +func (i *Config) GetShowOneTimeMovedNotification() bool { return i.getBool(ShowOneTimeMovedNotification) } -func (i *Instance) Set(key string, value interface{}) { +func (i *Config) Set(key string, value interface{}) { // if key == MenuItems { // i.configUpdates <- 0 // } @@ -366,13 +365,13 @@ func (i *Instance) Set(key string, value interface{}) { i.main.Set(key, value) } -func (i *Instance) SetDefault(key string, value interface{}) { +func (i *Config) SetDefault(key string, value interface{}) { i.Lock() defer i.Unlock() i.main.SetDefault(key, value) } -func (i *Instance) SetPassword(value string) { +func (i *Config) SetPassword(value string) { // if blank, don't bother hashing; we want it to be blank if value == "" { i.Set(Password, "") @@ -381,7 +380,7 @@ func (i *Instance) SetPassword(value string) { } } -func (i *Instance) Write() error { +func (i *Config) Write() error { i.Lock() defer i.Unlock() return i.main.WriteConfig() @@ -394,7 +393,7 @@ func FileEnvSet() bool { } // GetConfigFile returns the full path to the used configuration file. -func (i *Instance) GetConfigFile() string { +func (i *Config) GetConfigFile() string { i.RLock() defer i.RUnlock() return i.main.ConfigFileUsed() @@ -402,20 +401,20 @@ func (i *Instance) GetConfigFile() string { // GetConfigPath returns the path of the directory containing the used // configuration file. -func (i *Instance) GetConfigPath() string { +func (i *Config) GetConfigPath() string { return filepath.Dir(i.GetConfigFile()) } // GetDefaultDatabaseFilePath returns the default database filename, // which is located in the same directory as the config file. -func (i *Instance) GetDefaultDatabaseFilePath() string { +func (i *Config) GetDefaultDatabaseFilePath() string { return filepath.Join(i.GetConfigPath(), "stash-go.sqlite") } // viper returns the viper instance that should be used to get the provided // key. Returns the overrides instance if the key exists there, otherwise it // returns the main instance. Assumes read lock held. -func (i *Instance) viper(key string) *viper.Viper { +func (i *Config) viper(key string) *viper.Viper { v := i.main if i.overrides.IsSet(key) { v = i.overrides @@ -426,7 +425,7 @@ func (i *Instance) viper(key string) *viper.Viper { // viper returns the viper instance that has the key set. Returns nil // if no instance has the key. Assumes read lock held. -func (i *Instance) viperWith(key string) *viper.Viper { +func (i *Config) viperWith(key string) *viper.Viper { v := i.viper(key) if v.IsSet(key) { @@ -436,7 +435,7 @@ func (i *Instance) viperWith(key string) *viper.Viper { return nil } -func (i *Instance) HasOverride(key string) bool { +func (i *Config) HasOverride(key string) bool { i.RLock() defer i.RUnlock() @@ -446,35 +445,35 @@ func (i *Instance) HasOverride(key string) bool { // These functions wrap the equivalent viper functions, checking the override // instance first, then the main instance. -func (i *Instance) unmarshalKey(key string, rawVal interface{}) error { +func (i *Config) unmarshalKey(key string, rawVal interface{}) error { i.RLock() defer i.RUnlock() return i.viper(key).UnmarshalKey(key, rawVal) } -func (i *Instance) getStringSlice(key string) []string { +func (i *Config) getStringSlice(key string) []string { i.RLock() defer i.RUnlock() return i.viper(key).GetStringSlice(key) } -func (i *Instance) getString(key string) string { +func (i *Config) getString(key string) string { i.RLock() defer i.RUnlock() return i.viper(key).GetString(key) } -func (i *Instance) getBool(key string) bool { +func (i *Config) getBool(key string) bool { i.RLock() defer i.RUnlock() return i.viper(key).GetBool(key) } -func (i *Instance) getBoolDefault(key string, def bool) bool { +func (i *Config) getBoolDefault(key string, def bool) bool { i.RLock() defer i.RUnlock() @@ -486,21 +485,21 @@ func (i *Instance) getBoolDefault(key string, def bool) bool { return ret } -func (i *Instance) getInt(key string) int { +func (i *Config) getInt(key string) int { i.RLock() defer i.RUnlock() return i.viper(key).GetInt(key) } -func (i *Instance) getFloat64(key string) float64 { +func (i *Config) getFloat64(key string) float64 { i.RLock() defer i.RUnlock() return i.viper(key).GetFloat64(key) } -func (i *Instance) getStringMapString(key string) map[string]string { +func (i *Config) getStringMapString(key string) map[string]string { i.RLock() defer i.RUnlock() @@ -518,7 +517,7 @@ func (i *Instance) getStringMapString(key string) map[string]string { // GetStathPaths returns the configured stash library paths. // Works opposite to the usual case - it will return the override // value only if the main value is not set. -func (i *Instance) GetStashPaths() StashConfigs { +func (i *Config) GetStashPaths() StashConfigs { i.RLock() defer i.RUnlock() @@ -544,19 +543,19 @@ func (i *Instance) GetStashPaths() StashConfigs { return ret } -func (i *Instance) GetCachePath() string { +func (i *Config) GetCachePath() string { return i.getString(Cache) } -func (i *Instance) GetGeneratedPath() string { +func (i *Config) GetGeneratedPath() string { return i.getString(Generated) } -func (i *Instance) GetBlobsPath() string { +func (i *Config) GetBlobsPath() string { return i.getString(BlobsPath) } -func (i *Instance) GetBlobsStorage() BlobsStorageType { +func (i *Config) GetBlobsStorage() BlobsStorageType { ret := BlobsStorageType(i.getString(BlobsStorage)) if !ret.IsValid() { @@ -568,19 +567,19 @@ func (i *Instance) GetBlobsStorage() BlobsStorageType { return ret } -func (i *Instance) GetMetadataPath() string { +func (i *Config) GetMetadataPath() string { return i.getString(Metadata) } -func (i *Instance) GetDatabasePath() string { +func (i *Config) GetDatabasePath() string { return i.getString(Database) } -func (i *Instance) GetBackupDirectoryPath() string { +func (i *Config) GetBackupDirectoryPath() string { return i.getString(BackupDirectoryPath) } -func (i *Instance) GetBackupDirectoryPathOrDefault() string { +func (i *Config) GetBackupDirectoryPathOrDefault() string { ret := i.GetBackupDirectoryPath() if ret == "" { return i.GetConfigPath() @@ -589,30 +588,30 @@ func (i *Instance) GetBackupDirectoryPathOrDefault() string { return ret } -func (i *Instance) GetJWTSignKey() []byte { +func (i *Config) GetJWTSignKey() []byte { return []byte(i.getString(JWTSignKey)) } -func (i *Instance) GetSessionStoreKey() []byte { +func (i *Config) GetSessionStoreKey() []byte { return []byte(i.getString(SessionStoreKey)) } -func (i *Instance) GetDefaultScrapersPath() string { +func (i *Config) GetDefaultScrapersPath() string { // default to the same directory as the config file fn := filepath.Join(i.GetConfigPath(), "scrapers") return fn } -func (i *Instance) GetExcludes() []string { +func (i *Config) GetExcludes() []string { return i.getStringSlice(Exclude) } -func (i *Instance) GetImageExcludes() []string { +func (i *Config) GetImageExcludes() []string { return i.getStringSlice(ImageExclude) } -func (i *Instance) GetVideoExtensions() []string { +func (i *Config) GetVideoExtensions() []string { ret := i.getStringSlice(VideoExtensions) if ret == nil { ret = defaultVideoExtensions @@ -620,7 +619,7 @@ func (i *Instance) GetVideoExtensions() []string { return ret } -func (i *Instance) GetImageExtensions() []string { +func (i *Config) GetImageExtensions() []string { ret := i.getStringSlice(ImageExtensions) if ret == nil { ret = defaultImageExtensions @@ -628,7 +627,7 @@ func (i *Instance) GetImageExtensions() []string { return ret } -func (i *Instance) GetGalleryExtensions() []string { +func (i *Config) GetGalleryExtensions() []string { ret := i.getStringSlice(GalleryExtensions) if ret == nil { ret = defaultGalleryExtensions @@ -636,11 +635,11 @@ func (i *Instance) GetGalleryExtensions() []string { return ret } -func (i *Instance) GetCreateGalleriesFromFolders() bool { +func (i *Config) GetCreateGalleriesFromFolders() bool { return i.getBool(CreateGalleriesFromFolders) } -func (i *Instance) GetLanguage() string { +func (i *Config) GetLanguage() string { ret := i.getString(Language) // default to English @@ -653,13 +652,13 @@ func (i *Instance) GetLanguage() string { // IsCalculateMD5 returns true if MD5 checksums should be generated for // scene video files. -func (i *Instance) IsCalculateMD5() bool { +func (i *Config) IsCalculateMD5() bool { return i.getBool(CalculateMD5) } // GetVideoFileNamingAlgorithm returns what hash algorithm should be used for // naming generated scene video files. -func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm { +func (i *Config) GetVideoFileNamingAlgorithm() models.HashAlgorithm { ret := i.getString(VideoFileNamingAlgorithm) // default to oshash @@ -670,11 +669,11 @@ func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm { return models.HashAlgorithm(ret) } -func (i *Instance) GetSequentialScanning() bool { +func (i *Config) GetSequentialScanning() bool { return i.getBool(SequentialScanning) } -func (i *Instance) GetGalleryCoverRegex() string { +func (i *Config) GetGalleryCoverRegex() string { var regexString = i.getString(GalleryCoverRegex) _, err := regexp.Compile(regexString) @@ -686,31 +685,31 @@ func (i *Instance) GetGalleryCoverRegex() string { return regexString } -func (i *Instance) GetScrapersPath() string { +func (i *Config) GetScrapersPath() string { return i.getString(ScrapersPath) } -func (i *Instance) GetScraperUserAgent() string { +func (i *Config) GetScraperUserAgent() string { return i.getString(ScraperUserAgent) } // GetScraperCDPPath gets the path to the Chrome executable or remote address // to an instance of Chrome. -func (i *Instance) GetScraperCDPPath() string { +func (i *Config) GetScraperCDPPath() string { return i.getString(ScraperCDPPath) } // GetScraperCertCheck returns true if the scraper should check for insecure // certificates when fetching an image or a page. -func (i *Instance) GetScraperCertCheck() bool { +func (i *Config) GetScraperCertCheck() bool { return i.getBoolDefault(ScraperCertCheck, true) } -func (i *Instance) GetScraperExcludeTagPatterns() []string { +func (i *Config) GetScraperExcludeTagPatterns() []string { return i.getStringSlice(ScraperExcludeTagPatterns) } -func (i *Instance) GetStashBoxes() []*models.StashBox { +func (i *Config) GetStashBoxes() []*models.StashBox { var boxes []*models.StashBox if err := i.unmarshalKey(StashBoxes, &boxes); err != nil { logger.Warnf("error in unmarshalkey: %v", err) @@ -719,18 +718,18 @@ func (i *Instance) GetStashBoxes() []*models.StashBox { return boxes } -func (i *Instance) GetDefaultPluginsPath() string { +func (i *Config) GetDefaultPluginsPath() string { // default to the same directory as the config file fn := filepath.Join(i.GetConfigPath(), "plugins") return fn } -func (i *Instance) GetPluginsPath() string { +func (i *Config) GetPluginsPath() string { return i.getString(PluginsPath) } -func (i *Instance) GetAllPluginConfiguration() map[string]interface{} { +func (i *Config) GetAllPluginConfiguration() map[string]interface{} { i.RLock() defer i.RUnlock() @@ -751,7 +750,7 @@ func (i *Instance) GetAllPluginConfiguration() map[string]interface{} { return ret } -func (i *Instance) GetPluginConfiguration(pluginID string) map[string]interface{} { +func (i *Config) GetPluginConfiguration(pluginID string) map[string]interface{} { i.RLock() defer i.RUnlock() @@ -764,9 +763,9 @@ func (i *Instance) GetPluginConfiguration(pluginID string) map[string]interface{ return fromSnakeCaseMap(v) } -func (i *Instance) SetPluginConfiguration(pluginID string, v map[string]interface{}) { - i.RLock() - defer i.RUnlock() +func (i *Config) SetPluginConfiguration(pluginID string, v map[string]interface{}) { + i.Lock() + defer i.Unlock() pluginID = toSnakeCase(pluginID) @@ -777,15 +776,15 @@ func (i *Instance) SetPluginConfiguration(pluginID string, v map[string]interfac i.viper(key).Set(key, toSnakeCaseMap(v)) } -func (i *Instance) GetDisabledPlugins() []string { +func (i *Config) GetDisabledPlugins() []string { return i.getStringSlice(DisabledPlugins) } -func (i *Instance) GetPythonPath() string { +func (i *Config) GetPythonPath() string { return i.getString(PythonPath) } -func (i *Instance) GetHost() string { +func (i *Config) GetHost() string { ret := i.getString(Host) if ret == "" { ret = hostDefault @@ -794,7 +793,7 @@ func (i *Instance) GetHost() string { return ret } -func (i *Instance) GetPort() int { +func (i *Config) GetPort() int { ret := i.getInt(Port) if ret == 0 { ret = portDefault @@ -803,27 +802,27 @@ func (i *Instance) GetPort() int { return ret } -func (i *Instance) GetThemeColor() string { +func (i *Config) GetThemeColor() string { return i.getString(ThemeColor) } -func (i *Instance) GetExternalHost() string { +func (i *Config) GetExternalHost() string { return i.getString(ExternalHost) } // GetPreviewSegmentDuration returns the duration of a single segment in a // scene preview file, in seconds. -func (i *Instance) GetPreviewSegmentDuration() float64 { +func (i *Config) GetPreviewSegmentDuration() float64 { return i.getFloat64(PreviewSegmentDuration) } // GetParallelTasks returns the number of parallel tasks that should be started // by scan or generate task. -func (i *Instance) GetParallelTasks() int { +func (i *Config) GetParallelTasks() int { return i.getInt(ParallelTasks) } -func (i *Instance) GetParallelTasksWithAutoDetection() int { +func (i *Config) GetParallelTasksWithAutoDetection() int { parallelTasks := i.getInt(ParallelTasks) if parallelTasks <= 0 { parallelTasks = (runtime.NumCPU() / 4) + 1 @@ -831,12 +830,12 @@ func (i *Instance) GetParallelTasksWithAutoDetection() int { return parallelTasks } -func (i *Instance) GetPreviewAudio() bool { +func (i *Config) GetPreviewAudio() bool { return i.getBool(PreviewAudio) } // GetPreviewSegments returns the amount of segments in a scene preview file. -func (i *Instance) GetPreviewSegments() int { +func (i *Config) GetPreviewSegments() int { return i.getInt(PreviewSegments) } @@ -846,7 +845,7 @@ func (i *Instance) GetPreviewSegments() int { // of seconds to exclude from the start of the video before it is included // in the preview. If the value is suffixed with a '%' character (for example // '2%'), then it is interpreted as a proportion of the total video duration. -func (i *Instance) GetPreviewExcludeStart() string { +func (i *Config) GetPreviewExcludeStart() string { return i.getString(PreviewExcludeStart) } @@ -855,13 +854,13 @@ func (i *Instance) GetPreviewExcludeStart() string { // is interpreted as the amount of seconds to exclude from the end of the video // when generating previews. If the value is suffixed with a '%' character, // then it is interpreted as a proportion of the total video duration. -func (i *Instance) GetPreviewExcludeEnd() string { +func (i *Config) GetPreviewExcludeEnd() string { return i.getString(PreviewExcludeEnd) } // GetPreviewPreset returns the preset when generating previews. Defaults to // Slow. -func (i *Instance) GetPreviewPreset() models.PreviewPreset { +func (i *Config) GetPreviewPreset() models.PreviewPreset { ret := i.getString(PreviewPreset) // default to slow @@ -872,11 +871,11 @@ func (i *Instance) GetPreviewPreset() models.PreviewPreset { return models.PreviewPreset(ret) } -func (i *Instance) GetTranscodeHardwareAcceleration() bool { +func (i *Config) GetTranscodeHardwareAcceleration() bool { return i.getBool(TranscodeHardwareAcceleration) } -func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { +func (i *Config) GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := i.getString(MaxTranscodeSize) // default to original @@ -887,7 +886,7 @@ func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { return models.StreamingResolutionEnum(ret) } -func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { +func (i *Config) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { ret := i.getString(MaxStreamingTranscodeSize) // default to original @@ -898,49 +897,49 @@ func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum return models.StreamingResolutionEnum(ret) } -func (i *Instance) GetTranscodeInputArgs() []string { +func (i *Config) GetTranscodeInputArgs() []string { return i.getStringSlice(TranscodeInputArgs) } -func (i *Instance) GetTranscodeOutputArgs() []string { +func (i *Config) GetTranscodeOutputArgs() []string { return i.getStringSlice(TranscodeOutputArgs) } -func (i *Instance) GetLiveTranscodeInputArgs() []string { +func (i *Config) GetLiveTranscodeInputArgs() []string { return i.getStringSlice(LiveTranscodeInputArgs) } -func (i *Instance) GetLiveTranscodeOutputArgs() []string { +func (i *Config) GetLiveTranscodeOutputArgs() []string { return i.getStringSlice(LiveTranscodeOutputArgs) } -func (i *Instance) GetDrawFunscriptHeatmapRange() bool { +func (i *Config) GetDrawFunscriptHeatmapRange() bool { return i.getBoolDefault(DrawFunscriptHeatmapRange, drawFunscriptHeatmapRangeDefault) } // IsWriteImageThumbnails returns true if image thumbnails should be written // to disk after generating on the fly. -func (i *Instance) IsWriteImageThumbnails() bool { +func (i *Config) IsWriteImageThumbnails() bool { return i.getBool(WriteImageThumbnails) } -func (i *Instance) IsCreateImageClipsFromVideos() bool { +func (i *Config) IsCreateImageClipsFromVideos() bool { return i.getBool(CreateImageClipsFromVideos) } -func (i *Instance) GetAPIKey() string { +func (i *Config) GetAPIKey() string { return i.getString(ApiKey) } -func (i *Instance) GetUsername() string { +func (i *Config) GetUsername() string { return i.getString(Username) } -func (i *Instance) GetPasswordHash() string { +func (i *Config) GetPasswordHash() string { return i.getString(Password) } -func (i *Instance) GetCredentials() (string, string) { +func (i *Config) GetCredentials() (string, string) { if i.HasCredentials() { return i.getString(Username), i.getString(Password) } @@ -948,7 +947,7 @@ func (i *Instance) GetCredentials() (string, string) { return "", "" } -func (i *Instance) HasCredentials() bool { +func (i *Config) HasCredentials() bool { username := i.getString(Username) pwHash := i.getString(Password) @@ -961,7 +960,7 @@ func hashPassword(password string) string { return string(hash) } -func (i *Instance) ValidateCredentials(username string, password string) bool { +func (i *Config) ValidateCredentials(username string, password string) bool { if !i.HasCredentials() { // don't need to authenticate if no credentials saved return true @@ -982,7 +981,7 @@ type StashBoxInput struct { Name string `json:"name"` } -func (i *Instance) ValidateStashBoxes(boxes []*StashBoxInput) error { +func (i *Config) ValidateStashBoxes(boxes []*StashBoxInput) error { isMulti := len(boxes) > 1 for _, box := range boxes { @@ -1009,7 +1008,7 @@ func (i *Instance) ValidateStashBoxes(boxes []*StashBoxInput) error { // GetMaxSessionAge gets the maximum age for session cookies, in seconds. // Session cookie expiry times are refreshed every request. -func (i *Instance) GetMaxSessionAge() int { +func (i *Config) GetMaxSessionAge() int { i.RLock() defer i.RUnlock() @@ -1024,16 +1023,16 @@ func (i *Instance) GetMaxSessionAge() int { // GetCustomServedFolders gets the map of custom paths to their applicable // filesystem locations -func (i *Instance) GetCustomServedFolders() utils.URLMap { +func (i *Config) GetCustomServedFolders() utils.URLMap { return i.getStringMapString(CustomServedFolders) } -func (i *Instance) GetCustomUILocation() string { +func (i *Config) GetCustomUILocation() string { return i.getString(CustomUILocation) } // Interface options -func (i *Instance) GetMenuItems() []string { +func (i *Config) GetMenuItems() []string { i.RLock() defer i.RUnlock() v := i.viper(MenuItems) @@ -1043,11 +1042,11 @@ func (i *Instance) GetMenuItems() []string { return defaultMenuItems } -func (i *Instance) GetSoundOnPreview() bool { +func (i *Config) GetSoundOnPreview() bool { return i.getBool(SoundOnPreview) } -func (i *Instance) GetWallShowTitle() bool { +func (i *Config) GetWallShowTitle() bool { i.RLock() defer i.RUnlock() @@ -1059,11 +1058,11 @@ func (i *Instance) GetWallShowTitle() bool { return ret } -func (i *Instance) GetCustomPerformerImageLocation() string { +func (i *Config) GetCustomPerformerImageLocation() string { return i.getString(CustomPerformerImageLocation) } -func (i *Instance) GetWallPlayback() string { +func (i *Config) GetWallPlayback() string { i.RLock() defer i.RUnlock() @@ -1076,31 +1075,31 @@ func (i *Instance) GetWallPlayback() string { return ret } -func (i *Instance) GetShowScrubber() bool { +func (i *Config) GetShowScrubber() bool { return i.getBoolDefault(ShowScrubber, showScrubberDefault) } -func (i *Instance) GetMaximumLoopDuration() int { +func (i *Config) GetMaximumLoopDuration() int { return i.getInt(MaximumLoopDuration) } -func (i *Instance) GetAutostartVideo() bool { +func (i *Config) GetAutostartVideo() bool { return i.getBool(AutostartVideo) } -func (i *Instance) GetAutostartVideoOnPlaySelected() bool { +func (i *Config) GetAutostartVideoOnPlaySelected() bool { return i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault) } -func (i *Instance) GetContinuePlaylistDefault() bool { +func (i *Config) GetContinuePlaylistDefault() bool { return i.getBool(ContinuePlaylistDefault) } -func (i *Instance) GetShowStudioAsText() bool { +func (i *Config) GetShowStudioAsText() bool { return i.getBool(ShowStudioAsText) } -func (i *Instance) getSlideshowDelay() int { +func (i *Config) getSlideshowDelay() int { // assume have lock ret := defaultImageLightboxSlideshowDelay @@ -1118,7 +1117,7 @@ func (i *Instance) getSlideshowDelay() int { return ret } -func (i *Instance) GetImageLightboxOptions() ConfigImageLightboxResult { +func (i *Config) GetImageLightboxOptions() ConfigImageLightboxResult { i.RLock() defer i.RUnlock() @@ -1151,7 +1150,7 @@ func (i *Instance) GetImageLightboxOptions() ConfigImageLightboxResult { return ret } -func (i *Instance) GetDisableDropdownCreate() *ConfigDisableDropdownCreate { +func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate { return &ConfigDisableDropdownCreate{ Performer: i.getBool(DisableDropdownCreatePerformer), Studio: i.getBool(DisableDropdownCreateStudio), @@ -1160,7 +1159,7 @@ func (i *Instance) GetDisableDropdownCreate() *ConfigDisableDropdownCreate { } } -func (i *Instance) GetUIConfiguration() map[string]interface{} { +func (i *Config) GetUIConfiguration() map[string]interface{} { i.RLock() defer i.RUnlock() @@ -1171,16 +1170,16 @@ func (i *Instance) GetUIConfiguration() map[string]interface{} { return fromSnakeCaseMap(v) } -func (i *Instance) SetUIConfiguration(v map[string]interface{}) { - i.RLock() - defer i.RUnlock() +func (i *Config) SetUIConfiguration(v map[string]interface{}) { + i.Lock() + defer i.Unlock() // HACK: viper changes map keys to case insensitive values, so the workaround is to // convert map keys to snake case for storage i.viper(UI).Set(UI, toSnakeCaseMap(v)) } -func (i *Instance) GetCSSPath() string { +func (i *Config) GetCSSPath() string { // use custom.css in the same directory as the config file configFileUsed := i.GetConfigFile() configDir := filepath.Dir(configFileUsed) @@ -1190,7 +1189,7 @@ func (i *Instance) GetCSSPath() string { return fn } -func (i *Instance) GetCSS() string { +func (i *Config) GetCSS() string { fn := i.GetCSSPath() exists, _ := fsutil.FileExists(fn) @@ -1207,7 +1206,7 @@ func (i *Instance) GetCSS() string { return string(buf) } -func (i *Instance) SetCSS(css string) { +func (i *Config) SetCSS(css string) { fn := i.GetCSSPath() i.Lock() defer i.Unlock() @@ -1219,11 +1218,11 @@ func (i *Instance) SetCSS(css string) { } } -func (i *Instance) GetCSSEnabled() bool { +func (i *Config) GetCSSEnabled() bool { return i.getBool(CSSEnabled) } -func (i *Instance) GetJavascriptPath() string { +func (i *Config) GetJavascriptPath() string { // use custom.js in the same directory as the config file configFileUsed := i.GetConfigFile() configDir := filepath.Dir(configFileUsed) @@ -1233,7 +1232,7 @@ func (i *Instance) GetJavascriptPath() string { return fn } -func (i *Instance) GetJavascript() string { +func (i *Config) GetJavascript() string { fn := i.GetJavascriptPath() exists, _ := fsutil.FileExists(fn) @@ -1250,7 +1249,7 @@ func (i *Instance) GetJavascript() string { return string(buf) } -func (i *Instance) SetJavascript(javascript string) { +func (i *Config) SetJavascript(javascript string) { fn := i.GetJavascriptPath() i.Lock() defer i.Unlock() @@ -1262,11 +1261,11 @@ func (i *Instance) SetJavascript(javascript string) { } } -func (i *Instance) GetJavascriptEnabled() bool { +func (i *Config) GetJavascriptEnabled() bool { return i.getBool(JavascriptEnabled) } -func (i *Instance) GetCustomLocalesPath() string { +func (i *Config) GetCustomLocalesPath() string { // use custom-locales.json in the same directory as the config file configFileUsed := i.GetConfigFile() configDir := filepath.Dir(configFileUsed) @@ -1276,7 +1275,7 @@ func (i *Instance) GetCustomLocalesPath() string { return fn } -func (i *Instance) GetCustomLocales() string { +func (i *Config) GetCustomLocales() string { fn := i.GetCustomLocalesPath() exists, _ := fsutil.FileExists(fn) @@ -1293,7 +1292,7 @@ func (i *Instance) GetCustomLocales() string { return string(buf) } -func (i *Instance) SetCustomLocales(customLocales string) { +func (i *Config) SetCustomLocales(customLocales string) { fn := i.GetCustomLocalesPath() i.Lock() defer i.Unlock() @@ -1305,34 +1304,34 @@ func (i *Instance) SetCustomLocales(customLocales string) { } } -func (i *Instance) GetCustomLocalesEnabled() bool { +func (i *Config) GetCustomLocalesEnabled() bool { return i.getBool(CustomLocalesEnabled) } -func (i *Instance) GetHandyKey() string { +func (i *Config) GetHandyKey() string { return i.getString(HandyKey) } -func (i *Instance) GetFunscriptOffset() int { +func (i *Config) GetFunscriptOffset() int { return i.getInt(FunscriptOffset) } -func (i *Instance) GetUseStashHostedFunscript() bool { +func (i *Config) GetUseStashHostedFunscript() bool { return i.getBoolDefault(UseStashHostedFunscript, useStashHostedFunscriptDefault) } -func (i *Instance) GetDeleteFileDefault() bool { +func (i *Config) GetDeleteFileDefault() bool { return i.getBool(DeleteFileDefault) } -func (i *Instance) GetDeleteGeneratedDefault() bool { +func (i *Config) GetDeleteGeneratedDefault() bool { return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault) } // GetDefaultIdentifySettings returns the default Identify task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. -func (i *Instance) GetDefaultIdentifySettings() *identify.Options { +func (i *Config) GetDefaultIdentifySettings() *identify.Options { i.RLock() defer i.RUnlock() v := i.viper(DefaultIdentifySettings) @@ -1351,7 +1350,7 @@ func (i *Instance) GetDefaultIdentifySettings() *identify.Options { // GetDefaultScanSettings returns the default Scan task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. -func (i *Instance) GetDefaultScanSettings() *ScanMetadataOptions { +func (i *Config) GetDefaultScanSettings() *ScanMetadataOptions { i.RLock() defer i.RUnlock() v := i.viper(DefaultScanSettings) @@ -1370,7 +1369,7 @@ func (i *Instance) GetDefaultScanSettings() *ScanMetadataOptions { // GetDefaultAutoTagSettings returns the default Scan task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. -func (i *Instance) GetDefaultAutoTagSettings() *AutoTagMetadataOptions { +func (i *Config) GetDefaultAutoTagSettings() *AutoTagMetadataOptions { i.RLock() defer i.RUnlock() v := i.viper(DefaultAutoTagSettings) @@ -1389,7 +1388,7 @@ func (i *Instance) GetDefaultAutoTagSettings() *AutoTagMetadataOptions { // GetDefaultGenerateSettings returns the default Scan task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. -func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions { +func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions { i.RLock() defer i.RUnlock() v := i.viper(DefaultGenerateSettings) @@ -1407,43 +1406,43 @@ func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions // GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled. // See https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet -func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool { +func (i *Config) GetDangerousAllowPublicWithoutAuth() bool { return i.getBool(dangerousAllowPublicWithoutAuth) } // GetSecurityTripwireAccessedFromPublicInternet returns a public IP address if stash // has been accessed from the public internet, with no auth enabled, and // DangerousAllowPublicWithoutAuth disabled. Returns an empty string otherwise. -func (i *Instance) GetSecurityTripwireAccessedFromPublicInternet() string { +func (i *Config) GetSecurityTripwireAccessedFromPublicInternet() string { return i.getString(SecurityTripwireAccessedFromPublicInternet) } // GetDLNAServerName returns the visible name of the DLNA server. If empty, // "stash" will be used. -func (i *Instance) GetDLNAServerName() string { +func (i *Config) GetDLNAServerName() string { return i.getString(DLNAServerName) } // GetDLNADefaultEnabled returns true if the DLNA is enabled by default. -func (i *Instance) GetDLNADefaultEnabled() bool { +func (i *Config) GetDLNADefaultEnabled() bool { return i.getBool(DLNADefaultEnabled) } // GetDLNADefaultIPWhitelist returns a list of IP addresses/wildcards that // are allowed to use the DLNA service. -func (i *Instance) GetDLNADefaultIPWhitelist() []string { +func (i *Config) GetDLNADefaultIPWhitelist() []string { return i.getStringSlice(DLNADefaultIPWhitelist) } // GetDLNAInterfaces returns a list of interface names to expose DLNA on. If // empty, runs on all interfaces. -func (i *Instance) GetDLNAInterfaces() []string { +func (i *Config) GetDLNAInterfaces() []string { return i.getStringSlice(DLNAInterfaces) } // GetVideoSortOrder returns the sort order to display videos. If // empty, videos will be sorted by titles. -func (i *Instance) GetVideoSortOrder() string { +func (i *Config) GetVideoSortOrder() string { ret := i.getString(DLNAVideoSortOrder) if ret == "" { ret = dlnaVideoSortOrderDefault @@ -1454,20 +1453,20 @@ func (i *Instance) GetVideoSortOrder() string { // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. -func (i *Instance) GetLogFile() string { +func (i *Config) GetLogFile() string { return i.getString(LogFile) } // GetLogOut returns true if logging should be output to the terminal // in addition to writing to a log file. Logging will be output to the // terminal if file logging is disabled. Defaults to true. -func (i *Instance) GetLogOut() bool { +func (i *Config) GetLogOut() bool { return i.getBoolDefault(LogOut, defaultLogOut) } // GetLogLevel returns the lowest log level to write to the log. // Should be one of "Debug", "Info", "Warning", "Error" -func (i *Instance) GetLogLevel() string { +func (i *Config) GetLogLevel() string { value := i.getString(LogLevel) if value != "Debug" && value != "Info" && value != "Warning" && value != "Error" && value != "Trace" { value = defaultLogLevel @@ -1478,12 +1477,12 @@ func (i *Instance) GetLogLevel() string { // GetLogAccess returns true if http requests should be logged to the terminal. // HTTP requests are not logged to the log file. Defaults to true. -func (i *Instance) GetLogAccess() bool { +func (i *Config) GetLogAccess() bool { return i.getBoolDefault(LogAccess, defaultLogAccess) } // Max allowed graphql upload size in megabytes -func (i *Instance) GetMaxUploadSize() int64 { +func (i *Config) GetMaxUploadSize() int64 { i.RLock() defer i.RUnlock() ret := int64(1024) @@ -1496,7 +1495,7 @@ func (i *Instance) GetMaxUploadSize() int64 { } // GetProxy returns the url of a http proxy to be used for all outgoing http calls. -func (i *Instance) GetProxy() string { +func (i *Config) GetProxy() string { // Validate format reg := regexp.MustCompile(`^((?:socks5h?|https?):\/\/)(([\P{Cc}]+):([\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`) proxy := i.getString(Proxy) @@ -1511,7 +1510,7 @@ func (i *Instance) GetProxy() string { } // GetProxy returns the url of a http proxy to be used for all outgoing http calls. -func (i *Instance) GetNoProxy() string { +func (i *Config) GetNoProxy() string { // NoProxy does not require validation, it is validated by the native Go library sufficiently return i.getString(NoProxy) } @@ -1519,12 +1518,12 @@ func (i *Instance) GetNoProxy() string { // ActivatePublicAccessTripwire sets the security_tripwire_accessed_from_public_internet // config field to the provided IP address to indicate that stash has been accessed // from this public IP without authentication. -func (i *Instance) ActivatePublicAccessTripwire(requestIP string) error { +func (i *Config) ActivatePublicAccessTripwire(requestIP string) error { i.Set(SecurityTripwireAccessedFromPublicInternet, requestIP) return i.Write() } -func (i *Instance) getPackageSources(key string) []*models.PackageSource { +func (i *Config) getPackageSources(key string) []*models.PackageSource { var sources []*models.PackageSource if err := i.unmarshalKey(key, &sources); err != nil { logger.Warnf("error in unmarshalkey: %v", err) @@ -1533,11 +1532,11 @@ func (i *Instance) getPackageSources(key string) []*models.PackageSource { return sources } -func (i *Instance) GetPluginPackageSources() []*models.PackageSource { +func (i *Config) GetPluginPackageSources() []*models.PackageSource { return i.getPackageSources(PluginPackageSources) } -func (i *Instance) GetScraperPackageSources() []*models.PackageSource { +func (i *Config) GetScraperPackageSources() []*models.PackageSource { return i.getPackageSources(ScraperPackageSources) } @@ -1567,19 +1566,19 @@ func (g packagePathGetter) GetSourcePath(srcURL string) string { return "" } -func (i *Instance) GetPluginPackagePathGetter() packagePathGetter { +func (i *Config) GetPluginPackagePathGetter() packagePathGetter { return packagePathGetter{ getterFn: i.GetPluginPackageSources, } } -func (i *Instance) GetScraperPackagePathGetter() packagePathGetter { +func (i *Config) GetScraperPackagePathGetter() packagePathGetter { return packagePathGetter{ getterFn: i.GetScraperPackageSources, } } -func (i *Instance) Validate() error { +func (i *Config) Validate() error { i.RLock() defer i.RUnlock() mandatoryPaths := []string{ @@ -1610,7 +1609,7 @@ func (i *Instance) Validate() error { return nil } -func (i *Instance) setDefaultValues(write bool) error { +func (i *Config) setDefaultValues() { // read data before write lock scope defaultDatabaseFilePath := i.GetDefaultDatabaseFilePath() defaultScrapersPath := i.GetDefaultScrapersPath() @@ -1659,54 +1658,30 @@ func (i *Instance) setDefaultValues(write bool) error { // Set NoProxy default i.main.SetDefault(NoProxy, noProxyDefault) - - if write { - return i.main.WriteConfig() - } - - return nil } // setExistingSystemDefaults sets config options that are new and unset in an existing install, // but should have a separate default than for brand-new systems, to maintain behavior. -func (i *Instance) setExistingSystemDefaults() error { +// The config file will not be written. +func (i *Config) setExistingSystemDefaults() { i.Lock() defer i.Unlock() if !i.isNewSystem { - configDirtied := false - // Existing systems as of the introduction of auto-browser open should retain existing // behavior and not start the browser automatically. if !i.main.InConfig(NoBrowser) { - configDirtied = true i.main.Set(NoBrowser, true) } // Existing systems as of the introduction of the taskbar should inform users. if !i.main.InConfig(ShowOneTimeMovedNotification) { - configDirtied = true i.main.Set(ShowOneTimeMovedNotification, true) } - - if configDirtied { - return i.main.WriteConfig() - } } - - return nil } -// SetInitialConfig fills in missing required config fields -func (i *Instance) SetInitialConfig() error { - return i.setInitialConfig(true) -} - -// SetInitialMemoryConfig fills in missing required config fields without writing the configuration -func (i *Instance) SetInitialMemoryConfig() error { - return i.setInitialConfig(false) -} - -func (i *Instance) setInitialConfig(write bool) error { +// SetInitialConfig fills in missing required config fields. The config file will not be written. +func (i *Config) SetInitialConfig() error { // generate some api keys const apiKeyLength = 32 @@ -1726,10 +1701,12 @@ func (i *Instance) setInitialConfig(write bool) error { i.Set(SessionStoreKey, sessionStoreKey) } - return i.setDefaultValues(write) + i.setDefaultValues() + + return nil } -func (i *Instance) FinalizeSetup() { +func (i *Config) FinalizeSetup() { i.isNewSystem = false // i.configUpdates <- 0 } diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go index e96983527..69d657359 100644 --- a/internal/manager/config/config_concurrency_test.go +++ b/internal/manager/config/config_concurrency_test.go @@ -7,7 +7,7 @@ import ( // should be run with -race func TestConcurrentConfigAccess(t *testing.T) { - i := GetInstance() + i := InitializeEmpty() const workers = 8 const loops = 200 @@ -16,13 +16,12 @@ func TestConcurrentConfigAccess(t *testing.T) { wg.Add(1) go func(wk int) { for l := 0; l < loops; l++ { - if err := i.SetInitialMemoryConfig(); err != nil { + if err := i.SetInitialConfig(); err != nil { t.Errorf("Failure setting initial configuration in worker %v iteration %v: %v", wk, l, err) } i.HasCredentials() i.ValidateCredentials("", "") - i.GetCPUProfilePath() i.GetConfigFile() i.GetConfigPath() i.GetDefaultDatabaseFilePath() diff --git a/internal/manager/config/init.go b/internal/manager/config/init.go index 18cda5aa7..c5567fbf3 100644 --- a/internal/manager/config/init.go +++ b/internal/manager/config/init.go @@ -6,84 +6,107 @@ import ( "net" "os" "path/filepath" - "sync" "github.com/spf13/pflag" "github.com/spf13/viper" - "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) -var ( - initOnce sync.Once - instanceOnce sync.Once -) - type flagStruct struct { configFilePath string - cpuProfilePath string nobrowser bool - helpFlag bool - versionFlag bool } -func GetInstance() *Instance { - instanceOnce.Do(func() { - instance = &Instance{ - main: viper.New(), - overrides: viper.New(), +var flags flagStruct + +func init() { + pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host") + pflag.Int("port", 9999, "port to serve from") + pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use") + pflag.BoolVar(&flags.nobrowser, "nobrowser", false, "Don't open a browser window after launch") +} + +// Called at startup +func Initialize() (*Config, error) { + cfg := &Config{ + main: viper.New(), + overrides: viper.New(), + } + + cfg.initOverrides() + + err := cfg.initConfig() + if err != nil { + return nil, err + } + + if cfg.isNewSystem { + if cfg.Validate() == nil { + // system has been initialised by the environment + cfg.isNewSystem = false } - }) + } + + if !cfg.isNewSystem { + cfg.setExistingSystemDefaults() + + err := cfg.SetInitialConfig() + if err != nil { + return nil, err + } + + err = cfg.Write() + if err != nil { + return nil, err + } + + err = cfg.Validate() + if err != nil { + return nil, err + } + } + + instance = cfg + return instance, nil +} + +// Called by tests to initialize an empty config +func InitializeEmpty() *Config { + cfg := &Config{ + main: viper.New(), + overrides: viper.New(), + } + instance = cfg return instance } -func Initialize() (*Instance, error) { - var err error - initOnce.Do(func() { - flags := initFlags() - - if flags.helpFlag { - pflag.Usage() - os.Exit(0) - } - - if flags.versionFlag { - fmt.Printf(build.VersionString() + "\n") - os.Exit(0) - } - - overrides := makeOverrideConfig() - - _ = GetInstance() - instance.overrides = overrides - instance.cpuProfilePath = flags.cpuProfilePath - // instance.configUpdates = make(chan int) - - if err = initConfig(instance, flags); err != nil { - return - } - - if instance.isNewSystem { - if instance.Validate() == nil { - // system has been initialised by the environment - instance.isNewSystem = false - } - } - - if !instance.isNewSystem { - err = instance.setExistingSystemDefaults() - if err == nil { - err = instance.SetInitialConfig() - } - } - }) - return instance, err +func bindEnv(v *viper.Viper, key string) { + if err := v.BindEnv(key); err != nil { + panic(fmt.Sprintf("unable to set environment key (%v): %v", key, err)) + } } -func initConfig(instance *Instance, flags flagStruct) error { - v := instance.main +func (i *Config) initOverrides() { + v := i.overrides + + if err := v.BindPFlags(pflag.CommandLine); err != nil { + logger.Infof("failed to bind flags: %v", err) + } + + v.SetEnvPrefix("stash") // will be uppercased automatically + bindEnv(v, "host") // STASH_HOST + bindEnv(v, "port") // STASH_PORT + bindEnv(v, "external_host") // STASH_EXTERNAL_HOST + bindEnv(v, "generated") // STASH_GENERATED + bindEnv(v, "metadata") // STASH_METADATA + bindEnv(v, "cache") // STASH_CACHE + bindEnv(v, "stash") // STASH_STASH +} + +func (i *Config) initConfig() error { + v := i.main // The config file is called config. Leave off the file extension. v.SetConfigName("config") @@ -105,11 +128,11 @@ func initConfig(instance *Instance, flags flagStruct) error { // if file does not exist, assume it is a new system if exists, _ := fsutil.FileExists(configFile); !exists { - instance.isNewSystem = true + i.isNewSystem = true // ensure we can write to the file if err := fsutil.Touch(configFile); err != nil { - return fmt.Errorf(`could not write to provided config path "%s": %s`, configFile, err.Error()) + return fmt.Errorf(`could not write to provided config path "%s": %v`, configFile, err) } else { // remove the file os.Remove(configFile) @@ -123,7 +146,7 @@ func initConfig(instance *Instance, flags flagStruct) error { // if not found, assume its a new system var notFoundErr viper.ConfigFileNotFoundError if errors.As(err, ¬FoundErr) { - instance.isNewSystem = true + i.isNewSystem = true return nil } else if err != nil { return err @@ -131,48 +154,3 @@ func initConfig(instance *Instance, flags flagStruct) error { return nil } - -func initFlags() flagStruct { - flags := flagStruct{} - - pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host") - pflag.Int("port", 9999, "port to serve from") - pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use") - pflag.StringVar(&flags.cpuProfilePath, "cpuprofile", "", "write cpu profile to file") - pflag.BoolVar(&flags.nobrowser, "nobrowser", false, "Don't open a browser window after launch") - pflag.BoolVarP(&flags.helpFlag, "help", "h", false, "show this help text and exit") - pflag.BoolVarP(&flags.versionFlag, "version", "v", false, "show version number and exit") - - pflag.Parse() - - return flags -} - -func initEnvs(viper *viper.Viper) { - viper.SetEnvPrefix("stash") // will be uppercased automatically - bindEnv(viper, "host") // STASH_HOST - bindEnv(viper, "port") // STASH_PORT - bindEnv(viper, "external_host") // STASH_EXTERNAL_HOST - bindEnv(viper, "generated") // STASH_GENERATED - bindEnv(viper, "metadata") // STASH_METADATA - bindEnv(viper, "cache") // STASH_CACHE - bindEnv(viper, "stash") // STASH_STASH -} - -func bindEnv(viper *viper.Viper, key string) { - if err := viper.BindEnv(key); err != nil { - panic(fmt.Sprintf("unable to set environment key (%v): %v", key, err)) - } -} - -func makeOverrideConfig() *viper.Viper { - viper := viper.New() - - if err := viper.BindPFlags(pflag.CommandLine); err != nil { - logger.Infof("failed to bind flags: %s", err.Error()) - } - - initEnvs(viper) - - return viper -} diff --git a/internal/manager/enums.go b/internal/manager/enums.go new file mode 100644 index 000000000..3b707047c --- /dev/null +++ b/internal/manager/enums.go @@ -0,0 +1,50 @@ +package manager + +import ( + "fmt" + "io" + "strconv" +) + +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())) +} diff --git a/internal/manager/fingerprint.go b/internal/manager/fingerprint.go index b30ac4532..0e0402845 100644 --- a/internal/manager/fingerprint.go +++ b/internal/manager/fingerprint.go @@ -14,7 +14,7 @@ import ( ) type fingerprintCalculator struct { - Config *config.Instance + Config *config.Config } func (c *fingerprintCalculator) calculateOshash(f *models.BaseFile, o file.Opener) (*models.Fingerprint, error) { diff --git a/internal/manager/generator.go b/internal/manager/generator.go index d2ca95016..09e78df64 100644 --- a/internal/manager/generator.go +++ b/internal/manager/generator.go @@ -49,7 +49,7 @@ func (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er // If we are missing the frame count or frame rate then seek through the file and extract the info with regex if numberOfFrames == 0 || !isValidFloat64(framerate) { - info, err := instance.FFMPEG.CalculateFrameRate(context.TODO(), &g.VideoFile) + info, err := instance.FFMpeg.CalculateFrameRate(context.TODO(), &g.VideoFile) if err != nil { logger.Errorf("error calculating frame rate: %v", err) } else { diff --git a/internal/manager/generator_sprite.go b/internal/manager/generator_sprite.go index fa265cd56..c28d28674 100644 --- a/internal/manager/generator_sprite.go +++ b/internal/manager/generator_sprite.go @@ -75,7 +75,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO SlowSeek: slowSeek, Columns: cols, g: &generate.Generator{ - Encoder: instance.FFMPEG, + Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, LockManager: instance.ReadLockManager, ScenePaths: instance.Paths.Scene, diff --git a/internal/manager/init.go b/internal/manager/init.go new file mode 100644 index 000000000..c0aa08eac --- /dev/null +++ b/internal/manager/init.go @@ -0,0 +1,308 @@ +package manager + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "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/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/paths" + "github.com/stashapp/stash/pkg/pkg" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/scene" + "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" +) + +// Called at startup +func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { + ctx := context.TODO() + + db := sqlite.NewDatabase() + repo := db.Repository() + + // start with empty paths + mgrPaths := &paths.Paths{} + + scraperRepository := scraper.NewRepository(repo) + scraperCache := scraper.NewCache(cfg, scraperRepository) + + pluginCache := plugin.NewCache(cfg) + + sceneService := &scene.Service{ + File: db.File, + Repository: db.Scene, + MarkerRepository: db.SceneMarker, + PluginCache: pluginCache, + Paths: mgrPaths, + Config: cfg, + } + + imageService := &image.Service{ + File: db.File, + Repository: db.Image, + } + + galleryService := &gallery.Service{ + Repository: db.Gallery, + ImageFinder: db.Image, + ImageService: imageService, + File: db.File, + Folder: db.Folder, + } + + sceneServer := &SceneServer{ + TxnManager: repo.TxnManager, + SceneCoverGetter: repo.Scene, + } + + dlnaRepository := dlna.NewRepository(repo) + dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer) + + mgr := &Manager{ + Config: cfg, + Logger: l, + + Paths: mgrPaths, + + JobManager: initJobManager(cfg), + ReadLockManager: fsutil.NewReadLockManager(), + + DownloadStore: NewDownloadStore(), + + PluginCache: pluginCache, + ScraperCache: scraperCache, + + DLNAService: dlnaService, + + Database: db, + Repository: repo, + + SceneService: sceneService, + ImageService: imageService, + GalleryService: galleryService, + + scanSubs: &subscriptionManager{}, + } + + mgr.RefreshPluginSourceManager() + mgr.RefreshScraperSourceManager() + + if !cfg.IsNewSystem() { + logger.Infof("using config file: %s", cfg.GetConfigFile()) + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + if err := mgr.postInit(ctx); err != nil { + return nil, err + } + + mgr.checkSecurityTripwire() + } else { + cfgFile := cfg.GetConfigFile() + if cfgFile != "" { + cfgFile += " " + } + + // create temporary session store - this will be re-initialised + // after config is complete + mgr.SessionStore = session.NewStore(cfg) + + logger.Warnf("config file %snot found. Assuming new system...", cfgFile) + } + + instance = mgr + return mgr, nil +} + +func initialisePackageManager(localPath string, srcPathGetter pkg.SourcePathGetter) *pkg.Manager { + const timeout = 10 * time.Second + httpClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + Timeout: timeout, + } + + return &pkg.Manager{ + Local: &pkg.Store{ + BaseDir: localPath, + ManifestFile: pkg.ManifestFile, + }, + PackagePathGetter: srcPathGetter, + Client: httpClient, + } +} + +func formatDuration(t time.Duration) string { + return fmt.Sprintf("%02.f:%02.f:%02.f", t.Hours(), t.Minutes(), t.Seconds()) +} + +func initJobManager(cfg *config.Config) *job.Manager { + ret := job.NewManager() + + // desktop notifications + ctx := context.Background() + c := ret.Subscribe(context.Background()) + go func() { + for { + select { + case j := <-c.RemovedJob: + if cfg.GetNotificationsEnabled() { + cleanDesc := strings.TrimRight(j.Description, ".") + + if j.StartTime == nil { + // Task was never started + return + } + + timeElapsed := j.EndTime.Sub(*j.StartTime) + msg := fmt.Sprintf("Task \"%s\" is finished in %s.", cleanDesc, formatDuration(timeElapsed)) + desktop.SendNotification("Task Finished", msg) + } + case <-ctx.Done(): + return + } + } + }() + + return ret +} + +// postInit initialises the paths, caches and database after the initial +// configuration has been set. Should only be called if the configuration +// is valid. +func (s *Manager) postInit(ctx context.Context) error { + s.RefreshConfig() + + s.SessionStore = session.NewStore(s.Config) + s.PluginCache.RegisterSessionStore(s.SessionStore) + + s.RefreshPluginCache() + s.RefreshScraperCache() + s.RefreshStreamManager() + s.RefreshDLNA() + + s.SetBlobStoreOptions() + + s.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(s.Paths.Generated.Downloads); err != nil { + logger.Warnf("could not empty downloads directory: %v", err) + } + if err := fsutil.EnsureDir(s.Paths.Generated.Tmp); err != nil { + logger.Warnf("could not create temporary directory: %v", err) + } else { + if err := fsutil.EmptyDir(s.Paths.Generated.Tmp); err != nil { + logger.Warnf("could not empty temporary 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.") + }) + } + + if err := s.Database.Open(s.Config.GetDatabasePath()); err != nil { + var migrationNeededErr *sqlite.MigrationNeededError + if errors.As(err, &migrationNeededErr) { + logger.Warn(err) + } else { + return err + } + } + + // Set the proxy if defined in config + if s.Config.GetProxy() != "" { + os.Setenv("HTTP_PROXY", s.Config.GetProxy()) + os.Setenv("HTTPS_PROXY", s.Config.GetProxy()) + os.Setenv("NO_PROXY", s.Config.GetNoProxy()) + logger.Info("Using HTTP proxy") + } + + if err := s.initFFmpeg(ctx); err != nil { + return fmt.Errorf("error initializing FFmpeg subsystem: %v", err) + } + + return nil +} + +func (s *Manager) checkSecurityTripwire() { + if err := session.CheckExternalAccessTripwire(s.Config); err != nil { + session.LogExternalAccessError(*err) + } +} + +func (s *Manager) writeStashIcon() { + iconPath := filepath.Join(s.Config.GetConfigPath(), "icon.png") + err := os.WriteFile(iconPath, ui.FaviconProvider.GetFaviconPng(), 0644) + if err != nil { + logger.Errorf("Couldn't write icon file: %v", err) + } +} + +func (s *Manager) initFFmpeg(ctx context.Context) error { + // use same directory as config path + configDirectory := s.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 { + path, absErr := filepath.Abs(configDirectory) + if absErr != nil { + path = configDirectory + } + msg := `Unable to automatically download FFmpeg + +Check the readme for download links. +The ffmpeg and ffprobe binaries should be placed in %s. + +` + logger.Errorf(msg, path) + return err + } else { + // After download get new paths for ffmpeg and ffprobe + ffmpegPath, ffprobePath = ffmpeg.GetPaths(paths) + } + } + + s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath) + s.FFProbe = ffmpeg.FFProbe(ffprobePath) + + s.FFMpeg.InitHWSupport(ctx) + s.RefreshStreamManager() + + return nil +} diff --git a/internal/manager/manager.go b/internal/manager/manager.go index aa39226a4..d0942eb9b 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -4,130 +4,44 @@ import ( "context" "errors" "fmt" - "io" - "net/http" "os" "path/filepath" "runtime" - "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/pkg" "github.com/stashapp/stash/pkg/plugin" - "github.com/stashapp/stash/pkg/scene" "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"` - Os string `json:"os"` - WorkingDir string `json:"working_dir"` - HomeDir string `json:"home_dir"` -} - -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"` - // Empty to indicate default - CacheLocation string `json:"cacheLocation"` - - StoreBlobsInDatabase bool `json:"storeBlobsInDatabase"` - // Empty to indicate default - BlobsLocation string `json:"blobsLocation"` -} - type Manager struct { - Config *config.Instance + Config *config.Config Logger *log.Logger Paths *paths.Paths - FFMPEG *ffmpeg.FFMpeg + FFMpeg *ffmpeg.FFMpeg FFProbe ffmpeg.FFProbe StreamManager *ffmpeg.StreamManager + JobManager *job.Manager ReadLockManager *fsutil.ReadLockManager - SessionStore *session.Store - - JobManager *job.Manager + DownloadStore *DownloadStore + SessionStore *session.Store PluginCache *plugin.Cache ScraperCache *scraper.Cache @@ -135,8 +49,6 @@ type Manager struct { PluginPackageManager *pkg.Manager ScraperPackageManager *pkg.Manager - DownloadStore *DownloadStore - DLNAService *dlna.Service Database *sqlite.Database @@ -146,378 +58,18 @@ type Manager struct { 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) + if instance == nil { + panic("manager not initialized") } 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() - repo := db.Repository() - - // start with empty paths - emptyPaths := paths.Paths{} - - instance = &Manager{ - Config: cfg, - Logger: l, - ReadLockManager: fsutil.NewReadLockManager(), - DownloadStore: NewDownloadStore(), - PluginCache: plugin.NewCache(cfg), - - Database: db, - Repository: repo, - Paths: &emptyPaths, - - scanSubs: &subscriptionManager{}, - } - - instance.SceneService = &scene.Service{ - File: repo.File, - Repository: repo.Scene, - MarkerRepository: repo.SceneMarker, - PluginCache: instance.PluginCache, - Paths: instance.Paths, - Config: cfg, - } - - instance.ImageService = &image.Service{ - File: repo.File, - Repository: repo.Image, - } - - instance.GalleryService = &gallery.Service{ - Repository: repo.Gallery, - ImageFinder: repo.Image, - ImageService: instance.ImageService, - File: repo.File, - Folder: repo.Folder, - } - - instance.JobManager = initJobManager() - - sceneServer := SceneServer{ - TxnManager: repo.TxnManager, - SceneCoverGetter: repo.Scene, - } - - dlnaRepository := dlna.NewRepository(repo) - instance.DLNAService = dlna.NewService(dlnaRepository, cfg, &sceneServer) - - instance.RefreshPluginSourceManager() - instance.RefreshScraperSourceManager() - - 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(repo, instance.PluginCache) - instance.Cleaner = makeCleaner(repo, 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 initialisePackageManager(localPath string, srcPathGetter pkg.SourcePathGetter) *pkg.Manager { - const timeout = 10 * time.Second - httpClient := &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - }, - Timeout: timeout, - } - - return &pkg.Manager{ - Local: &pkg.Store{ - BaseDir: localPath, - ManifestFile: pkg.ManifestFile, - }, - PackagePathGetter: srcPathGetter, - Client: httpClient, - } -} - -func videoFileFilter(ctx context.Context, f models.File) bool { - return useAsVideo(f.Base().Path) -} - -func imageFileFilter(ctx context.Context, f models.File) bool { - return useAsImage(f.Base().Path) -} - -func galleryFileFilter(ctx context.Context, f models.File) bool { - return isZip(f.Base().Basename) -} - -func makeScanner(repo models.Repository, pluginCache *plugin.Cache) *file.Scanner { - return &file.Scanner{ - Repository: file.NewRepository(repo), - FileDecorators: []file.Decorator{ - &file.FilteredDecorator{ - Decorator: &video.Decorator{ - FFProbe: instance.FFProbe, - }, - Filter: file.FilterFunc(videoFileFilter), - }, - &file.FilteredDecorator{ - Decorator: &file_image.Decorator{ - FFProbe: instance.FFProbe, - }, - Filter: file.FilterFunc(imageFileFilter), - }, - }, - FingerprintCalculator: &fingerprintCalculator{instance.Config}, - FS: &file.OsFS{}, - } -} - -func makeCleaner(repo models.Repository, pluginCache *plugin.Cache) *file.Cleaner { - return &file.Cleaner{ - FS: &file.OsFS{}, - Repository: file.NewRepository(repo), - 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.NewEncoder(ffmpegPath) - instance.FFProbe = ffmpeg.FFProbe(ffprobePath) - - instance.FFMPEG.InitHWSupport(ctx) - instance.RefreshStreamManager() - } - - 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.Config.GetBlobsPath()) - 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.SetBlobStoreOptions() - - 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 - } - - // Set the proxy if defined in config - if s.Config.GetProxy() != "" { - os.Setenv("HTTP_PROXY", s.Config.GetProxy()) - os.Setenv("HTTPS_PROXY", s.Config.GetProxy()) - os.Setenv("NO_PROXY", s.Config.GetNoProxy()) - logger.Info("Using HTTP Proxy") - } - - return nil -} - func (s *Manager) SetBlobStoreOptions() { storageType := s.Config.GetBlobsStorage() blobsPath := s.Config.GetBlobsPath() @@ -529,59 +81,45 @@ func (s *Manager) SetBlobStoreOptions() { }) } -func writeStashIcon() { - iconPath := filepath.Join(instance.Config.GetConfigPath(), "icon.png") - err := os.WriteFile(iconPath, ui.FaviconProvider.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 { - scraperRepository := scraper.NewRepository(s.Repository) - ret, err := scraper.NewCache(s.Config, scraperRepository) - - 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(), s.Config.GetBlobsPath()) - config := s.Config - if config.Validate() == nil { + cfg := s.Config + *s.Paths = paths.NewPaths(cfg.GetGeneratedPath(), cfg.GetBlobsPath()) + if cfg.Validate() == nil { if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil { - logger.Warnf("could not create directory for Screenshots: %v", err) + logger.Warnf("could not create screenshots directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Vtt); err != nil { - logger.Warnf("could not create directory for VTT: %v", err) + logger.Warnf("could not create VTT directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Markers); err != nil { - logger.Warnf("could not create directory for Markers: %v", err) + logger.Warnf("could not create markers directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Transcodes); err != nil { - logger.Warnf("could not create directory for Transcodes: %v", err) + logger.Warnf("could not create transcodes directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Downloads); err != nil { - logger.Warnf("could not create directory for Downloads: %v", err) + logger.Warnf("could not create downloads directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil { - logger.Warnf("could not create directory for Interactive Heatmaps: %v", err) + logger.Warnf("could not create interactive heatmaps directory: %v", err) } } } -// RefreshScraperCache refreshes the scraper cache. Call this when scraper -// configuration changes. -func (s *Manager) RefreshScraperCache() { - s.ScraperCache = s.initScraperCache() +// RefreshPluginCache refreshes the plugin cache. +// Call this when the plugin configuration changes. +func (s *Manager) RefreshPluginCache() { + s.PluginCache.ReloadPlugins() } -// RefreshStreamManager refreshes the stream manager. Call this when cache directory -// changes. +// RefreshScraperCache refreshes the scraper cache. +// Call this when the scraper configuration changes. +func (s *Manager) RefreshScraperCache() { + s.ScraperCache.ReloadScrapers() +} + +// RefreshStreamManager refreshes the stream manager. +// Call this when the cache directory changes. func (s *Manager) RefreshStreamManager() { // shutdown existing manager if needed if s.StreamManager != nil { @@ -589,8 +127,22 @@ func (s *Manager) RefreshStreamManager() { s.StreamManager = nil } - cacheDir := s.Config.GetCachePath() - s.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMPEG, s.FFProbe, s.Config, s.ReadLockManager) + cfg := s.Config + cacheDir := cfg.GetCachePath() + s.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMpeg, s.FFProbe, cfg, s.ReadLockManager) +} + +// RefreshDLNA starts/stops the DLNA service as needed. +func (s *Manager) RefreshDLNA() { + dlnaService := s.DLNAService + enabled := s.Config.GetDLNADefaultEnabled() + if !enabled && dlnaService.IsRunning() { + dlnaService.Stop(nil) + } else if enabled && !dlnaService.IsRunning() { + if err := dlnaService.Start(nil); err != nil { + logger.Warnf("error starting DLNA service: %v", err) + } + } } func (s *Manager) RefreshScraperSourceManager() { @@ -625,7 +177,11 @@ func setSetupDefaults(input *SetupInput) { func (s *Manager) Setup(ctx context.Context, input SetupInput) error { setSetupDefaults(&input) - c := s.Config + cfg := s.Config + + if err := cfg.SetInitialConfig(); err != nil { + return fmt.Errorf("error setting initial configuration: %v", err) + } // create the config directory if it does not exist // don't do anything if config is already set in the environment @@ -652,7 +208,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { } // create the generated directory if it does not exist - if !c.HasOverride(config.Generated) { + if !cfg.HasOverride(config.Generated) { if exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists { if err := os.MkdirAll(input.GeneratedLocation, 0755); err != nil { return fmt.Errorf("error creating generated directory: %v", err) @@ -663,75 +219,60 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { } // create the cache directory if it does not exist - if !c.HasOverride(config.Cache) { + if !cfg.HasOverride(config.Cache) { if exists, _ := fsutil.DirExists(input.CacheLocation); !exists { if err := os.MkdirAll(input.CacheLocation, 0755); err != nil { return fmt.Errorf("error creating cache directory: %v", err) } } - s.Config.Set(config.Cache, input.CacheLocation) + cfg.Set(config.Cache, input.CacheLocation) } if input.StoreBlobsInDatabase { - s.Config.Set(config.BlobsStorage, config.BlobStorageTypeDatabase) + cfg.Set(config.BlobsStorage, config.BlobStorageTypeDatabase) } else { - if !c.HasOverride(config.BlobsPath) { + if !cfg.HasOverride(config.BlobsPath) { if exists, _ := fsutil.DirExists(input.BlobsLocation); !exists { if err := os.MkdirAll(input.BlobsLocation, 0755); err != nil { return fmt.Errorf("error creating blobs directory: %v", err) } } - s.Config.Set(config.BlobsPath, input.BlobsLocation) + cfg.Set(config.BlobsPath, input.BlobsLocation) } - s.Config.Set(config.BlobsStorage, config.BlobStorageTypeFilesystem) + cfg.Set(config.BlobsStorage, config.BlobStorageTypeFilesystem) } // set the configuration - if !c.HasOverride(config.Database) { - s.Config.Set(config.Database, input.DatabaseFile) + if !cfg.HasOverride(config.Database) { + cfg.Set(config.Database, input.DatabaseFile) } - s.Config.Set(config.Stash, input.Stashes) - if err := s.Config.Write(); err != nil { + cfg.Set(config.Stash, input.Stashes) + + if err := cfg.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) - } + // finish initialization + if err := s.postInit(ctx); err != nil { + return fmt.Errorf("error completing initialization: %v", err) } - s.Config.FinalizeSetup() - - if err := initFFMPEG(ctx); err != nil { - return fmt.Errorf("error initializing FFMPEG subsystem: %v", err) - } - - instance.Scanner = makeScanner(instance.Repository, instance.PluginCache) + cfg.FinalizeSetup() return nil } -func (s *Manager) validateFFMPEG() error { - if s.FFMPEG == nil || s.FFProbe == "" { +func (s *Manager) validateFFmpeg() error { + if s.FFMpeg == nil || 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 @@ -778,6 +319,76 @@ func (s *Manager) Migrate(ctx context.Context, input MigrateInput) error { return nil } +func (s *Manager) BackupDatabase(download bool) (string, string, error) { + var backupPath string + var backupName string + if download { + backupDir := s.Paths.Generated.Downloads + if err := fsutil.EnsureDir(backupDir); err != nil { + return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) + } + f, err := os.CreateTemp(backupDir, "backup*.sqlite") + if err != nil { + return "", "", err + } + + backupPath = f.Name() + backupName = s.Database.DatabaseBackupPath("") + f.Close() + } else { + backupDir := s.Config.GetBackupDirectoryPathOrDefault() + if backupDir != "" { + if err := fsutil.EnsureDir(backupDir); err != nil { + return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) + } + } + backupPath = s.Database.DatabaseBackupPath(backupDir) + backupName = filepath.Base(backupPath) + } + + err := s.Database.Backup(backupPath) + if err != nil { + return "", "", err + } + + return backupPath, backupName, nil +} + +func (s *Manager) AnonymiseDatabase(download bool) (string, string, error) { + var outPath string + var outName string + if download { + outDir := s.Paths.Generated.Downloads + if err := fsutil.EnsureDir(outDir); err != nil { + return "", "", fmt.Errorf("could not create output directory %v: %w", outDir, err) + } + f, err := os.CreateTemp(outDir, "anonymous*.sqlite") + if err != nil { + return "", "", err + } + + outPath = f.Name() + outName = s.Database.AnonymousDatabasePath("") + f.Close() + } else { + outDir := s.Config.GetBackupDirectoryPathOrDefault() + if outDir != "" { + if err := fsutil.EnsureDir(outDir); err != nil { + return "", "", fmt.Errorf("could not create output directory %v: %w", outDir, err) + } + } + outPath = s.Database.AnonymousDatabasePath(outDir) + outName = filepath.Base(outPath) + } + + err := s.Database.Anonymise(outPath) + if err != nil { + return "", "", err + } + + return outPath, outName, nil +} + func (s *Manager) GetSystemStatus() *SystemStatus { workingDir := fsutil.GetWorkingDirectory() homeDir := fsutil.GetHomeDirectory() @@ -809,24 +420,16 @@ func (s *Manager) GetSystemStatus() *SystemStatus { } // Shutdown gracefully stops the manager -func (s *Manager) Shutdown(code int) { - // stop any profiling at exit - pprof.StopCPUProfile() +func (s *Manager) Shutdown() { + // TODO: Each part of the manager needs to gracefully stop at some point if s.StreamManager != nil { s.StreamManager.Shutdown() s.StreamManager = nil } - // 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) } diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index dea78b535..1202d0327 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -9,6 +9,9 @@ import ( "time" "github.com/stashapp/stash/internal/manager/config" + "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/job" "github.com/stashapp/stash/pkg/logger" @@ -90,12 +93,32 @@ type ScanMetaDataFilterInput struct { } func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error) { - if err := s.validateFFMPEG(); err != nil { + if err := s.validateFFmpeg(); err != nil { return 0, err } + scanner := &file.Scanner{ + Repository: file.NewRepository(s.Repository), + FileDecorators: []file.Decorator{ + &file.FilteredDecorator{ + Decorator: &video.Decorator{ + FFProbe: s.FFProbe, + }, + Filter: file.FilterFunc(videoFileFilter), + }, + &file.FilteredDecorator{ + Decorator: &file_image.Decorator{ + FFProbe: s.FFProbe, + }, + Filter: file.FilterFunc(imageFileFilter), + }, + }, + FingerprintCalculator: &fingerprintCalculator{s.Config}, + FS: &file.OsFS{}, + } + scanJob := ScanJob{ - scanner: s.Scanner, + scanner: scanner, input: input, subscriptions: s.scanSubs, } @@ -160,7 +183,7 @@ func (s *Manager) RunSingleTask(ctx context.Context, t Task) int { } func (s *Manager) Generate(ctx context.Context, input GenerateMetadataInput) (int, error) { - if err := s.validateFFMPEG(); err != nil { + if err := s.validateFFmpeg(); err != nil { return 0, err } if err := instance.Paths.Generated.EnsureTmpDir(); err != nil { @@ -254,8 +277,16 @@ type CleanMetadataInput struct { } func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { + cleaner := &file.Cleaner{ + FS: &file.OsFS{}, + Repository: file.NewRepository(s.Repository), + Handlers: []file.CleanHandler{ + &cleanHandler{}, + }, + } + j := cleanJob{ - cleaner: s.Cleaner, + cleaner: cleaner, repository: s.Repository, sceneService: s.SceneService, imageService: s.ImageService, diff --git a/internal/manager/models.go b/internal/manager/models.go new file mode 100644 index 000000000..ab7a09bc0 --- /dev/null +++ b/internal/manager/models.go @@ -0,0 +1,36 @@ +package manager + +import ( + "github.com/stashapp/stash/internal/manager/config" +) + +type SystemStatus struct { + DatabaseSchema *int `json:"databaseSchema"` + DatabasePath *string `json:"databasePath"` + ConfigPath *string `json:"configPath"` + AppSchema int `json:"appSchema"` + Status SystemStatusEnum `json:"status"` + Os string `json:"os"` + WorkingDir string `json:"working_dir"` + HomeDir string `json:"home_dir"` +} + +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"` + // Empty to indicate default + CacheLocation string `json:"cacheLocation"` + + StoreBlobsInDatabase bool `json:"storeBlobsInDatabase"` + // Empty to indicate default + BlobsLocation string `json:"blobsLocation"` +} + +type MigrateInput struct { + BackupPath string `json:"backupPath"` +} diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index 38e029252..d33ac1609 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -144,7 +144,7 @@ type cleanFilter struct { scanFilter } -func newCleanFilter(c *config.Instance) *cleanFilter { +func newCleanFilter(c *config.Config) *cleanFilter { return &cleanFilter{ scanFilter: scanFilter{ extensionConfig: newExtensionConfig(c), diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 08d01c311..89ba668ae 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -104,7 +104,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { } g := &generate.Generator{ - Encoder: instance.FFMPEG, + Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, LockManager: instance.ReadLockManager, MarkerPaths: instance.Paths.SceneMarkers, diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go index e8f98cd17..f3b9d4c13 100644 --- a/internal/manager/task_generate_clip_preview.go +++ b/internal/manager/task_generate_clip_preview.go @@ -33,7 +33,7 @@ func (t *GenerateClipPreviewTask) Start(ctx context.Context) { Preset: GetInstance().Config.GetPreviewPreset().String(), } - encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions) + encoder := image.NewThumbnailEncoder(GetInstance().FFMpeg, GetInstance().FFProbe, clipPreviewOptions) err := encoder.GetPreview(filePath, prevPath, models.DefaultGthumbWidth) if err != nil { logger.Errorf("getting preview for image %s: %w", filePath, err) diff --git a/internal/manager/task_generate_phash.go b/internal/manager/task_generate_phash.go index 1d04d6a8a..ec869b128 100644 --- a/internal/manager/task_generate_phash.go +++ b/internal/manager/task_generate_phash.go @@ -25,7 +25,7 @@ func (t *GeneratePhashTask) Start(ctx context.Context) { return } - hash, err := videophash.Generate(instance.FFMPEG, t.File) + hash, err := videophash.Generate(instance.FFMpeg, t.File) if err != nil { logger.Errorf("error generating phash: %s", err.Error()) logErrorOutput(err) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index f8bff653c..77ad2be34 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -56,7 +56,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { logger.Debugf("Creating screenshot for %s", scenePath) g := generate.Generator{ - Encoder: instance.FFMPEG, + Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, LockManager: instance.ReadLockManager, ScenePaths: instance.Paths.Scene, diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 26985e86f..596a06143 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -90,7 +90,7 @@ type extensionConfig struct { zipExt []string } -func newExtensionConfig(c *config.Instance) extensionConfig { +func newExtensionConfig(c *config.Config) extensionConfig { return extensionConfig{ vidExt: c.GetVideoExtensions(), imgExt: c.GetImageExtensions(), @@ -126,7 +126,7 @@ type handlerRequiredFilter struct { videoFileNamingAlgorithm models.HashAlgorithm } -func newHandlerRequiredFilter(c *config.Instance, repo models.Repository) *handlerRequiredFilter { +func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handlerRequiredFilter { processes := c.GetParallelTasksWithAutoDetection() return &handlerRequiredFilter{ @@ -239,7 +239,7 @@ type scanFilter struct { minModTime time.Time } -func newScanFilter(c *config.Instance, repo models.Repository, minModTime time.Time) *scanFilter { +func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter { return &scanFilter{ extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, @@ -325,6 +325,18 @@ func (c *scanConfig) GetCreateGalleriesFromFolders() bool { return c.createGalleriesFromFolders } +func videoFileFilter(ctx context.Context, f models.File) bool { + return useAsVideo(f.Base().Path) +} + +func imageFileFilter(ctx context.Context, f models.File) bool { + return useAsImage(f.Base().Path) +} + +func galleryFileFilter(ctx context.Context, f models.File) bool { + return isZip(f.Base().Basename) +} + func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler { mgr := GetInstance() c := mgr.Config @@ -464,7 +476,7 @@ func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image Preset: c.GetPreviewPreset().String(), } - encoder := image.NewThumbnailEncoder(mgr.FFMPEG, mgr.FFProbe, clipPreviewOptions) + encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { @@ -547,7 +559,7 @@ func (g *sceneGenerators) Generate(ctx context.Context, s *models.Scene, f *mode options := getGeneratePreviewOptions(GeneratePreviewOptionsInput{}) generator := &generate.Generator{ - Encoder: mgr.FFMPEG, + Encoder: mgr.FFMpeg, FFMpegConfig: mgr.Config, LockManager: mgr.ReadLockManager, MarkerPaths: g.paths.SceneMarkers, diff --git a/pkg/ffmpeg/downloader.go b/pkg/ffmpeg/downloader.go index b98e20f6f..d3af72bd7 100644 --- a/pkg/ffmpeg/downloader.go +++ b/pkg/ffmpeg/downloader.go @@ -29,7 +29,7 @@ func GetPaths(paths []string) (string, string) { // Check if ffmpeg exists in the config directory if ffmpegPath == "" { - ffmpegPath = fsutil.FindInPaths(paths, getFFMPEGFilename()) + ffmpegPath = fsutil.FindInPaths(paths, getFFMpegFilename()) } if ffprobePath == "" { ffprobePath = fsutil.FindInPaths(paths, getFFProbeFilename()) @@ -39,7 +39,7 @@ func GetPaths(paths []string) (string, string) { } func Download(ctx context.Context, configDirectory string) error { - for _, url := range getFFMPEGURL() { + for _, url := range getFFmpegURL() { err := downloadSingle(ctx, configDirectory, url) if err != nil { return err @@ -47,7 +47,7 @@ func Download(ctx context.Context, configDirectory string) error { } // validate that the urls contained what we needed - executables := []string{getFFMPEGFilename(), getFFProbeFilename()} + executables := []string{getFFMpegFilename(), getFFProbeFilename()} for _, executable := range executables { _, err := os.Stat(filepath.Join(configDirectory, executable)) if err != nil { @@ -173,7 +173,7 @@ func downloadSingle(ctx context.Context, configDirectory, url string) error { return nil } -func getFFMPEGURL() []string { +func getFFmpegURL() []string { var urls []string switch runtime.GOOS { case "darwin": @@ -195,7 +195,7 @@ func getFFMPEGURL() []string { return urls } -func getFFMPEGFilename() string { +func getFFMpegFilename() string { if runtime.GOOS == "windows" { return "ffmpeg.exe" } @@ -209,7 +209,7 @@ func getFFProbeFilename() string { return "ffprobe" } -// Checks if FFMPEG in the path has the correct flags +// Checks if ffmpeg in the path has the correct flags func pathBinaryHasCorrectFlags() bool { ffmpegPath, err := exec.LookPath("ffmpeg") if err != nil { diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index b809de93a..206787895 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" + "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/common" @@ -123,46 +124,31 @@ func (c *Cache) RegisterSessionStore(sessionStore *session.Store) { c.sessionStore = sessionStore } -// LoadPlugins clears the plugin cache and loads from the plugin path. -// In the event of an error during loading, the cache will be left empty. -func (c *Cache) LoadPlugins() error { - c.plugins = nil - plugins, err := loadPlugins(c.config.GetPluginsPath()) - if err != nil { - return err - } - - c.plugins = plugins - return nil -} - -func loadPlugins(path string) ([]Config, error) { +// ReloadPlugins clears the plugin cache and loads from the plugin path. +// If a plugin cannot be loaded, an error is logged and the plugin is skipped. +func (c *Cache) ReloadPlugins() { + path := c.config.GetPluginsPath() plugins := make([]Config, 0) logger.Debugf("Reading plugin configs from %s", path) - pluginFiles := []string{} - err := filepath.Walk(path, func(fp string, f os.FileInfo, err error) error { + + err := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error { if filepath.Ext(fp) == ".yml" { - pluginFiles = append(pluginFiles, fp) + plugin, err := loadPluginFromYAMLFile(fp) + if err != nil { + logger.Errorf("Error loading plugin %s: %v", fp, err) + } else { + plugins = append(plugins, *plugin) + } } return nil }) if err != nil { - - return nil, err + logger.Errorf("Error reading plugin configs: %v", err) } - for _, file := range pluginFiles { - plugin, err := loadPluginFromYAMLFile(file) - if err != nil { - logger.Errorf("Error loading plugin %s: %s", file, err.Error()) - } else { - plugins = append(plugins, *plugin) - } - } - - return plugins, nil + c.plugins = plugins } func (c Cache) enabledPlugins() []Config { diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index ccb7ceb11..498a4ce39 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -133,32 +133,27 @@ func newClient(gc GlobalConfig) *http.Client { return client } -// NewCache returns a new Cache loading scraper configurations from the -// scraper path provided in the global config object. It returns a new -// instance and an error if the scraper directory could not be loaded. +// NewCache returns a new Cache. // -// Scraper configurations are loaded from yml files in the provided scrapers -// directory and any subdirectories. -func NewCache(globalConfig GlobalConfig, repo Repository) (*Cache, error) { +// Scraper configurations are loaded from yml files in the scrapers +// directory in the config and any subdirectories. +// +// Does not load scrapers. Scrapers will need to be +// loaded explicitly using ReloadScrapers. +func NewCache(globalConfig GlobalConfig, repo Repository) *Cache { // HTTP Client setup client := newClient(globalConfig) - ret := &Cache{ + return &Cache{ client: client, globalConfig: globalConfig, repository: repo, } - - var err error - ret.scrapers, err = ret.loadScrapers() - if err != nil { - return nil, err - } - - return ret, nil } -func (c *Cache) loadScrapers() (map[string]scraper, error) { +// ReloadScrapers clears the scraper cache and reloads from the scraper path. +// If a scraper cannot be loaded, an error is logged and the scraper is skipped. +func (c *Cache) ReloadScrapers() { path := c.globalConfig.GetScrapersPath() scrapers := make(map[string]scraper) @@ -185,23 +180,9 @@ func (c *Cache) loadScrapers() (map[string]scraper, error) { if err != nil { logger.Errorf("Error reading scraper configs: %v", err) - return nil, err - } - - return scrapers, nil -} - -// ReloadScrapers clears the scraper cache and reloads from the scraper path. -// In the event of an error during loading, the cache will be left empty. -func (c *Cache) ReloadScrapers() error { - c.scrapers = nil - scrapers, err := c.loadScrapers() - if err != nil { - return err } c.scrapers = scrapers - return nil } // ListScrapers lists scrapers matching one of the given types. diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 9f5f75f5b..e043be2a6 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sqlite" @@ -535,6 +536,10 @@ func indexFromID(ids []int, id int) int { var db *sqlite.Database func TestMain(m *testing.M) { + // initialise empty config - needed by some migrations + _ = config.InitializeEmpty() + + ret := runTests(m) os.Exit(ret) } diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index 3d7aafb47..d14323f9f 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -43,7 +43,7 @@ export const Setup: React.FC = () => { const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState(false); const [blobsLocation, setBlobsLocation] = useState(""); const [loading, setLoading] = useState(false); - const [setupError, setSetupError] = useState(""); + const [setupError, setSetupError] = useState(); const intl = useIntl(); const history = useHistory(); @@ -617,7 +617,11 @@ export const Setup: React.FC = () => { }, }); } catch (e) { - if (e instanceof Error) setSetupError(e.message ?? e.toString()); + if (e instanceof Error && e.message) { + setSetupError(e.message); + } else { + setSetupError(String(e)); + } } finally { setLoading(false); next(); @@ -737,6 +741,11 @@ export const Setup: React.FC = () => { } function renderError() { + function onBackClick() { + setSetupError(undefined); + goBack(2); + } + return ( <>
@@ -758,7 +767,7 @@ export const Setup: React.FC = () => {
-
@@ -851,7 +860,7 @@ export const Setup: React.FC = () => { } function renderFinish() { - if (setupError) { + if (setupError !== undefined) { return renderError(); }