diff --git a/docker/build/x86_64/Dockerfile b/docker/build/x86_64/Dockerfile index 6eabf1105..3a2d8a198 100644 --- a/docker/build/x86_64/Dockerfile +++ b/docker/build/x86_64/Dockerfile @@ -53,6 +53,8 @@ FROM ubuntu:20.04 as app RUN apt-get update && apt-get -y install ca-certificates COPY --from=compiler /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/ +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 833037c36..1db050163 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,6 +12,8 @@ FROM ubuntu:20.04 as app run apt update && apt install -y python3 python3 python-is-python3 python3-requests ffmpeg && rm -rf /var/lib/apt/lists/* COPY --from=prep /stash /usr/bin/ +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/docker/develop/x86_64/Dockerfile b/docker/develop/x86_64/Dockerfile index cca25aafd..c2efef3a1 100644 --- a/docker/develop/x86_64/Dockerfile +++ b/docker/develop/x86_64/Dockerfile @@ -20,5 +20,8 @@ RUN curl --http1.1 -o /ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/f FROM ubuntu:20.04 as app RUN apt-get update && apt-get -y install ca-certificates COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/ + +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/docker/production/x86_64/Dockerfile b/docker/production/x86_64/Dockerfile index bc152161c..95a2516ed 100644 --- a/docker/production/x86_64/Dockerfile +++ b/docker/production/x86_64/Dockerfile @@ -20,5 +20,8 @@ RUN curl --http1.1 -o /ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/f FROM ubuntu:20.04 as app RUN apt-get update && apt-get -y install ca-certificates COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/ + +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/graphql/documents/mutations/config.graphql b/graphql/documents/mutations/config.graphql index 3b2dc6cb2..25bc7f01d 100644 --- a/graphql/documents/mutations/config.graphql +++ b/graphql/documents/mutations/config.graphql @@ -1,3 +1,11 @@ +mutation Setup($input: SetupInput!) { + setup(input: $input) +} + +mutation Migrate($input: MigrateInput!) { + migrate(input: $input) +} + mutation ConfigureGeneral($input: ConfigGeneralInput!) { configureGeneral(input: $input) { ...ConfigGeneralData diff --git a/graphql/documents/queries/settings/metadata.graphql b/graphql/documents/queries/settings/metadata.graphql index 376f8e4a0..048d287e0 100644 --- a/graphql/documents/queries/settings/metadata.graphql +++ b/graphql/documents/queries/settings/metadata.graphql @@ -5,3 +5,12 @@ query JobStatus { message } } + +query SystemStatus { + systemStatus { + databaseSchema + databasePath + appSchema + status + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 048aa37d4..08e8834be 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -106,7 +106,7 @@ type Query { directory(path: String): Directory! # Metadata - + systemStatus: SystemStatus! jobStatus: MetadataUpdateStatus! # Get everything @@ -126,6 +126,9 @@ type Query { } type Mutation { + setup(input: SetupInput!): Boolean! + migrate(input: MigrateInput!): Boolean! + sceneUpdate(input: SceneUpdateInput!): Scene bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] sceneDestroy(input: SceneDestroyInput!): Boolean! diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index df1c3415e..9cf463125 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -1,3 +1,13 @@ +input SetupInput { + """Empty to indicate $HOME/.stash/config.yml default""" + configLocation: String! + stashes: [StashConfigInput!]! + """Empty to indicate default""" + databaseFile: String! + """Empty to indicate default""" + generatedLocation: String! +} + enum StreamingResolutionEnum { "240p", LOW "480p", STANDARD diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 600d8f9c8..ac8185982 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -106,3 +106,20 @@ input ImportObjectsInput { input BackupDatabaseInput { download: Boolean } + +enum SystemStatusEnum { + SETUP + NEEDS_MIGRATION + OK +} + +type SystemStatus { + databaseSchema: Int + databasePath: String + appSchema: Int! + status: SystemStatusEnum! +} + +input MigrateInput { + backupPath: String! +} diff --git a/main.go b/main.go index e54d5cad7..bc2a83c6f 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,7 @@ package main import ( "github.com/stashapp/stash/pkg/api" - "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/manager" - "github.com/stashapp/stash/pkg/manager/config" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" @@ -13,12 +11,6 @@ import ( func main() { manager.Initialize() - - // perform the post-migration for new databases - if database.Initialize(config.GetDatabasePath()) { - manager.GetInstance().PostMigrate() - } - api.Start() blockForever() } diff --git a/pkg/api/migrate.go b/pkg/api/migrate.go deleted file mode 100644 index 4a7bcddf8..000000000 --- a/pkg/api/migrate.go +++ /dev/null @@ -1,96 +0,0 @@ -package api - -import ( - "fmt" - "html/template" - "net/http" - "os" - - "github.com/stashapp/stash/pkg/database" - "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/manager" -) - -type migrateData struct { - ExistingVersion uint - MigrateVersion uint - BackupPath string -} - -func getMigrateData() migrateData { - return migrateData{ - ExistingVersion: database.Version(), - MigrateVersion: database.AppSchemaVersion(), - BackupPath: database.DatabaseBackupPath(), - } -} - -func getMigrateHandler(w http.ResponseWriter, r *http.Request) { - if !database.NeedsMigration() { - http.Redirect(w, r, "/", 301) - return - } - - data, _ := setupUIBox.Find("migrate.html") - templ, err := template.New("Migrate").Parse(string(data)) - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - return - } - - err = templ.Execute(w, getMigrateData()) - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - } -} - -func doMigrateHandler(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - } - - formBackupPath := r.Form.Get("backuppath") - - // always backup so that we can roll back to the previous version if - // migration fails - backupPath := formBackupPath - if formBackupPath == "" { - backupPath = database.DatabaseBackupPath() - } - - // perform database backup - if err = database.Backup(database.DB, backupPath); err != nil { - http.Error(w, fmt.Sprintf("error backing up database: %s", err), 500) - return - } - - err = database.RunMigrations() - if err != nil { - errStr := fmt.Sprintf("error performing migration: %s", err) - - // roll back to the backed up version - restoreErr := database.RestoreFromBackup(backupPath) - if restoreErr != nil { - errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr) - } else { - errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr - } - - http.Error(w, errStr, 500) - return - } - - // perform post-migration operations - manager.GetInstance().PostMigrate() - - // if no backup path was provided, then delete the created backup - if formBackupPath == "" { - err = os.Remove(backupPath) - if err != nil { - logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error()) - } - } - - http.Redirect(w, r, "/", 301) -} diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index 40b71c63f..e5cd71c9e 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -13,7 +13,18 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +func (r *mutationResolver) Setup(ctx context.Context, input models.SetupInput) (bool, error) { + err := manager.GetInstance().Setup(input) + return err == nil, err +} + +func (r *mutationResolver) Migrate(ctx context.Context, input models.MigrateInput) (bool, error) { + err := manager.GetInstance().Migrate(input) + return err == nil, err +} + func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.ConfigGeneralInput) (*models.ConfigGeneralResult, error) { + c := config.GetInstance() if len(input.Stashes) > 0 { for _, s := range input.Stashes { exists, err := utils.DirExists(s.Path) @@ -21,7 +32,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co return makeConfigGeneralResult(), err } } - config.Set(config.Stash, input.Stashes) + c.Set(config.Stash, input.Stashes) } if input.DatabasePath != nil { @@ -29,138 +40,140 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" { return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or sqlite3") } - config.Set(config.Database, input.DatabasePath) + c.Set(config.Database, input.DatabasePath) } if input.GeneratedPath != nil { if err := utils.EnsureDir(*input.GeneratedPath); err != nil { return makeConfigGeneralResult(), err } - config.Set(config.Generated, input.GeneratedPath) + c.Set(config.Generated, input.GeneratedPath) } if input.CachePath != nil { - if err := utils.EnsureDir(*input.CachePath); err != nil { - return makeConfigGeneralResult(), err + if *input.CachePath != "" { + if err := utils.EnsureDir(*input.CachePath); err != nil { + return makeConfigGeneralResult(), err + } } - config.Set(config.Cache, input.CachePath) + c.Set(config.Cache, input.CachePath) } if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 { return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5") } - if input.VideoFileNamingAlgorithm != config.GetVideoFileNamingAlgorithm() { + if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() { // validate changing VideoFileNamingAlgorithm if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil { return makeConfigGeneralResult(), err } - config.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm) + c.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm) } - config.Set(config.CalculateMD5, input.CalculateMd5) + c.Set(config.CalculateMD5, input.CalculateMd5) if input.ParallelTasks != nil { - config.Set(config.ParallelTasks, *input.ParallelTasks) + c.Set(config.ParallelTasks, *input.ParallelTasks) } if input.PreviewSegments != nil { - config.Set(config.PreviewSegments, *input.PreviewSegments) + c.Set(config.PreviewSegments, *input.PreviewSegments) } if input.PreviewSegmentDuration != nil { - config.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration) + c.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration) } if input.PreviewExcludeStart != nil { - config.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart) + c.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart) } if input.PreviewExcludeEnd != nil { - config.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd) + c.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd) } if input.PreviewPreset != nil { - config.Set(config.PreviewPreset, input.PreviewPreset.String()) + c.Set(config.PreviewPreset, input.PreviewPreset.String()) } if input.MaxTranscodeSize != nil { - config.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) + c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) } if input.MaxStreamingTranscodeSize != nil { - config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) + c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) } if input.Username != nil { - config.Set(config.Username, input.Username) + c.Set(config.Username, input.Username) } if input.Password != nil { // bit of a hack - check if the passed in password is the same as the stored hash // and only set if they are different - currentPWHash := config.GetPasswordHash() + currentPWHash := c.GetPasswordHash() if *input.Password != currentPWHash { - config.SetPassword(*input.Password) + c.SetPassword(*input.Password) } } if input.MaxSessionAge != nil { - config.Set(config.MaxSessionAge, *input.MaxSessionAge) + c.Set(config.MaxSessionAge, *input.MaxSessionAge) } if input.LogFile != nil { - config.Set(config.LogFile, input.LogFile) + c.Set(config.LogFile, input.LogFile) } - config.Set(config.LogOut, input.LogOut) - config.Set(config.LogAccess, input.LogAccess) + c.Set(config.LogOut, input.LogOut) + c.Set(config.LogAccess, input.LogAccess) - if input.LogLevel != config.GetLogLevel() { - config.Set(config.LogLevel, input.LogLevel) + if input.LogLevel != c.GetLogLevel() { + c.Set(config.LogLevel, input.LogLevel) logger.SetLogLevel(input.LogLevel) } if input.Excludes != nil { - config.Set(config.Exclude, input.Excludes) + c.Set(config.Exclude, input.Excludes) } if input.ImageExcludes != nil { - config.Set(config.ImageExclude, input.ImageExcludes) + c.Set(config.ImageExclude, input.ImageExcludes) } if input.VideoExtensions != nil { - config.Set(config.VideoExtensions, input.VideoExtensions) + c.Set(config.VideoExtensions, input.VideoExtensions) } if input.ImageExtensions != nil { - config.Set(config.ImageExtensions, input.ImageExtensions) + c.Set(config.ImageExtensions, input.ImageExtensions) } if input.GalleryExtensions != nil { - config.Set(config.GalleryExtensions, input.GalleryExtensions) + c.Set(config.GalleryExtensions, input.GalleryExtensions) } - config.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders) + c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders) refreshScraperCache := false if input.ScraperUserAgent != nil { - config.Set(config.ScraperUserAgent, input.ScraperUserAgent) + c.Set(config.ScraperUserAgent, input.ScraperUserAgent) refreshScraperCache = true } if input.ScraperCDPPath != nil { - config.Set(config.ScraperCDPPath, input.ScraperCDPPath) + c.Set(config.ScraperCDPPath, input.ScraperCDPPath) refreshScraperCache = true } - config.Set(config.ScraperCertCheck, input.ScraperCertCheck) + c.Set(config.ScraperCertCheck, input.ScraperCertCheck) if input.StashBoxes != nil { - if err := config.ValidateStashBoxes(input.StashBoxes); err != nil { + if err := c.ValidateStashBoxes(input.StashBoxes); err != nil { return nil, err } - config.Set(config.StashBoxes, input.StashBoxes) + c.Set(config.StashBoxes, input.StashBoxes) } - if err := config.Write(); err != nil { + if err := c.Write(); err != nil { return makeConfigGeneralResult(), err } @@ -173,36 +186,37 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co } func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) { + c := config.GetInstance() if input.MenuItems != nil { - config.Set(config.MenuItems, input.MenuItems) + c.Set(config.MenuItems, input.MenuItems) } if input.SoundOnPreview != nil { - config.Set(config.SoundOnPreview, *input.SoundOnPreview) + c.Set(config.SoundOnPreview, *input.SoundOnPreview) } if input.WallShowTitle != nil { - config.Set(config.WallShowTitle, *input.WallShowTitle) + c.Set(config.WallShowTitle, *input.WallShowTitle) } if input.WallPlayback != nil { - config.Set(config.WallPlayback, *input.WallPlayback) + c.Set(config.WallPlayback, *input.WallPlayback) } if input.MaximumLoopDuration != nil { - config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration) + c.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration) } if input.AutostartVideo != nil { - config.Set(config.AutostartVideo, *input.AutostartVideo) + c.Set(config.AutostartVideo, *input.AutostartVideo) } if input.ShowStudioAsText != nil { - config.Set(config.ShowStudioAsText, *input.ShowStudioAsText) + c.Set(config.ShowStudioAsText, *input.ShowStudioAsText) } if input.Language != nil { - config.Set(config.Language, *input.Language) + c.Set(config.Language, *input.Language) } css := "" @@ -211,13 +225,13 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. css = *input.CSS } - config.SetCSS(css) + c.SetCSS(css) if input.CSSEnabled != nil { - config.Set(config.CSSEnabled, *input.CSSEnabled) + c.Set(config.CSSEnabled, *input.CSSEnabled) } - if err := config.Write(); err != nil { + if err := c.Write(); err != nil { return makeConfigInterfaceResult(), err } @@ -225,9 +239,11 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. } func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) { + c := config.GetInstance() + var newAPIKey string if input.Clear == nil || !*input.Clear { - username := config.GetUsername() + username := c.GetUsername() if username != "" { var err error newAPIKey, err = manager.GenerateAPIKey(username) @@ -237,8 +253,8 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene } } - config.Set(config.ApiKey, newAPIKey) - if err := config.Write(); err != nil { + c.Set(config.ApiKey, newAPIKey) + if err := c.Write(); err != nil { return newAPIKey, err } diff --git a/pkg/api/resolver_mutation_metadata.go b/pkg/api/resolver_mutation_metadata.go index 19ce4f279..82f678c5c 100644 --- a/pkg/api/resolver_mutation_metadata.go +++ b/pkg/api/resolver_mutation_metadata.go @@ -20,12 +20,15 @@ func (r *mutationResolver) MetadataScan(ctx context.Context, input models.ScanMe } func (r *mutationResolver) MetadataImport(ctx context.Context) (string, error) { - manager.GetInstance().Import() + if err := manager.GetInstance().Import(); err != nil { + return "", err + } + return "todo", nil } func (r *mutationResolver) ImportObjects(ctx context.Context, input models.ImportObjectsInput) (string, error) { - t, err := manager.CreateImportTask(config.GetVideoFileNamingAlgorithm(), input) + t, err := manager.CreateImportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input) if err != nil { return "", err } @@ -39,12 +42,15 @@ func (r *mutationResolver) ImportObjects(ctx context.Context, input models.Impor } func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) { - manager.GetInstance().Export() + if err := manager.GetInstance().Export(); err != nil { + return "", err + } + return "todo", nil } func (r *mutationResolver) ExportObjects(ctx context.Context, input models.ExportObjectsInput) (*string, error) { - t := manager.CreateExportTask(config.GetVideoFileNamingAlgorithm(), input) + t := manager.CreateExportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input) wg, err := manager.GetInstance().RunSingleTask(t) if err != nil { return nil, err diff --git a/pkg/api/resolver_mutation_plugin.go b/pkg/api/resolver_mutation_plugin.go index c5a83e8fc..3e65366c2 100644 --- a/pkg/api/resolver_mutation_plugin.go +++ b/pkg/api/resolver_mutation_plugin.go @@ -23,6 +23,7 @@ func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, t } } + config := config.GetInstance() serverConnection := common.StashServerConnection{ Scheme: "http", Port: config.GetPort(), diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index ba73aecb1..2a187bf1b 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -139,7 +139,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator // only update the cover image if provided and everything else was successful if coverImageData != nil { - err = manager.SetSceneScreenshot(scene.GetHash(config.GetVideoFileNamingAlgorithm()), coverImageData) + err = manager.SetSceneScreenshot(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData) if err != nil { return nil, err } @@ -384,7 +384,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD // if delete generated is true, then delete the generated files // for the scene if input.DeleteGenerated != nil && *input.DeleteGenerated { - manager.DeleteGeneratedSceneFiles(scene, config.GetVideoFileNamingAlgorithm()) + manager.DeleteGeneratedSceneFiles(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) } // if delete file is true, then delete the file as well @@ -426,7 +426,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene f() } - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() for _, scene := range scenes { // if delete generated is true, then delete the generated files // for the scene @@ -586,7 +586,7 @@ func (r *mutationResolver) changeMarker(ctx context.Context, changeType int, cha // remove the marker preview if the timestamp was changed if scene != nil && existingMarker != nil && existingMarker.Seconds != changedMarker.Seconds { seconds := int(existingMarker.Seconds) - manager.DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm()) + manager.DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm()) } return sceneMarker, nil diff --git a/pkg/api/resolver_mutation_stash_box.go b/pkg/api/resolver_mutation_stash_box.go index 303b66dae..7cb7134ad 100644 --- a/pkg/api/resolver_mutation_stash_box.go +++ b/pkg/api/resolver_mutation_stash_box.go @@ -10,7 +10,7 @@ import ( ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input models.StashBoxFingerprintSubmissionInput) (bool, error) { - boxes := config.GetStashBoxes() + boxes := config.GetInstance().GetStashBoxes() if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index cc8de2a3e..c11d8dc0c 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -34,6 +34,7 @@ func makeConfigResult() *models.ConfigResult { } func makeConfigGeneralResult() *models.ConfigGeneralResult { + config := config.GetInstance() logFile := config.GetLogFile() maxTranscodeSize := config.GetMaxTranscodeSize() @@ -81,6 +82,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { } func makeConfigInterfaceResult() *models.ConfigInterfaceResult { + config := config.GetInstance() menuItems := config.GetMenuItems() soundOnPreview := config.GetSoundOnPreview() wallShowTitle := config.GetWallShowTitle() diff --git a/pkg/api/resolver_query_metadata.go b/pkg/api/resolver_query_metadata.go index 862d91eae..cb12d96e7 100644 --- a/pkg/api/resolver_query_metadata.go +++ b/pkg/api/resolver_query_metadata.go @@ -17,3 +17,7 @@ func (r *queryResolver) JobStatus(ctx context.Context) (*models.MetadataUpdateSt return &ret, nil } + +func (r *queryResolver) SystemStatus(ctx context.Context) (*models.SystemStatus, error) { + return manager.GetInstance().GetSystemStatus(), nil +} diff --git a/pkg/api/resolver_query_scene.go b/pkg/api/resolver_query_scene.go index 64110e70d..236913689 100644 --- a/pkg/api/resolver_query_scene.go +++ b/pkg/api/resolver_query_scene.go @@ -30,5 +30,5 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*models baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID) - return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetMaxStreamingTranscodeSize()) + return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetInstance().GetMaxStreamingTranscodeSize()) } diff --git a/pkg/api/resolver_query_scraper.go b/pkg/api/resolver_query_scraper.go index 7a197a025..0daf80154 100644 --- a/pkg/api/resolver_query_scraper.go +++ b/pkg/api/resolver_query_scraper.go @@ -89,7 +89,7 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models } func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxQueryInput) ([]*models.ScrapedScene, error) { - boxes := config.GetStashBoxes() + boxes := config.GetInstance().GetStashBoxes() if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) diff --git a/pkg/api/routes_scene.go b/pkg/api/routes_scene.go index 328983be9..c83876e8a 100644 --- a/pkg/api/routes_scene.go +++ b/pkg/api/routes_scene.go @@ -69,7 +69,7 @@ func getSceneFileContainer(scene *models.Scene) ffmpeg.Container { func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo)) manager.RegisterStream(filepath, &w) @@ -158,7 +158,7 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec) options.StartTime = startTime - options.MaxTranscodeSize = config.GetMaxStreamingTranscodeSize() + options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize() if requestedSize != "" { options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize) } @@ -178,7 +178,7 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) // fall back to the scene image blob if the file isn't present screenshotExists, _ := utils.FileExists(filepath) @@ -196,13 +196,13 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) utils.ServeFileNoCache(w, r, filepath) } func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } @@ -267,14 +267,14 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) w.Header().Set("Content-Type", "text/vtt") - filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) w.Header().Set("Content-Type", "image/jpeg") - filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } @@ -291,7 +291,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) http.Error(w, http.StatusText(500), 500) return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) http.ServeFile(w, r, filepath) } @@ -308,7 +308,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) http.Error(w, http.StatusText(500), 500) return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) // If the image doesn't exist, send the placeholder exists, _ := utils.FileExists(filepath) diff --git a/pkg/api/server.go b/pkg/api/server.go index e4e3a1fea..26dd34fbe 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -8,9 +8,7 @@ import ( "io/ioutil" "net/http" "net/url" - "os" "path" - "path/filepath" "runtime/debug" "strconv" "strings" @@ -22,7 +20,6 @@ import ( "github.com/gobuffalo/packr/v2" "github.com/gorilla/websocket" "github.com/rs/cors" - "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/manager/config" @@ -38,7 +35,6 @@ var githash string var uiBox *packr.Box //var legacyUiBox *packr.Box -var setupUIBox *packr.Box var loginUIBox *packr.Box const ApiKeyHeader = "ApiKey" @@ -50,6 +46,7 @@ func allowUnauthenticated(r *http.Request) bool { func authenticateHandler() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := config.GetInstance() ctx := r.Context() // translate api key into current user, if present @@ -61,13 +58,13 @@ func authenticateHandler() func(http.Handler) http.Handler { // match against configured API and set userID to the // configured username. In future, we'll want to // get the username from the key. - if config.GetAPIKey() != apiKey { + if c.GetAPIKey() != apiKey { w.Header().Add("WWW-Authenticate", `FormBased`) w.WriteHeader(http.StatusUnauthorized) return } - userID = config.GetUsername() + userID = c.GetUsername() } else { // handle session userID, err = getSessionUserID(w, r) @@ -80,7 +77,7 @@ func authenticateHandler() func(http.Handler) http.Handler { } // handle redirect if no user and user is required - if userID == "" && config.HasCredentials() && !allowUnauthenticated(r) { + if userID == "" && c.HasCredentials() && !allowUnauthenticated(r) { // if we don't have a userID, then redirect // if graphql was requested, we just return a forbidden error if r.URL.Path == "/graphql" { @@ -109,14 +106,11 @@ func authenticateHandler() func(http.Handler) http.Handler { } } -const setupEndPoint = "/setup" -const migrateEndPoint = "/migrate" const loginEndPoint = "/login" func Start() { uiBox = packr.New("UI Box", "../../ui/v2.5/build") //legacyUiBox = packr.New("UI Box", "../../ui/v1/dist/stash-frontend") - setupUIBox = packr.New("Setup UI Box", "../../ui/setup") loginUIBox = packr.New("Login UI Box", "../../ui/login") initSessionStore() @@ -128,15 +122,14 @@ func Start() { r.Use(authenticateHandler()) r.Use(middleware.Recoverer) - if config.GetLogAccess() { + c := config.GetInstance() + if c.GetLogAccess() { r.Use(middleware.Logger) } r.Use(middleware.DefaultCompress) r.Use(middleware.StripSlashes) r.Use(cors.AllowAll().Handler) r.Use(BaseURLMiddleware) - r.Use(ConfigCheckMiddleware) - r.Use(DatabaseCheckMiddleware) recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error { logger.Error(err) @@ -150,7 +143,7 @@ func Start() { return true }, }) - maxUploadSize := handler.UploadMaxSize(config.GetMaxUploadSize()) + maxUploadSize := handler.UploadMaxSize(c.GetMaxUploadSize()) websocketKeepAliveDuration := handler.WebsocketKeepAliveDuration(10 * time.Second) txnManager := manager.GetInstance().TxnManager @@ -191,12 +184,12 @@ func Start() { r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") - if !config.GetCSSEnabled() { + if !c.GetCSSEnabled() { return } // search for custom.css in current directory, then $HOME/.stash - fn := config.GetCSSPath() + fn := c.GetCSSPath() exists, _ := utils.FileExists(fn) if !exists { return @@ -205,21 +198,6 @@ func Start() { http.ServeFile(w, r, fn) }) - // Serve the migration UI - r.Get("/migrate", getMigrateHandler) - r.Post("/migrate", doMigrateHandler) - - // Serve the setup UI - r.HandleFunc("/setup*", func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - if ext == ".html" || ext == "" { - data, _ := setupUIBox.Find("index.html") - _, _ = w.Write(data) - } else { - r.URL.Path = strings.Replace(r.URL.Path, "/setup", "", 1) - http.FileServer(setupUIBox).ServeHTTP(w, r) - } - }) r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) if ext == ".html" || ext == "" { @@ -230,62 +208,9 @@ func Start() { http.FileServer(loginUIBox).ServeHTTP(w, r) } }) - r.Post("/init", func(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - } - stash := filepath.Clean(r.Form.Get("stash")) - generated := filepath.Clean(r.Form.Get("generated")) - metadata := filepath.Clean(r.Form.Get("metadata")) - cache := filepath.Clean(r.Form.Get("cache")) - //downloads := filepath.Clean(r.Form.Get("downloads")) // TODO - downloads := filepath.Join(metadata, "downloads") - - exists, _ := utils.DirExists(stash) - if !exists || stash == "." { - http.Error(w, fmt.Sprintf("the stash path either doesn't exist, or is not a directory <%s>. Go back and try again.", stash), 500) - return - } - - exists, _ = utils.DirExists(generated) - if !exists || generated == "." { - http.Error(w, fmt.Sprintf("the generated path either doesn't exist, or is not a directory <%s>. Go back and try again.", generated), 500) - return - } - - exists, _ = utils.DirExists(metadata) - if !exists || metadata == "." { - http.Error(w, fmt.Sprintf("the metadata path either doesn't exist, or is not a directory <%s> Go back and try again.", metadata), 500) - return - } - - exists, _ = utils.DirExists(cache) - if !exists || cache == "." { - http.Error(w, fmt.Sprintf("the cache path either doesn't exist, or is not a directory <%s> Go back and try again.", cache), 500) - return - } - - _ = os.Mkdir(downloads, 0755) - - // #536 - set stash as slice of strings - config.Set(config.Stash, []string{stash}) - config.Set(config.Generated, generated) - config.Set(config.Metadata, metadata) - config.Set(config.Cache, cache) - config.Set(config.Downloads, downloads) - if err := config.Write(); err != nil { - http.Error(w, fmt.Sprintf("there was an error saving the config file: %s", err), 500) - return - } - - manager.GetInstance().RefreshConfig() - - http.Redirect(w, r, "/", 301) - }) // Serve static folders - customServedFolders := config.GetCustomServedFolders() + customServedFolders := c.GetCustomServedFolders() if customServedFolders != nil { r.HandleFunc("/custom/*", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1) @@ -316,13 +241,13 @@ func Start() { } }) - displayHost := config.GetHost() + displayHost := c.GetHost() if displayHost == "0.0.0.0" { displayHost = "localhost" } - displayAddress := displayHost + ":" + strconv.Itoa(config.GetPort()) + displayAddress := displayHost + ":" + strconv.Itoa(c.GetPort()) - address := config.GetHost() + ":" + strconv.Itoa(config.GetPort()) + address := c.GetHost() + ":" + strconv.Itoa(c.GetPort()) if tlsConfig := makeTLSConfig(); tlsConfig != nil { httpsServer := &http.Server{ Addr: address, @@ -417,7 +342,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler { } baseURL := scheme + "://" + r.Host - externalHost := config.GetExternalHost() + externalHost := config.GetInstance().GetExternalHost() if externalHost != "" { baseURL = externalHost } @@ -428,34 +353,3 @@ func BaseURLMiddleware(next http.Handler) http.Handler { } return http.HandlerFunc(fn) } - -func ConfigCheckMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - shouldRedirect := ext == "" && r.Method == "GET" - if !config.IsValid() && shouldRedirect { - // #539 - don't redirect if loading login page - if !strings.HasPrefix(r.URL.Path, setupEndPoint) && !strings.HasPrefix(r.URL.Path, loginEndPoint) { - http.Redirect(w, r, setupEndPoint, http.StatusFound) - return - } - } - next.ServeHTTP(w, r) - }) -} - -func DatabaseCheckMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - shouldRedirect := ext == "" && r.Method == "GET" - if shouldRedirect && database.NeedsMigration() { - // #451 - don't redirect if loading login page - // #539 - or setup page - if !strings.HasPrefix(r.URL.Path, migrateEndPoint) && !strings.HasPrefix(r.URL.Path, loginEndPoint) && !strings.HasPrefix(r.URL.Path, setupEndPoint) { - http.Redirect(w, r, migrateEndPoint, http.StatusFound) - return - } - } - next.ServeHTTP(w, r) - }) -} diff --git a/pkg/api/session.go b/pkg/api/session.go index 8be4876bd..a81d37c9e 100644 --- a/pkg/api/session.go +++ b/pkg/api/session.go @@ -19,7 +19,7 @@ const userIDKey = "userID" const returnURLParam = "returnURL" -var sessionStore = sessions.NewCookieStore(config.GetSessionStoreKey()) +var sessionStore = sessions.NewCookieStore(config.GetInstance().GetSessionStoreKey()) type loginTemplateData struct { URL string @@ -27,7 +27,7 @@ type loginTemplateData struct { } func initSessionStore() { - sessionStore.MaxAge(config.GetMaxSessionAge()) + sessionStore.MaxAge(config.GetInstance().GetMaxSessionAge()) } func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) { @@ -45,7 +45,7 @@ func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) } func getLoginHandler(w http.ResponseWriter, r *http.Request) { - if !config.HasCredentials() { + if !config.GetInstance().HasCredentials() { http.Redirect(w, r, "/", http.StatusFound) return } @@ -66,7 +66,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { password := r.FormValue("password") // authenticate the user - if !config.ValidateCredentials(username, password) { + if !config.GetInstance().ValidateCredentials(username, password) { // redirect back to the login page with an error redirectToLogin(w, url, "Username or password is invalid") return diff --git a/pkg/database/database.go b/pkg/database/database.go index 8082e0978..95bcb9081 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -26,8 +26,27 @@ var dbPath string var appSchemaVersion uint = 20 var databaseSchemaVersion uint +var ( + // ErrMigrationNeeded indicates that a database migration is needed + // before the database can be initialized + ErrMigrationNeeded = errors.New("database migration required") + + // ErrDatabaseNotInitialized indicates that the database is not + // initialized, usually due to an incomplete configuration. + ErrDatabaseNotInitialized = errors.New("database not initialized") +) + const sqlite3Driver = "sqlite3ex" +// Ready returns an error if the database is not ready to begin transactions. +func Ready() error { + if DB == nil { + return ErrDatabaseNotInitialized + } + + return nil +} + func init() { // register custom driver with regexp function registerCustomDriver() @@ -37,20 +56,20 @@ func init() { // performs a full migration to the latest schema version. Otherwise, any // necessary migrations must be run separately using RunMigrations. // Returns true if the database is new. -func Initialize(databasePath string) bool { +func Initialize(databasePath string) error { dbPath = databasePath if err := getDatabaseSchemaVersion(); err != nil { - panic(err) + return fmt.Errorf("error getting database schema version: %s", err.Error()) } if databaseSchemaVersion == 0 { // new database, just run the migrations if err := RunMigrations(); err != nil { - panic(err) + return fmt.Errorf("error running initial schema migrations: %s", err.Error()) } // RunMigrations calls Initialise. Just return - return true + return nil } else { if databaseSchemaVersion > appSchemaVersion { panic(fmt.Sprintf("Database schema version %d is incompatible with required schema version %d", databaseSchemaVersion, appSchemaVersion)) @@ -59,7 +78,7 @@ func Initialize(databasePath string) bool { // if migration is needed, then don't open the connection if NeedsMigration() { logger.Warnf("Database schema version %d does not match required schema version %d.", databaseSchemaVersion, appSchemaVersion) - return false + return nil } } @@ -67,7 +86,7 @@ func Initialize(databasePath string) bool { DB = open(databasePath, disableForeignKeys) WriteMu = &sync.Mutex{} - return false + return nil } func open(databasePath string, disableForeignKeys bool) *sqlx.DB { @@ -150,6 +169,10 @@ func AppSchemaVersion() uint { return appSchemaVersion } +func DatabasePath() string { + return dbPath +} + func DatabaseBackupPath() string { return fmt.Sprintf("%s.%d.%s", dbPath, databaseSchemaVersion, time.Now().Format("20060102_150405")) } diff --git a/pkg/manager/apikey.go b/pkg/manager/apikey.go index e6423d362..a01a9b221 100644 --- a/pkg/manager/apikey.go +++ b/pkg/manager/apikey.go @@ -28,7 +28,7 @@ func GenerateAPIKey(userID string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - ss, err := token.SignedString(config.GetJWTSignKey()) + ss, err := token.SignedString(config.GetInstance().GetJWTSignKey()) if err != nil { return "", err } @@ -40,7 +40,7 @@ func GenerateAPIKey(userID string) (string, error) { func GetUserIDFromAPIKey(apiKey string) (string, error) { claims := &APIKeyClaims{} token, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) { - return config.GetJWTSignKey(), nil + return config.GetInstance().GetJWTSignKey(), nil }) if err != nil { diff --git a/pkg/manager/checksum.go b/pkg/manager/checksum.go index 244549dab..a545008b6 100644 --- a/pkg/manager/checksum.go +++ b/pkg/manager/checksum.go @@ -31,9 +31,11 @@ func setInitialMD5Config(txnManager models.TransactionManager) { defaultAlgorithm = models.HashAlgorithmMd5 } + // TODO - this should use the config instance viper.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm) viper.SetDefault(config.CalculateMD5, usingMD5) + config := config.GetInstance() if err := config.Write(); err != nil { logger.Errorf("Error while writing configuration file: %s", err.Error()) } diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 46fd0c2c5..1ce880b1a 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -1,7 +1,9 @@ package config import ( + "fmt" "runtime" + "strings" "golang.org/x/crypto/bcrypt" @@ -126,33 +128,64 @@ const LogAccess = "logAccess" // File upload options const MaxUploadSize = "max_upload_size" -func Set(key string, value interface{}) { +type MissingConfigError struct { + missingFields []string +} + +func (e MissingConfigError) Error() string { + return fmt.Sprintf("missing the following mandatory settings: %s", strings.Join(e.missingFields, ", ")) +} + +type Instance struct{} + +var instance *Instance + +func GetInstance() *Instance { + if instance == nil { + instance = &Instance{} + } + return instance +} + +func (i *Instance) SetConfigFile(fn string) { + viper.SetConfigFile(fn) +} + +func (i *Instance) Set(key string, value interface{}) { viper.Set(key, value) } -func SetPassword(value string) { +func (i *Instance) SetPassword(value string) { // if blank, don't bother hashing; we want it to be blank if value == "" { - Set(Password, "") + i.Set(Password, "") } else { - Set(Password, hashPassword(value)) + i.Set(Password, hashPassword(value)) } } -func Write() error { +func (i *Instance) Write() error { return viper.WriteConfig() } -func GetConfigPath() string { - configFileUsed := viper.ConfigFileUsed() - return filepath.Dir(configFileUsed) -} - -func GetConfigFilePath() string { +// GetConfigFile returns the full path to the used configuration file. +func (i *Instance) GetConfigFile() string { return viper.ConfigFileUsed() } -func GetStashPaths() []*models.StashConfig { +// GetConfigPath returns the path of the directory containing the used +// configuration file. +func (i *Instance) 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 { + return filepath.Join(i.GetConfigPath(), "stash-go.sqlite") +} + +func (i *Instance) GetStashPaths() []*models.StashConfig { var ret []*models.StashConfig if err := viper.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 { // fallback to legacy format @@ -169,47 +202,51 @@ func GetStashPaths() []*models.StashConfig { return ret } -func GetCachePath() string { +func (i *Instance) GetConfigFilePath() string { + return viper.ConfigFileUsed() +} + +func (i *Instance) GetCachePath() string { return viper.GetString(Cache) } -func GetGeneratedPath() string { +func (i *Instance) GetGeneratedPath() string { return viper.GetString(Generated) } -func GetMetadataPath() string { +func (i *Instance) GetMetadataPath() string { return viper.GetString(Metadata) } -func GetDatabasePath() string { +func (i *Instance) GetDatabasePath() string { return viper.GetString(Database) } -func GetJWTSignKey() []byte { +func (i *Instance) GetJWTSignKey() []byte { return []byte(viper.GetString(JWTSignKey)) } -func GetSessionStoreKey() []byte { +func (i *Instance) GetSessionStoreKey() []byte { return []byte(viper.GetString(SessionStoreKey)) } -func GetDefaultScrapersPath() string { +func (i *Instance) GetDefaultScrapersPath() string { // default to the same directory as the config file - fn := filepath.Join(GetConfigPath(), "scrapers") + fn := filepath.Join(i.GetConfigPath(), "scrapers") return fn } -func GetExcludes() []string { +func (i *Instance) GetExcludes() []string { return viper.GetStringSlice(Exclude) } -func GetImageExcludes() []string { +func (i *Instance) GetImageExcludes() []string { return viper.GetStringSlice(ImageExclude) } -func GetVideoExtensions() []string { +func (i *Instance) GetVideoExtensions() []string { ret := viper.GetStringSlice(VideoExtensions) if ret == nil { ret = defaultVideoExtensions @@ -217,7 +254,7 @@ func GetVideoExtensions() []string { return ret } -func GetImageExtensions() []string { +func (i *Instance) GetImageExtensions() []string { ret := viper.GetStringSlice(ImageExtensions) if ret == nil { ret = defaultImageExtensions @@ -225,7 +262,7 @@ func GetImageExtensions() []string { return ret } -func GetGalleryExtensions() []string { +func (i *Instance) GetGalleryExtensions() []string { ret := viper.GetStringSlice(GalleryExtensions) if ret == nil { ret = defaultGalleryExtensions @@ -233,11 +270,11 @@ func GetGalleryExtensions() []string { return ret } -func GetCreateGalleriesFromFolders() bool { +func (i *Instance) GetCreateGalleriesFromFolders() bool { return viper.GetBool(CreateGalleriesFromFolders) } -func GetLanguage() string { +func (i *Instance) GetLanguage() string { ret := viper.GetString(Language) // default to English @@ -250,13 +287,13 @@ func GetLanguage() string { // IsCalculateMD5 returns true if MD5 checksums should be generated for // scene video files. -func IsCalculateMD5() bool { +func (i *Instance) IsCalculateMD5() bool { return viper.GetBool(CalculateMD5) } // GetVideoFileNamingAlgorithm returns what hash algorithm should be used for // naming generated scene video files. -func GetVideoFileNamingAlgorithm() models.HashAlgorithm { +func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm { ret := viper.GetString(VideoFileNamingAlgorithm) // default to oshash @@ -267,23 +304,23 @@ func GetVideoFileNamingAlgorithm() models.HashAlgorithm { return models.HashAlgorithm(ret) } -func GetScrapersPath() string { +func (i *Instance) GetScrapersPath() string { return viper.GetString(ScrapersPath) } -func GetScraperUserAgent() string { +func (i *Instance) GetScraperUserAgent() string { return viper.GetString(ScraperUserAgent) } // GetScraperCDPPath gets the path to the Chrome executable or remote address // to an instance of Chrome. -func GetScraperCDPPath() string { +func (i *Instance) GetScraperCDPPath() string { return viper.GetString(ScraperCDPPath) } // GetScraperCertCheck returns true if the scraper should check for insecure // certificates when fetching an image or a page. -func GetScraperCertCheck() bool { +func (i *Instance) GetScraperCertCheck() bool { ret := true if viper.IsSet(ScraperCertCheck) { ret = viper.GetBool(ScraperCertCheck) @@ -292,48 +329,48 @@ func GetScraperCertCheck() bool { return ret } -func GetStashBoxes() []*models.StashBox { +func (i *Instance) GetStashBoxes() []*models.StashBox { var boxes []*models.StashBox viper.UnmarshalKey(StashBoxes, &boxes) return boxes } -func GetDefaultPluginsPath() string { +func (i *Instance) GetDefaultPluginsPath() string { // default to the same directory as the config file - fn := filepath.Join(GetConfigPath(), "plugins") + fn := filepath.Join(i.GetConfigPath(), "plugins") return fn } -func GetPluginsPath() string { +func (i *Instance) GetPluginsPath() string { return viper.GetString(PluginsPath) } -func GetHost() string { +func (i *Instance) GetHost() string { return viper.GetString(Host) } -func GetPort() int { +func (i *Instance) GetPort() int { return viper.GetInt(Port) } -func GetExternalHost() string { +func (i *Instance) GetExternalHost() string { return viper.GetString(ExternalHost) } // GetPreviewSegmentDuration returns the duration of a single segment in a // scene preview file, in seconds. -func GetPreviewSegmentDuration() float64 { +func (i *Instance) GetPreviewSegmentDuration() float64 { return viper.GetFloat64(PreviewSegmentDuration) } // GetParallelTasks returns the number of parallel tasks that should be started // by scan or generate task. -func GetParallelTasks() int { +func (i *Instance) GetParallelTasks() int { return viper.GetInt(ParallelTasks) } -func GetParallelTasksWithAutoDetection() int { +func (i *Instance) GetParallelTasksWithAutoDetection() int { parallelTasks := viper.GetInt(ParallelTasks) if parallelTasks <= 0 { parallelTasks = (runtime.NumCPU() / 4) + 1 @@ -342,7 +379,7 @@ func GetParallelTasksWithAutoDetection() int { } // GetPreviewSegments returns the amount of segments in a scene preview file. -func GetPreviewSegments() int { +func (i *Instance) GetPreviewSegments() int { return viper.GetInt(PreviewSegments) } @@ -352,7 +389,7 @@ func 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 GetPreviewExcludeStart() string { +func (i *Instance) GetPreviewExcludeStart() string { return viper.GetString(PreviewExcludeStart) } @@ -361,13 +398,13 @@ func 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 GetPreviewExcludeEnd() string { +func (i *Instance) GetPreviewExcludeEnd() string { return viper.GetString(PreviewExcludeEnd) } // GetPreviewPreset returns the preset when generating previews. Defaults to // Slow. -func GetPreviewPreset() models.PreviewPreset { +func (i *Instance) GetPreviewPreset() models.PreviewPreset { ret := viper.GetString(PreviewPreset) // default to slow @@ -378,7 +415,7 @@ func GetPreviewPreset() models.PreviewPreset { return models.PreviewPreset(ret) } -func GetMaxTranscodeSize() models.StreamingResolutionEnum { +func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := viper.GetString(MaxTranscodeSize) // default to original @@ -389,7 +426,7 @@ func GetMaxTranscodeSize() models.StreamingResolutionEnum { return models.StreamingResolutionEnum(ret) } -func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { +func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { ret := viper.GetString(MaxStreamingTranscodeSize) // default to original @@ -400,33 +437,33 @@ func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { return models.StreamingResolutionEnum(ret) } -func GetAPIKey() string { +func (i *Instance) GetAPIKey() string { return viper.GetString(ApiKey) } -func GetUsername() string { +func (i *Instance) GetUsername() string { return viper.GetString(Username) } -func GetPasswordHash() string { +func (i *Instance) GetPasswordHash() string { return viper.GetString(Password) } -func GetCredentials() (string, string) { - if HasCredentials() { +func (i *Instance) GetCredentials() (string, string) { + if i.HasCredentials() { return viper.GetString(Username), viper.GetString(Password) } return "", "" } -func HasCredentials() bool { +func (i *Instance) HasCredentials() bool { if !viper.IsSet(Username) || !viper.IsSet(Password) { return false } - username := GetUsername() - pwHash := GetPasswordHash() + username := i.GetUsername() + pwHash := i.GetPasswordHash() return username != "" && pwHash != "" } @@ -437,20 +474,20 @@ func hashPassword(password string) string { return string(hash) } -func ValidateCredentials(username string, password string) bool { - if !HasCredentials() { +func (i *Instance) ValidateCredentials(username string, password string) bool { + if !i.HasCredentials() { // don't need to authenticate if no credentials saved return true } - authUser, authPWHash := GetCredentials() + authUser, authPWHash := i.GetCredentials() err := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password)) return username == authUser && err == nil } -func ValidateStashBoxes(boxes []*models.StashBoxInput) error { +func (i *Instance) ValidateStashBoxes(boxes []*models.StashBoxInput) error { isMulti := len(boxes) > 1 re, err := regexp.Compile("^http.*graphql$") @@ -474,56 +511,56 @@ func ValidateStashBoxes(boxes []*models.StashBoxInput) error { // GetMaxSessionAge gets the maximum age for session cookies, in seconds. // Session cookie expiry times are refreshed every request. -func GetMaxSessionAge() int { +func (i *Instance) GetMaxSessionAge() int { viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge) return viper.GetInt(MaxSessionAge) } // GetCustomServedFolders gets the map of custom paths to their applicable // filesystem locations -func GetCustomServedFolders() URLMap { +func (i *Instance) GetCustomServedFolders() URLMap { return viper.GetStringMapString(CustomServedFolders) } // Interface options -func GetMenuItems() []string { +func (i *Instance) GetMenuItems() []string { if viper.IsSet(MenuItems) { return viper.GetStringSlice(MenuItems) } return defaultMenuItems } -func GetSoundOnPreview() bool { +func (i *Instance) GetSoundOnPreview() bool { viper.SetDefault(SoundOnPreview, false) return viper.GetBool(SoundOnPreview) } -func GetWallShowTitle() bool { +func (i *Instance) GetWallShowTitle() bool { viper.SetDefault(WallShowTitle, true) return viper.GetBool(WallShowTitle) } -func GetWallPlayback() string { +func (i *Instance) GetWallPlayback() string { viper.SetDefault(WallPlayback, "video") return viper.GetString(WallPlayback) } -func GetMaximumLoopDuration() int { +func (i *Instance) GetMaximumLoopDuration() int { viper.SetDefault(MaximumLoopDuration, 0) return viper.GetInt(MaximumLoopDuration) } -func GetAutostartVideo() bool { +func (i *Instance) GetAutostartVideo() bool { viper.SetDefault(AutostartVideo, false) return viper.GetBool(AutostartVideo) } -func GetShowStudioAsText() bool { +func (i *Instance) GetShowStudioAsText() bool { viper.SetDefault(ShowStudioAsText, false) return viper.GetBool(ShowStudioAsText) } -func GetCSSPath() string { +func (i *Instance) GetCSSPath() string { // use custom.css in the same directory as the config file configFileUsed := viper.ConfigFileUsed() configDir := filepath.Dir(configFileUsed) @@ -533,8 +570,8 @@ func GetCSSPath() string { return fn } -func GetCSS() string { - fn := GetCSSPath() +func (i *Instance) GetCSS() string { + fn := i.GetCSSPath() exists, _ := utils.FileExists(fn) if !exists { @@ -550,28 +587,28 @@ func GetCSS() string { return string(buf) } -func SetCSS(css string) { - fn := GetCSSPath() +func (i *Instance) SetCSS(css string) { + fn := i.GetCSSPath() buf := []byte(css) ioutil.WriteFile(fn, buf, 0777) } -func GetCSSEnabled() bool { +func (i *Instance) GetCSSEnabled() bool { return viper.GetBool(CSSEnabled) } // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. -func GetLogFile() string { +func (i *Instance) GetLogFile() string { return viper.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 GetLogOut() bool { +func (i *Instance) GetLogOut() bool { ret := true if viper.IsSet(LogOut) { ret = viper.GetBool(LogOut) @@ -582,7 +619,7 @@ func GetLogOut() bool { // GetLogLevel returns the lowest log level to write to the log. // Should be one of "Debug", "Info", "Warning", "Error" -func GetLogLevel() string { +func (i *Instance) GetLogLevel() string { const defaultValue = "Info" value := viper.GetString(LogLevel) @@ -595,7 +632,7 @@ func 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 GetLogAccess() bool { +func (i *Instance) GetLogAccess() bool { ret := true if viper.IsSet(LogAccess) { ret = viper.GetBool(LogAccess) @@ -605,7 +642,7 @@ func GetLogAccess() bool { } // Max allowed graphql upload size in megabytes -func GetMaxUploadSize() int64 { +func (i *Instance) GetMaxUploadSize() int64 { ret := int64(1024) if viper.IsSet(MaxUploadSize) { ret = viper.GetInt64(MaxUploadSize) @@ -613,11 +650,27 @@ func GetMaxUploadSize() int64 { return ret << 20 } -func IsValid() bool { - setPaths := viper.IsSet(Stash) && viper.IsSet(Cache) && viper.IsSet(Generated) && viper.IsSet(Metadata) +func (i *Instance) Validate() error { + mandatoryPaths := []string{ + Database, + Generated, + } - // TODO: check valid paths - return setPaths + var missingFields []string + + for _, p := range mandatoryPaths { + if !viper.IsSet(p) || viper.GetString(p) == "" { + missingFields = append(missingFields, p) + } + } + + if len(missingFields) > 0 { + return MissingConfigError{ + missingFields: missingFields, + } + } + + return nil } func setDefaultValues() { @@ -629,21 +682,19 @@ func setDefaultValues() { } // SetInitialConfig fills in missing required config fields -func SetInitialConfig() error { +func (i *Instance) SetInitialConfig() { // generate some api keys const apiKeyLength = 32 - if string(GetJWTSignKey()) == "" { + if string(i.GetJWTSignKey()) == "" { signKey := utils.GenerateRandomKey(apiKeyLength) - Set(JWTSignKey, signKey) + i.Set(JWTSignKey, signKey) } - if string(GetSessionStoreKey()) == "" { + if string(i.GetSessionStoreKey()) == "" { sessionStoreKey := utils.GenerateRandomKey(apiKeyLength) - Set(SessionStoreKey, sessionStoreKey) + i.Set(SessionStoreKey, sessionStoreKey) } setDefaultValues() - - return Write() } diff --git a/pkg/manager/config/init.go b/pkg/manager/config/init.go new file mode 100644 index 000000000..932641ff8 --- /dev/null +++ b/pkg/manager/config/init.go @@ -0,0 +1,104 @@ +package config + +import ( + "net" + "os" + "sync" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/utils" +) + +var once sync.Once + +type flagStruct struct { + configFilePath string +} + +func Initialize() (*Instance, error) { + var err error + once.Do(func() { + instance = &Instance{} + + flags := initFlags() + err = initConfig(flags) + initEnvs() + }) + return instance, err +} + +func initConfig(flags flagStruct) error { + // The config file is called config. Leave off the file extension. + viper.SetConfigName("config") + + if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists { + viper.SetConfigFile(flags.configFilePath) + } + viper.AddConfigPath(".") // Look for config in the working directory + viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory + + // for Docker compatibility, if STASH_CONFIG_FILE is set, then touch the + // given filename + envConfigFile := os.Getenv("STASH_CONFIG_FILE") + if envConfigFile != "" { + utils.Touch(envConfigFile) + viper.SetConfigFile(envConfigFile) + } + + err := viper.ReadInConfig() // Find and read the config file + // continue, but set an error to be handled by caller + + postInitConfig() + instance.SetInitialConfig() + + return err +} + +func postInitConfig() { + c := instance + if c.GetConfigFile() != "" { + viper.SetDefault(Database, c.GetDefaultDatabaseFilePath()) + } + + // Set generated to the metadata path for backwards compat + viper.SetDefault(Generated, viper.GetString(Metadata)) + + // Set default scrapers and plugins paths + viper.SetDefault(ScrapersPath, c.GetDefaultScrapersPath()) + viper.SetDefault(PluginsPath, c.GetDefaultPluginsPath()) + + viper.WriteConfig() +} + +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.Parse() + if err := viper.BindPFlags(pflag.CommandLine); err != nil { + logger.Infof("failed to bind flags: %s", err.Error()) + } + + return flags +} + +func initEnvs() { + viper.SetEnvPrefix("stash") // will be uppercased automatically + viper.BindEnv("host") // STASH_HOST + viper.BindEnv("port") // STASH_PORT + viper.BindEnv("external_host") // STASH_EXTERNAL_HOST + viper.BindEnv("generated") // STASH_GENERATED + viper.BindEnv("metadata") // STASH_METADATA + viper.BindEnv("cache") // STASH_CACHE + + // only set stash config flag if not already set + if instance.GetStashPaths() == nil { + viper.BindEnv("stash") // STASH_STASH + } +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index acd23f8bd..9d4aabea9 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -1,11 +1,13 @@ package manager import ( - "net" + "errors" + "fmt" + "os" + "path/filepath" "sync" - "github.com/spf13/pflag" - "github.com/spf13/viper" + "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" @@ -18,6 +20,8 @@ import ( ) type singleton struct { + Config *config.Instance + Status TaskStatus Paths *paths.Paths @@ -48,29 +52,35 @@ func GetInstance() *singleton { func Initialize() *singleton { once.Do(func() { - _ = utils.EnsureDir(paths.GetConfigDirectory()) - initFlags() - initConfig() + _ = utils.EnsureDir(paths.GetStashHomeDirectory()) + cfg, err := config.Initialize() initLog() - initEnvs() + instance = &singleton{ - Status: TaskStatus{Status: Idle, Progress: -1}, - Paths: paths.NewPaths(), - - PluginCache: initPluginCache(), - + Config: cfg, + Status: TaskStatus{Status: Idle, Progress: -1}, DownloadStore: NewDownloadStore(), - TxnManager: sqlite.NewTransactionManager(), + + TxnManager: sqlite.NewTransactionManager(), } - instance.ScraperCache = instance.initScraperCache() - instance.RefreshConfig() + cfgFile := cfg.GetConfigFile() + if cfgFile != "" { + logger.Infof("using config file: %s", cfg.GetConfigFile()) - // clear the downloads and tmp directories - // #1021 - only clear these directories if the generated folder is non-empty - if config.GetGeneratedPath() != "" { - utils.EmptyDir(instance.Paths.Generated.Downloads) - utils.EmptyDir(instance.Paths.Generated.Tmp) + if err == nil { + err = cfg.Validate() + } + + if err != nil { + panic(fmt.Sprintf("error initializing configuration: %s", err.Error())) + } else { + if err := instance.PostInit(); err != nil { + panic(err) + } + } + } else { + logger.Warn("config file not found. Assuming new system...") } initFFMPEG() @@ -79,78 +89,8 @@ func Initialize() *singleton { return instance } -func initConfig() { - // The config file is called config. Leave off the file extension. - viper.SetConfigName("config") - - if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists { - viper.SetConfigFile(flags.configFilePath) - } - viper.AddConfigPath(".") // Look for config in the working directory - viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory - - err := viper.ReadInConfig() // Find and read the config file - if err != nil { // Handle errors reading the config file - _ = utils.Touch(paths.GetDefaultConfigFilePath()) - if err = viper.ReadInConfig(); err != nil { - panic(err) - } - } - logger.Infof("using config file: %s", viper.ConfigFileUsed()) - - config.SetInitialConfig() - - viper.SetDefault(config.Database, paths.GetDefaultDatabaseFilePath()) - - // Set generated to the metadata path for backwards compat - viper.SetDefault(config.Generated, viper.GetString(config.Metadata)) - - // Set default scrapers and plugins paths - viper.SetDefault(config.ScrapersPath, config.GetDefaultScrapersPath()) - viper.SetDefault(config.PluginsPath, config.GetDefaultPluginsPath()) - - // Disabling config watching due to race condition issue - // See: https://github.com/spf13/viper/issues/174 - // Changes to the config outside the system will require a restart - // Watch for changes - // viper.WatchConfig() - // viper.OnConfigChange(func(e fsnotify.Event) { - // fmt.Println("Config file changed:", e.Name) - // instance.refreshConfig() - // }) - - //viper.Set("stash", []string{"/", "/stuff"}) - //viper.WriteConfig() -} - -func initFlags() { - 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.Parse() - if err := viper.BindPFlags(pflag.CommandLine); err != nil { - logger.Infof("failed to bind flags: %s", err.Error()) - } -} - -func initEnvs() { - viper.SetEnvPrefix("stash") // will be uppercased automatically - viper.BindEnv("host") // STASH_HOST - viper.BindEnv("port") // STASH_PORT - viper.BindEnv("external_host") // STASH_EXTERNAL_HOST - viper.BindEnv("generated") // STASH_GENERATED - viper.BindEnv("metadata") // STASH_METADATA - viper.BindEnv("cache") // STASH_CACHE - - // only set stash config flag if not already set - if config.GetStashPaths() == nil { - viper.BindEnv("stash") // STASH_STASH - } -} - func initFFMPEG() { - configDirectory := paths.GetConfigDirectory() + configDirectory := paths.GetStashHomeDirectory() ffmpegPath, ffprobePath := ffmpeg.GetPaths(configDirectory) if ffmpegPath == "" || ffprobePath == "" { logger.Infof("couldn't find FFMPEG, attempting to download it") @@ -174,10 +114,12 @@ The error was: %s } func initLog() { + config := config.GetInstance() logger.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel()) } func initPluginCache() *plugin.Cache { + config := config.GetInstance() ret, err := plugin.NewCache(config.GetPluginsPath()) if err != nil { @@ -187,14 +129,37 @@ func initPluginCache() *plugin.Cache { return ret } +// PostInit initialises the paths, caches and txnManager after the initial +// configuration has been set. Should only be called if the configuration +// is valid. +func (s *singleton) PostInit() error { + s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + s.PluginCache = initPluginCache() + s.ScraperCache = instance.initScraperCache() + + s.RefreshConfig() + + // clear the downloads and tmp directories + // #1021 - only clear these directories if the generated folder is non-empty + if s.Config.GetGeneratedPath() != "" { + utils.EmptyDir(instance.Paths.Generated.Downloads) + utils.EmptyDir(instance.Paths.Generated.Tmp) + } + + if err := database.Initialize(s.Config.GetDatabasePath()); err != nil { + return err + } + + if database.Ready() == nil { + s.PostMigrate() + } + + return nil +} + // initScraperCache initializes a new scraper cache and returns it. func (s *singleton) initScraperCache() *scraper.Cache { - scraperConfig := scraper.GlobalConfig{ - Path: config.GetScrapersPath(), - UserAgent: config.GetScraperUserAgent(), - CDPPath: config.GetScraperCDPPath(), - } - ret, err := scraper.NewCache(scraperConfig, s.TxnManager) + ret, err := scraper.NewCache(config.GetInstance(), s.TxnManager) if err != nil { logger.Errorf("Error reading scraper configs: %s", err.Error()) @@ -204,14 +169,14 @@ func (s *singleton) initScraperCache() *scraper.Cache { } func (s *singleton) RefreshConfig() { - s.Paths = paths.NewPaths() - if config.IsValid() { + s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + config := s.Config + if config.Validate() == nil { utils.EnsureDir(s.Paths.Generated.Screenshots) utils.EnsureDir(s.Paths.Generated.Vtt) utils.EnsureDir(s.Paths.Generated.Markers) utils.EnsureDir(s.Paths.Generated.Transcodes) utils.EnsureDir(s.Paths.Generated.Downloads) - paths.EnsureJSONDirs(config.GetMetadataPath()) } } @@ -220,3 +185,110 @@ func (s *singleton) RefreshConfig() { func (s *singleton) RefreshScraperCache() { s.ScraperCache = s.initScraperCache() } + +func setSetupDefaults(input *models.SetupInput) { + if input.ConfigLocation == "" { + input.ConfigLocation = filepath.Join(utils.GetHomeDirectory(), ".stash", "config.yml") + } + + configDir := filepath.Dir(input.ConfigLocation) + if input.GeneratedLocation == "" { + input.GeneratedLocation = filepath.Join(configDir, "generated") + } + + if input.DatabaseFile == "" { + input.DatabaseFile = filepath.Join(configDir, "stash-go.sqlite") + } +} + +func (s *singleton) Setup(input models.SetupInput) error { + setSetupDefaults(&input) + + // create the generated directory if it does not exist + if exists, _ := utils.DirExists(input.GeneratedLocation); !exists { + if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil { + return fmt.Errorf("error creating generated directory: %s", err.Error()) + } + } + + if err := utils.Touch(input.ConfigLocation); err != nil { + return fmt.Errorf("error creating config file: %s", err.Error()) + } + + s.Config.SetConfigFile(input.ConfigLocation) + + // set the configuration + s.Config.Set(config.Generated, input.GeneratedLocation) + s.Config.Set(config.Database, input.DatabaseFile) + s.Config.Set(config.Stash, input.Stashes) + if err := s.Config.Write(); err != nil { + return fmt.Errorf("error writing configuration file: %s", err.Error()) + } + + // initialise the database + if err := s.PostInit(); err != nil { + return fmt.Errorf("error initializing the database: %s", err.Error()) + } + + return nil +} + +func (s *singleton) Migrate(input models.MigrateInput) error { + // always backup so that we can roll back to the previous version if + // migration fails + backupPath := input.BackupPath + if backupPath == "" { + backupPath = database.DatabaseBackupPath() + } + + // perform database backup + if err := database.Backup(database.DB, backupPath); err != nil { + return fmt.Errorf("error backing up database: %s", err) + } + + if err := database.RunMigrations(); err != nil { + errStr := fmt.Sprintf("error performing migration: %s", err) + + // roll back to the backed up version + restoreErr := database.RestoreFromBackup(backupPath) + if restoreErr != nil { + errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr) + } else { + errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr + } + + return errors.New(errStr) + } + + // perform post-migration operations + s.PostMigrate() + + // if no backup path was provided, then delete the created backup + if input.BackupPath == "" { + if err := os.Remove(backupPath); err != nil { + logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error()) + } + } + + return nil +} + +func (s *singleton) GetSystemStatus() *models.SystemStatus { + status := models.SystemStatusEnumOk + dbSchema := int(database.Version()) + dbPath := database.DatabasePath() + appSchema := int(database.AppSchemaVersion()) + + if s.Config.GetConfigFile() == "" { + status = models.SystemStatusEnumSetup + } else if dbSchema < appSchema { + status = models.SystemStatusEnumNeedsMigration + } + + return &models.SystemStatus{ + DatabaseSchema: &dbSchema, + DatabasePath: &dbPath, + AppSchema: appSchema, + Status: status, + } +} diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index ff8116bda..182a938f3 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -18,17 +18,17 @@ import ( ) func isGallery(pathname string) bool { - gExt := config.GetGalleryExtensions() + gExt := config.GetInstance().GetGalleryExtensions() return matchExtension(pathname, gExt) } func isVideo(pathname string) bool { - vidExt := config.GetVideoExtensions() + vidExt := config.GetInstance().GetVideoExtensions() return matchExtension(pathname, vidExt) } func isImage(pathname string) bool { - imgExt := config.GetImageExtensions() + imgExt := config.GetInstance().GetImageExtensions() return matchExtension(pathname, imgExt) } @@ -84,7 +84,7 @@ func (t *TaskStatus) updated() { func getScanPaths(inputPaths []string) []*models.StashConfig { if len(inputPaths) == 0 { - return config.GetStashPaths() + return config.GetInstance().GetStashPaths() } var ret []*models.StashConfig @@ -181,6 +181,7 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { } start := time.Now() + config := config.GetInstance() parallelTasks := config.GetParallelTasksWithAutoDetection() logger.Infof("Scan started with %d parallel tasks", parallelTasks) wg := sizedwaitgroup.New(parallelTasks) @@ -264,9 +265,15 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { }() } -func (s *singleton) Import() { +func (s *singleton) Import() error { + config := config.GetInstance() + metadataPath := config.GetMetadataPath() + if metadataPath == "" { + return errors.New("metadata path must be set in config") + } + if s.Status.Status != Idle { - return + return nil } s.Status.SetStatus(Import) s.Status.indefiniteProgress() @@ -276,9 +283,10 @@ func (s *singleton) Import() { var wg sync.WaitGroup wg.Add(1) + task := ImportTask{ txnManager: s.TxnManager, - BaseDir: config.GetMetadataPath(), + BaseDir: metadataPath, Reset: true, DuplicateBehaviour: models.ImportDuplicateEnumFail, MissingRefBehaviour: models.ImportMissingRefEnumFail, @@ -287,11 +295,19 @@ func (s *singleton) Import() { go task.Start(&wg) wg.Wait() }() + + return nil } -func (s *singleton) Export() { +func (s *singleton) Export() error { + config := config.GetInstance() + metadataPath := config.GetMetadataPath() + if metadataPath == "" { + return errors.New("metadata path must be set in config") + } + if s.Status.Status != Idle { - return + return nil } s.Status.SetStatus(Export) s.Status.indefiniteProgress() @@ -309,6 +325,8 @@ func (s *singleton) Export() { go task.Start(&wg) wg.Wait() }() + + return nil } func (s *singleton) RunSingleTask(t Task) (*sync.WaitGroup, error) { @@ -332,6 +350,7 @@ func (s *singleton) RunSingleTask(t Task) (*sync.WaitGroup, error) { } func setGeneratePreviewOptionsInput(optionsInput *models.GeneratePreviewOptionsInput) { + config := config.GetInstance() if optionsInput.PreviewSegments == nil { val := config.GetPreviewSegments() optionsInput.PreviewSegments = &val @@ -409,6 +428,7 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { return } + config := config.GetInstance() parallelTasks := config.GetParallelTasksWithAutoDetection() logger.Infof("Generate started with %d parallel tasks", parallelTasks) @@ -587,7 +607,7 @@ func (s *singleton) generateScreenshot(sceneId string, at *float64) { txnManager: s.TxnManager, Scene: *scene, ScreenshotAt: at, - fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), + fileNamingAlgorithm: config.GetInstance().GetVideoFileNamingAlgorithm(), } var wg sync.WaitGroup @@ -862,7 +882,7 @@ func (s *singleton) Clean(input models.CleanMetadataInput) { var wg sync.WaitGroup s.Status.Progress = 0 total := len(scenes) + len(images) + len(galleries) - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() for i, scene := range scenes { s.Status.setProgress(i, total) if s.Status.stopping { @@ -944,7 +964,7 @@ func (s *singleton) MigrateHash() { go func() { defer s.returnToIdleState() - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String()) var scenes []*models.Scene @@ -1020,7 +1040,7 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate chTimeout <- struct{}{} }() - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() overwrite := false if input.Overwrite != nil { overwrite = *input.Overwrite diff --git a/pkg/manager/paths/paths.go b/pkg/manager/paths/paths.go index 459c60943..0d06af2c0 100644 --- a/pkg/manager/paths/paths.go +++ b/pkg/manager/paths/paths.go @@ -13,31 +13,27 @@ type Paths struct { SceneMarkers *sceneMarkerPaths } -func NewPaths() *Paths { +func NewPaths(generatedPath string) *Paths { p := Paths{} - p.Generated = newGeneratedPaths() + p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) p.SceneMarkers = newSceneMarkerPaths(p) return &p } -func GetConfigDirectory() string { +func GetStashHomeDirectory() string { return filepath.Join(utils.GetHomeDirectory(), ".stash") } func GetDefaultDatabaseFilePath() string { - return filepath.Join(GetConfigDirectory(), "stash-go.sqlite") -} - -func GetDefaultConfigFilePath() string { - return filepath.Join(GetConfigDirectory(), "config.yml") + return filepath.Join(GetStashHomeDirectory(), "stash-go.sqlite") } func GetSSLKey() string { - return filepath.Join(GetConfigDirectory(), "stash.key") + return filepath.Join(GetStashHomeDirectory(), "stash.key") } func GetSSLCert() string { - return filepath.Join(GetConfigDirectory(), "stash.crt") + return filepath.Join(GetStashHomeDirectory(), "stash.crt") } diff --git a/pkg/manager/paths/paths_generated.go b/pkg/manager/paths/paths_generated.go index 25aef7f45..234f3918b 100644 --- a/pkg/manager/paths/paths_generated.go +++ b/pkg/manager/paths/paths_generated.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "path/filepath" - "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/utils" ) @@ -22,15 +21,15 @@ type generatedPaths struct { Tmp string } -func newGeneratedPaths() *generatedPaths { +func newGeneratedPaths(path string) *generatedPaths { gp := generatedPaths{} - gp.Screenshots = filepath.Join(config.GetGeneratedPath(), "screenshots") - gp.Thumbnails = filepath.Join(config.GetGeneratedPath(), "thumbnails") - gp.Vtt = filepath.Join(config.GetGeneratedPath(), "vtt") - gp.Markers = filepath.Join(config.GetGeneratedPath(), "markers") - gp.Transcodes = filepath.Join(config.GetGeneratedPath(), "transcodes") - gp.Downloads = filepath.Join(config.GetGeneratedPath(), "download_stage") - gp.Tmp = filepath.Join(config.GetGeneratedPath(), "tmp") + gp.Screenshots = filepath.Join(path, "screenshots") + gp.Thumbnails = filepath.Join(path, "thumbnails") + gp.Vtt = filepath.Join(path, "vtt") + gp.Markers = filepath.Join(path, "markers") + gp.Transcodes = filepath.Join(path, "transcodes") + gp.Downloads = filepath.Join(path, "download_stage") + gp.Tmp = filepath.Join(path, "tmp") return &gp } diff --git a/pkg/manager/scene.go b/pkg/manager/scene.go index d40d673e9..e262dda18 100644 --- a/pkg/manager/scene.go +++ b/pkg/manager/scene.go @@ -54,7 +54,7 @@ func DestroySceneMarker(scene *models.Scene, sceneMarker *models.SceneMarker, qb // delete the preview for the marker return func() { seconds := int(sceneMarker.Seconds) - DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm()) + DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm()) }, nil } @@ -247,7 +247,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami // don't care if we can't get the container container, _ := GetSceneFileContainer(scene) - if HasTranscode(scene, config.GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { + if HasTranscode(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { label := "Direct stream" ret = append(ret, &models.SceneStreamEndpoint{ URL: directStreamURL, diff --git a/pkg/manager/task_clean.go b/pkg/manager/task_clean.go index fd13277b5..a36fcebb8 100644 --- a/pkg/manager/task_clean.go +++ b/pkg/manager/task_clean.go @@ -42,7 +42,7 @@ func (t *CleanTask) shouldClean(path string) bool { fileExists := image.FileExists(path) // #1102 - clean anything in generated path - generatedPath := config.GetGeneratedPath() + generatedPath := config.GetInstance().GetGeneratedPath() if !fileExists || getStashFromPath(path) == nil || utils.IsPathInDir(generatedPath, path) { logger.Infof("File not found. Cleaning: \"%s\"", path) return true @@ -62,6 +62,7 @@ func (t *CleanTask) shouldCleanScene(s *models.Scene) bool { return true } + config := config.GetInstance() if !matchExtension(s.Path, config.GetVideoExtensions()) { logger.Infof("File extension does not match video extensions. Cleaning: \"%s\"", s.Path) return true @@ -92,6 +93,7 @@ func (t *CleanTask) shouldCleanGallery(g *models.Gallery) bool { return true } + config := config.GetInstance() if !matchExtension(path, config.GetGalleryExtensions()) { logger.Infof("File extension does not match gallery extensions. Cleaning: \"%s\"", path) return true @@ -121,6 +123,7 @@ func (t *CleanTask) shouldCleanImage(s *models.Image) bool { return true } + config := config.GetInstance() if !matchExtension(s.Path, config.GetImageExtensions()) { logger.Infof("File extension does not match image extensions. Cleaning: \"%s\"", s.Path) return true @@ -199,7 +202,7 @@ func (t *CleanTask) fileExists(filename string) (bool, error) { } func getStashFromPath(pathToCheck string) *models.StashConfig { - for _, s := range config.GetStashPaths() { + for _, s := range config.GetInstance().GetStashPaths() { if utils.IsPathInDir(s.Path, filepath.Dir(pathToCheck)) { return s } @@ -208,7 +211,7 @@ func getStashFromPath(pathToCheck string) *models.StashConfig { } func getStashFromDirPath(pathToCheck string) *models.StashConfig { - for _, s := range config.GetStashPaths() { + for _, s := range config.GetInstance().GetStashPaths() { if utils.IsPathInDir(s.Path, pathToCheck) { return s } diff --git a/pkg/manager/task_export.go b/pkg/manager/task_export.go index b949b9389..59bacca24 100644 --- a/pkg/manager/task_export.go +++ b/pkg/manager/task_export.go @@ -107,7 +107,7 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) { startTime := time.Now() if t.full { - t.baseDir = config.GetMetadataPath() + t.baseDir = config.GetInstance().GetMetadataPath() } else { var err error t.baseDir, err = instance.Paths.Generated.TempDir("export") diff --git a/pkg/manager/task_import.go b/pkg/manager/task_import.go index d5f8b720b..76a9a2954 100644 --- a/pkg/manager/task_import.go +++ b/pkg/manager/task_import.go @@ -120,7 +120,7 @@ func (t *ImportTask) Start(wg *sync.WaitGroup) { t.scraped = scraped if t.Reset { - err := database.Reset(config.GetDatabasePath()) + err := database.Reset(config.GetInstance().GetDatabasePath()) if err != nil { logger.Errorf("Error resetting database: %s", err.Error()) diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index d9ce9581c..361793c10 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -69,6 +69,7 @@ func (t *ScanTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { if t.GeneratePreview { iwg.Add() + config := config.GetInstance() var previewSegmentDuration = config.GetPreviewSegmentDuration() var previewSegments = config.GetPreviewSegments() var previewExcludeStart = config.GetPreviewExcludeStart() @@ -313,7 +314,7 @@ func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) { basename := strings.TrimSuffix(t.FilePath, filepath.Ext(t.FilePath)) var relatedFiles []string - vExt := config.GetVideoExtensions() + vExt := config.GetInstance().GetVideoExtensions() // make a list of media files that can be related to the gallery for _, ext := range vExt { related := basename + "." + ext @@ -398,6 +399,7 @@ func (t *ScanTask) scanScene() *models.Scene { // if the mod time of the file is different than that of the associated // scene, then recalculate the checksum and regenerate the thumbnail modified := t.isFileModified(fileModTime, s.FileModTime) + config := config.GetInstance() if modified || !s.Size.Valid { oldHash := s.GetHash(config.GetVideoFileNamingAlgorithm()) s, err = t.rescanScene(s, fileModTime) @@ -874,7 +876,7 @@ func (t *ScanTask) scanImage() { logger.Error(err.Error()) return } - } else if config.GetCreateGalleriesFromFolders() { + } else if config.GetInstance().GetCreateGalleriesFromFolders() { // create gallery from folder or associate with existing gallery logger.Infof("Associating image %s with folder gallery", i.Path) if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { @@ -1027,6 +1029,7 @@ func (t *ScanTask) calculateImageChecksum() (string, error) { } func (t *ScanTask) doesPathExist() bool { + config := config.GetInstance() vidExt := config.GetVideoExtensions() imgExt := config.GetImageExtensions() gExt := config.GetGalleryExtensions() @@ -1057,6 +1060,7 @@ func (t *ScanTask) doesPathExist() bool { } func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error { + config := config.GetInstance() vidExt := config.GetVideoExtensions() imgExt := config.GetImageExtensions() gExt := config.GetGalleryExtensions() diff --git a/pkg/manager/task_transcode.go b/pkg/manager/task_transcode.go index f5a46e58f..7b1ed33cf 100644 --- a/pkg/manager/task_transcode.go +++ b/pkg/manager/task_transcode.go @@ -57,7 +57,7 @@ func (t *GenerateTranscodeTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) outputPath := instance.Paths.Generated.GetTmpPath(sceneHash + ".mp4") - transcodeSize := config.GetMaxTranscodeSize() + transcodeSize := config.GetInstance().GetMaxTranscodeSize() options := ffmpeg.TranscodeOptions{ OutputPath: outputPath, MaxTranscodeSize: transcodeSize, diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 08cb6725d..ab09f28da 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -8,7 +8,6 @@ import ( "strings" "time" - stashConfig "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) @@ -87,7 +86,7 @@ func setMovieBackImage(m *models.ScrapedMovie, globalConfig GlobalConfig) error func getImage(url string, globalConfig GlobalConfig) (*string, error) { client := &http.Client{ Transport: &http.Transport{ // ignore insecure certificates - TLSClientConfig: &tls.Config{InsecureSkipVerify: !stashConfig.GetScraperCertCheck()}}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: !globalConfig.GetScraperCertCheck()}}, Timeout: imageGetTimeout, } @@ -96,7 +95,7 @@ func getImage(url string, globalConfig GlobalConfig) (*string, error) { return nil, err } - userAgent := globalConfig.UserAgent + userAgent := globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } diff --git a/pkg/scraper/scrapers.go b/pkg/scraper/scrapers.go index 6e2ee3fd2..f82dbd223 100644 --- a/pkg/scraper/scrapers.go +++ b/pkg/scraper/scrapers.go @@ -14,21 +14,19 @@ import ( ) // GlobalConfig contains the global scraper options. -type GlobalConfig struct { - // User Agent used when scraping using http. - UserAgent string - - // Path (file or remote address) to a Chrome CDP instance. - CDPPath string - Path string +type GlobalConfig interface { + GetScraperUserAgent() string + GetScrapersPath() string + GetScraperCDPPath() string + GetScraperCertCheck() bool } -func (c GlobalConfig) isCDPPathHTTP() bool { - return strings.HasPrefix(c.CDPPath, "http://") || strings.HasPrefix(c.CDPPath, "https://") +func isCDPPathHTTP(c GlobalConfig) bool { + return strings.HasPrefix(c.GetScraperCDPPath(), "http://") || strings.HasPrefix(c.GetScraperCDPPath(), "https://") } -func (c GlobalConfig) isCDPPathWS() bool { - return strings.HasPrefix(c.CDPPath, "ws://") +func isCDPPathWS(c GlobalConfig) bool { + return strings.HasPrefix(c.GetScraperCDPPath(), "ws://") } // Cache stores scraper details. @@ -45,7 +43,7 @@ type Cache struct { // Scraper configurations are loaded from yml files in the provided scrapers // directory and any subdirectories. func NewCache(globalConfig GlobalConfig, txnManager models.TransactionManager) (*Cache, error) { - scrapers, err := loadScrapers(globalConfig.Path) + scrapers, err := loadScrapers(globalConfig.GetScrapersPath()) if err != nil { return nil, err } @@ -93,7 +91,7 @@ func loadScrapers(path string) ([]config, error) { // In the event of an error during loading, the cache will be left empty. func (c *Cache) ReloadScrapers() error { c.scrapers = nil - scrapers, err := loadScrapers(c.globalConfig.Path) + scrapers, err := loadScrapers(c.globalConfig.GetScrapersPath()) if err != nil { return err } @@ -102,6 +100,7 @@ func (c *Cache) ReloadScrapers() error { return nil } +// TODO - don't think this is needed // UpdateConfig updates the global config for the cache. If the scraper path // has changed, ReloadScrapers will need to be called separately. func (c *Cache) UpdateConfig(globalConfig GlobalConfig) { diff --git a/pkg/scraper/url.go b/pkg/scraper/url.go index 85e1590ee..4404dc067 100644 --- a/pkg/scraper/url.go +++ b/pkg/scraper/url.go @@ -23,7 +23,6 @@ import ( "golang.org/x/net/publicsuffix" "github.com/stashapp/stash/pkg/logger" - stashConfig "github.com/stashapp/stash/pkg/manager/config" ) // Timeout for the scrape http request. Includes transfer time. May want to make this @@ -52,7 +51,7 @@ func loadURL(url string, scraperConfig config, globalConfig GlobalConfig) (io.Re client := &http.Client{ Transport: &http.Transport{ // ignore insecure certificates - TLSClientConfig: &tls.Config{InsecureSkipVerify: !stashConfig.GetScraperCertCheck()}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: !globalConfig.GetScraperCertCheck()}, }, Timeout: scrapeGetTimeout, // defaultCheckRedirect code with max changed from 10 to 20 @@ -70,7 +69,7 @@ func loadURL(url string, scraperConfig config, globalConfig GlobalConfig) (io.Re return nil, err } - userAgent := globalConfig.UserAgent + userAgent := globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } @@ -114,14 +113,15 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo act := context.Background() // if scraperCDPPath is a remote address, then allocate accordingly - if globalConfig.CDPPath != "" { + cdpPath := globalConfig.GetScraperCDPPath() + if cdpPath != "" { var cancelAct context.CancelFunc - if globalConfig.isCDPPathHTTP() || globalConfig.isCDPPathWS() { - remote := globalConfig.CDPPath + if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) { + remote := cdpPath // if CDPPath is http(s) then we need to get the websocket URL - if globalConfig.isCDPPathHTTP() { + if isCDPPathHTTP(globalConfig) { var err error remote, err = getRemoteCDPWSAddress(remote) if err != nil { @@ -140,7 +140,7 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.UserDataDir(dir), - chromedp.ExecPath(globalConfig.CDPPath), + chromedp.ExecPath(cdpPath), ) act, cancelAct = chromedp.NewExecAllocator(act, opts...) } diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index 275d59830..efc5968a5 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -758,6 +758,24 @@ func TestLoadInvalidXPath(t *testing.T) { config.process(q, nil) } +type mockGlobalConfig struct{} + +func (mockGlobalConfig) GetScraperUserAgent() string { + return "" +} + +func (mockGlobalConfig) GetScrapersPath() string { + return "" +} + +func (mockGlobalConfig) GetScraperCDPPath() string { + return "" +} + +func (mockGlobalConfig) GetScraperCertCheck() bool { + return false +} + func TestSubScrape(t *testing.T) { retHTML := `
- Your current stash database is schema version {{.ExistingVersion}} and needs to be migrated to version {{.MigrateVersion}}. - This version of Stash will not function without migrating the database. The schema migration process is not reversible. Once the migration is - performed, your database will be incompatible with previous versions of stash. -
- -
- It is recommended that you backup your existing database before you migrate. We can do this for you, writing a backup to {{.BackupPath}} if required.
-
The following error was encountered while migrating the database:
+ +{migrateError}
+ + Please make any necessary corrections and try again. Otherwise, raise + a bug on the {githubLink} or seek help in the {discordLink}. +
++ Your current stash database is schema version{" "} + {status.databaseSchema} and needs to be migrated to + version {status.appSchema}. This version of Stash + will not function without migrating the database. +
+ ++ The schema migration process is not reversible. Once the migration + is performed, your database will be incompatible with previous + versions of stash. +
+ +
+ It is recommended that you backup your existing database before you
+ migrate. We can do this for you, making a copy of your writing a
+ backup to {defaultBackupPath} if required.
+
+ If you're reading this, then Stash couldn't find an + existing configuration. This wizard will guide you through the + process of setting up a new configuration. +
+
+ Stash tries to find its configuration file (config.yml)
+ from the current working directory first, and if it does not find it
+ there, it falls back to $HOME/.stash/config.yml (on
+ Windows, this will be %USERPROFILE%\.stash\config.yml).
+ You can also make Stash read from a specific configuration file by
+ running it with the -c <path to config file> or{" "}
+ --config <path to config file> options.
+
-c flag.
+ + With all of that out of the way, if you're ready to proceed + with setting up a new system, choose where you'd like to store + your configuration file and click Next. +
++ Next up, we need to determine where to find your porn collection, + where to store the stash database and generated files. These + settings can be changed later if needed. +
++ Add directories containing your porn videos and images. Stash will + use these directories to find videos and images during scanning. +
+
+ Stash uses an sqlite database to store your porn metadata. By
+ default, this will be created as stash-go.sqlite in
+ the directory containing your config file. If you want to change
+ this, please enter an absolute or relative (to the current working
+ directory) filename.
+
+ In order to provide thumbnails, previews and sprites, Stash
+ generates images and videos. This also includes transcodes for
+ unsupported file formats. By default, Stash will create a{" "}
+ generated directory within the directory containing
+ your config file. If you want to change where this generated media
+ will be stored, please enter an absolute or relative (to the
+ current working directory) path. Stash will create this directory
+ if it does not already exist.
+
<current working directory>/config.yml;
+ }
+
+ return {configLocation};
+ }
+
+ function maybeRenderExclusions(s: GQL.StashConfig) {
+ if (!s.excludeImage && !s.excludeVideo) {
+ return;
+ }
+
+ const excludes = [];
+ if (s.excludeVideo) {
+ excludes.push("videos");
+ }
+ if (s.excludeImage) {
+ excludes.push("images");
+ }
+
+ return `(excludes ${excludes.join(" and ")})`;
+ }
+
+ function renderStashLibraries() {
+ return (
+ {s.path}
+ {maybeRenderExclusions(s)}
+ + We're almost ready to complete the configuration. Please + confirm the following settings. You can click back to change + anything incorrect. If everything looks good, click Confirm to + create your system. +
+
+ {databaseFile !== ""
+ ? databaseFile
+ : `/stash-go.sqlite`}
+
+
+ {generatedLocation !== ""
+ ? generatedLocation
+ : `/generated`}
+
+ + Something went wrong while setting up your system. Here is the error + we received: +
{setupError}
+
+ + If this looks like a problem with your inputs, go ahead and click + back to fix them up. Otherwise, raise a bug on the {githubLink} + or seek help in the {discordLink}. +
++ You will be taken to the Configuration page next. This page will + allow you to customize what files to include and exclude, set a + username and password to protect your system, and a whole bunch of + other options. +
+
+ When you are satisfied with these settings, you can begin scanning
+ your content into Stash by clicking on Tasks, then{" "}
+ Scan.
+
+ You are encouraged to check out the in-app manual which can be
+ accessed from the icon in the top-right corner of the screen that
+ looks like this:
+ If you run into issues or have any questions or suggestions, feel + free to open an issue in the {githubLink}, or ask the community in + the {discordLink}. +
++ Check out our{" "} + + OpenCollective + {" "} + to see how you can contribute to the continued development of Stash. +
++ We also welcome contributions in the form of code (bug fixes, + improvements and new features), testing, bug reports, improvement + and feature requests, and user support. Details can be found in the + Contribution section of the in-app manual. +
+Thanks for trying Stash!
+