Setup and migration UI refactor (#1190)

* Make config instance-based
* Remove config dependency in paths
* Refactor config init
* Allow startup without database
* Get system status at UI initialise
* Add setup wizard
* Cache and Metadata optional. Database mandatory
* Handle metadata not set during full import/export
* Add links
* Remove config check middleware
* Stash not mandatory
* Panic on missing mandatory config fields
* Redirect setup to main page if setup not required
* Add migration UI
* Remove unused stuff
* Move UI initialisation into App
* Don't create metadata paths on RefreshConfig
* Add folder selector for generated in setup
* Env variable to set and create config file.
Make docker images use a fixed config file.
* Set config file during setup
This commit is contained in:
WithoutPants 2021-04-12 09:31:33 +10:00 committed by GitHub
parent c38660d209
commit f6ffda7504
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1467 additions and 682 deletions

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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

View File

@ -5,3 +5,12 @@ query JobStatus {
message
}
}
query SystemStatus {
systemStatus {
databaseSchema
databasePath
appSchema
status
}
}

View File

@ -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!

View File

@ -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

View File

@ -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!
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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(),

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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
}

View File

@ -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())
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
})
}

View File

@ -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

View File

@ -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"))
}

View File

@ -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 {

View File

@ -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())
}

View File

@ -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()
}

104
pkg/manager/config/init.go Normal file
View File

@ -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
}
}

View File

@ -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,
}
}

View File

@ -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

View File

@ -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")
}

View File

@ -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
}

View File

@ -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,

View File

@ -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
}

View File

@ -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")

View File

@ -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())

View File

@ -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()

View File

@ -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,

View File

@ -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)
}

View File

@ -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) {

View File

@ -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...)
}

View File

@ -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 := `
<div>
@ -805,7 +823,7 @@ xPathScrapers:
return
}
globalConfig := GlobalConfig{}
globalConfig := mockGlobalConfig{}
performer, err := c.ScrapePerformerURL(ts.URL, nil, globalConfig)

View File

@ -29,6 +29,10 @@ func (t *transaction) Begin() error {
return errors.New("transaction already begun")
}
if err := database.Ready(); err != nil {
return err
}
var err error
t.tx, err = database.DB.BeginTxx(t.Ctx, nil)
if err != nil {
@ -124,6 +128,10 @@ func (t *transaction) Tag() models.TagReaderWriter {
type ReadTransaction struct{}
func (t *ReadTransaction) Begin() error {
if err := database.Ready(); err != nil {
return err
}
return nil
}

View File

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stash</title>
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
<link rel="stylesheet" href="/setup/milligram.min.css">
</head>
<body>
<div class="container">
<form action="/init" method="POST">
<fieldset>
<label for="stash">Where is your porn located (mp4, wmv, zip, etc)?</label>
<input name="stash" type="text" placeholder="EX: C:\videos (Windows) or /User/StashApp/Videos (macOS / Linux)" />
<label for="generated">In order to provide previews Stash generates images and videos. This also includes transcodes for unsupported file formats. Where would you like to save generated files?</label>
<input name="generated" type="text" placeholder="EX: C:\stash\generated (Windows) or /User/StashApp/stash/generated (macOS / Linux)" />
<label for="metadata">Where would you like to save metadata? Metadata is stored as JSON files and can be created using the export button in settings.</label>
<input name="metadata" type="text" placeholder="EX: C:\stash\metadata (Windows) or /User/StashApp/stash/metadata (macOS / Linux)" />
<label for="cache">Where do you want to Stash to save cache / temporary files it might need to create?</label>
<input name="cache" type="text" placeholder="EX: C:\stash\cache (Windows) or /User/StashApp/stash/cache (macOS / Linux)" />
<input hidden name="downloads" value="">
<div>
<input class="button button-black" type="submit" value="Submit">
</div>
</fieldset>
</form>
</div>
</body>
</html>

View File

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stash</title>
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
<link rel="stylesheet" href="/setup/milligram.min.css">
</head>
<body>
<div class="container">
<p>
Your current stash database is schema version <strong>{{.ExistingVersion}}</strong> and needs to be migrated to version <strong>{{.MigrateVersion}}</strong>.
This version of Stash will not function without migrating the database. <strong>The schema migration process is not reversible. Once the migration is
performed, your database will be incompatible with previous versions of stash.</strong>
</p>
<p>
It is recommended that you backup your existing database before you migrate. We can do this for you, writing a backup to <code>{{.BackupPath}}</code> if required.
</p>
<form action="/migrate" method="POST">
<fieldset>
<label for="stash">Backup database path (leave empty to disable backup):</label>
<input name="backuppath" type="text" value="{{.BackupPath}}" />
<div>
<input class="button button-black" type="submit" value="Perform schema migration">
</div>
</fieldset>
</form>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import React, { useEffect } from "react";
import { Route, Switch, useRouteMatch } from "react-router-dom";
import { IntlProvider } from "react-intl";
import { ToastProvider } from "src/hooks/Toast";
import LightboxProvider from "src/hooks/Lightbox/context";
@ -8,7 +8,7 @@ import { fas } from "@fortawesome/free-solid-svg-icons";
import { initPolyfills } from "src/polyfills";
import locales from "src/locale";
import { useConfiguration } from "src/core/StashService";
import { useConfiguration, useSystemStatus } from "src/core/StashService";
import { flattenMessages } from "src/utils";
import Mousetrap from "mousetrap";
import MousetrapPause from "mousetrap-pause";
@ -25,6 +25,10 @@ import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilen
import Movies from "./components/Movies/Movies";
import Tags from "./components/Tags/Tags";
import Images from "./components/Images/Images";
import { Setup } from "./components/Setup/Setup";
import { Migrate } from "./components/Setup/Migrate";
import * as GQL from "./core/generated-graphql";
import { LoadingIndicator } from "./components/Shared";
initPolyfills();
@ -41,35 +45,78 @@ const intlFormats = {
export const App: React.FC = () => {
const config = useConfiguration();
const { data: systemStatusData } = useSystemStatus();
const language = config.data?.configuration?.interface?.language ?? "en-GB";
const messageLanguage = language.replace(/-/, "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages = flattenMessages((locales as any)[messageLanguage]);
const setupMatch = useRouteMatch(["/setup", "/migrate"]);
// redirect to setup or migrate as needed
useEffect(() => {
if (!systemStatusData) {
return;
}
if (
window.location.pathname !== "/setup" &&
systemStatusData.systemStatus.status === GQL.SystemStatusEnum.Setup
) {
// redirect to setup page
const newURL = new URL("/setup", window.location.toString());
window.location.href = newURL.toString();
}
if (
window.location.pathname !== "/migrate" &&
systemStatusData.systemStatus.status ===
GQL.SystemStatusEnum.NeedsMigration
) {
// redirect to setup page
const newURL = new URL("/migrate", window.location.toString());
window.location.href = newURL.toString();
}
}, [systemStatusData]);
function maybeRenderNavbar() {
// don't render navbar for setup views
if (!setupMatch) {
return <MainNavbar />;
}
}
function renderContent() {
if (!systemStatusData) {
return <LoadingIndicator />;
}
return (
<Switch>
<Route exact path="/" component={Stats} />
<Route path="/scenes" component={Scenes} />
<Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route path="/setup" component={Setup} />
<Route path="/migrate" component={Migrate} />
<Route component={PageNotFound} />
</Switch>
);
}
return (
<ErrorBoundary>
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
<ToastProvider>
<LightboxProvider>
<MainNavbar />
<div className="main container-fluid">
<Switch>
<Route exact path="/" component={Stats} />
<Route path="/scenes" component={Scenes} />
<Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/settings" component={Settings} />
<Route
path="/sceneFilenameParser"
component={SceneFilenameParser}
/>
<Route component={PageNotFound} />
</Switch>
</div>
{maybeRenderNavbar()}
<div className="main container-fluid">{renderContent()}</div>
</LightboxProvider>
</ToastProvider>
</IntlProvider>

View File

@ -4,6 +4,7 @@
* Added scene queue.
### 🎨 Improvements
* Revamped setup wizard and migration UI.
* Add various `count` filter criteria and sort options.
* Scroll to top when changing page number.
* Add URL filter criteria for scenes, galleries, movies, performers and studios.

View File

@ -116,9 +116,13 @@ export const SettingsTasksPanel: React.FC = () => {
function onImport() {
setIsImportAlertOpen(false);
mutateMetadataImport().then(() => {
jobStatus.refetch();
});
mutateMetadataImport()
.then(() => {
jobStatus.refetch();
})
.catch((e) => {
Toast.error(e);
});
}
function renderImportAlert() {
@ -535,9 +539,11 @@ export const SettingsTasksPanel: React.FC = () => {
variant="secondary"
type="submit"
onClick={() =>
mutateMetadataExport().then(() => {
jobStatus.refetch();
})
mutateMetadataExport()
.then(() => {
jobStatus.refetch();
})
.catch((e) => Toast.error(e))
}
>
Full Export

View File

@ -0,0 +1,158 @@
import React, { useEffect, useState } from "react";
import { Button, Card, Container, Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { useSystemStatus, mutateMigrate } from "src/core/StashService";
import { LoadingIndicator } from "../Shared";
export const Migrate: React.FC = () => {
const { data: systemStatus, loading } = useSystemStatus();
const [backupPath, setBackupPath] = useState<string | undefined>();
const [migrateLoading, setMigrateLoading] = useState(false);
const [migrateError, setMigrateError] = useState("");
// make suffix based on current time
const now = new Date()
.toISOString()
.replace(/T/g, "_")
.replace(/-/g, "")
.replace(/:/g, "")
.replace(/\..*/, "");
const defaultBackupPath = systemStatus
? `${systemStatus.systemStatus.databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}`
: "";
const discordLink = (
<a href="https://discord.gg/2TsNFKt" target="_blank" rel="noreferrer">
Discord
</a>
);
const githubLink = (
<a
href="https://github.com/stashapp/stash/issues"
target="_blank"
rel="noreferrer"
>
Github repository
</a>
);
useEffect(() => {
if (backupPath === undefined && defaultBackupPath) {
setBackupPath(defaultBackupPath);
}
}, [defaultBackupPath, backupPath]);
// only display setup wizard if system is not setup
if (loading || !systemStatus) {
return <LoadingIndicator />;
}
if (migrateLoading) {
return <LoadingIndicator message="Migrating database" />;
}
if (
systemStatus.systemStatus.status !== GQL.SystemStatusEnum.NeedsMigration
) {
// redirect to main page
const newURL = new URL("/", window.location.toString());
window.location.href = newURL.toString();
return <LoadingIndicator />;
}
const status = systemStatus.systemStatus;
async function onMigrate() {
try {
setMigrateLoading(true);
setMigrateError("");
await mutateMigrate({
backupPath: backupPath ?? "",
});
const newURL = new URL("/", window.location.toString());
window.location.href = newURL.toString();
} catch (e) {
setMigrateError(e.message ?? e.toString());
setMigrateLoading(false);
}
}
function maybeRenderError() {
if (!migrateError) {
return;
}
return (
<section>
<h2 className="text-danger">Migration failed</h2>
<p>The following error was encountered while migrating the database:</p>
<Card>
<pre>{migrateError}</pre>
</Card>
<p>
Please make any necessary corrections and try again. Otherwise, raise
a bug on the {githubLink} or seek help in the {discordLink}.
</p>
</section>
);
}
return (
<Container>
<h1 className="text-center mb-3">Migration required</h1>
<Card>
<section>
<p>
Your current stash database is schema version{" "}
<strong>{status.databaseSchema}</strong> and needs to be migrated to
version <strong>{status.appSchema}</strong>. This version of Stash
will not function without migrating the database.
</p>
<p className="lead text-center my-5">
The schema migration process is not reversible. Once the migration
is performed, your database will be incompatible with previous
versions of stash.
</p>
<p>
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 <code>{defaultBackupPath}</code> if required.
</p>
</section>
<section>
<Form.Group id="migrate">
<Form.Label>
Backup database path (leave empty to disable backup):
</Form.Label>
<Form.Control
className="text-input"
name="backupPath"
defaultValue={backupPath}
placeholder="database filename (empty for default)"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setBackupPath(e.currentTarget.value)
}
/>
</Form.Group>
</section>
<section>
<div className="d-flex justify-content-center">
<Button variant="primary mx-2 p-5" onClick={() => onMigrate()}>
Perform schema migration
</Button>
</div>
</section>
{maybeRenderError()}
</Card>
</Container>
);
};

View File

@ -0,0 +1,460 @@
import React, { useState } from "react";
import {
Alert,
Button,
Card,
Container,
Form,
InputGroup,
} from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { mutateSetup, useSystemStatus } from "src/core/StashService";
import { Link } from "react-router-dom";
import StashConfiguration from "../Settings/StashConfiguration";
import { Icon, LoadingIndicator } from "../Shared";
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
export const Setup: React.FC = () => {
const [step, setStep] = useState(0);
const [configLocation, setConfigLocation] = useState("");
const [stashes, setStashes] = useState<GQL.StashConfig[]>([]);
const [generatedLocation, setGeneratedLocation] = useState("");
const [databaseFile, setDatabaseFile] = useState("");
const [loading, setLoading] = useState(false);
const [setupError, setSetupError] = useState("");
const [showGeneratedDialog, setShowGeneratedDialog] = useState(false);
const { data: systemStatus, loading: statusLoading } = useSystemStatus();
const discordLink = (
<a href="https://discord.gg/2TsNFKt" target="_blank" rel="noreferrer">
Discord
</a>
);
const githubLink = (
<a
href="https://github.com/stashapp/stash/issues"
target="_blank"
rel="noreferrer"
>
Github repository
</a>
);
function onConfigLocationChosen(loc: string) {
setConfigLocation(loc);
next();
}
function goBack(n?: number) {
let dec = n;
if (!dec) {
dec = 1;
}
setStep(Math.max(0, step - dec));
}
function next() {
setStep(step + 1);
}
function renderWelcome() {
return (
<>
<section>
<h2 className="mb-5">Welcome to Stash</h2>
<p className="lead text-center">
If you&apos;re reading this, then Stash couldn&apos;t find an
existing configuration. This wizard will guide you through the
process of setting up a new configuration.
</p>
<p>
Stash tries to find its configuration file (<code>config.yml</code>)
from the current working directory first, and if it does not find it
there, it falls back to <code>$HOME/.stash/config.yml</code> (on
Windows, this will be <code>%USERPROFILE%\.stash\config.yml</code>).
You can also make Stash read from a specific configuration file by
running it with the <code>-c &lt;path to config file&gt;</code> or{" "}
<code>--config &lt;path to config file&gt;</code> options.
</p>
<Alert variant="info text-center">
If you&apos;re getting this screen unexpectedly, please try
restarting Stash in the correct working directory or with the{" "}
<code>-c</code> flag.
</Alert>
<p>
With all of that out of the way, if you&apos;re ready to proceed
with setting up a new system, choose where you&apos;d like to store
your configuration file and click Next.
</p>
</section>
<section className="mt-5">
<h3 className="text-center mb-5">
Where do you want to store your Stash configuration?
</h3>
<div className="d-flex justify-content-center">
<Button
variant="secondary mx-2 p-5"
onClick={() => onConfigLocationChosen("")}
>
In the <code>$HOME/.stash</code> directory
</Button>
<Button
variant="secondary mx-2 p-5"
onClick={() => onConfigLocationChosen("config.yml")}
>
In the current working directory
</Button>
</div>
</section>
</>
);
}
function onGeneratedClosed(d?: string) {
if (d) {
setGeneratedLocation(d);
}
setShowGeneratedDialog(false);
}
function maybeRenderGeneratedSelectDialog() {
if (!showGeneratedDialog) {
return;
}
return <FolderSelectDialog onClose={onGeneratedClosed} />;
}
function renderSetPaths() {
return (
<>
<section>
<h2 className="mb-3">Set up your paths</h2>
<p>
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.
</p>
</section>
<section>
<Form.Group id="stashes">
<h3>Where is your porn located?</h3>
<p>
Add directories containing your porn videos and images. Stash will
use these directories to find videos and images during scanning.
</p>
<Card>
<StashConfiguration
stashes={stashes}
setStashes={(s) => setStashes(s)}
/>
</Card>
</Form.Group>
<Form.Group id="database">
<h3>Where can Stash store its database?</h3>
<p>
Stash uses an sqlite database to store your porn metadata. By
default, this will be created as <code>stash-go.sqlite</code> 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.
</p>
<Form.Control
className="text-input"
defaultValue={databaseFile}
placeholder="database filename (empty for default)"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDatabaseFile(e.currentTarget.value)
}
/>
</Form.Group>
<Form.Group id="generated">
<h3>Where can Stash store its generated content?</h3>
<p>
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{" "}
<code>generated</code> 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.
</p>
<InputGroup>
<Form.Control
className="text-input"
value={generatedLocation}
placeholder="path to generated directory (empty for default)"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setGeneratedLocation(e.currentTarget.value)
}
/>
<InputGroup.Append>
<Button
variant="secondary"
className="text-input"
onClick={() => setShowGeneratedDialog(true)}
>
<Icon icon="ellipsis-h" />
</Button>
</InputGroup.Append>
</InputGroup>
</Form.Group>
</section>
<section className="mt-5">
<div className="d-flex justify-content-center">
<Button variant="secondary mx-2 p-5" onClick={() => goBack()}>
Back
</Button>
<Button variant="primary mx-2 p-5" onClick={() => next()}>
Next
</Button>
</div>
</section>
</>
);
}
function renderConfigLocation() {
if (configLocation === "config.yml") {
return <code>&lt;current working directory&gt;/config.yml</code>;
}
return <code>{configLocation}</code>;
}
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 (
<ul>
{stashes.map((s) => (
<li>
<code>{s.path} </code>
{maybeRenderExclusions(s)}
</li>
))}
</ul>
);
}
async function onSave() {
try {
setLoading(true);
await mutateSetup({
configLocation,
databaseFile,
generatedLocation,
stashes,
});
} catch (e) {
setSetupError(e.message ?? e.toString());
} finally {
setLoading(false);
next();
}
}
function renderConfirm() {
return (
<>
<section>
<h2 className="mb-3">Nearly there!</h2>
<p>
We&apos;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.
</p>
<dl>
<dt>Configuration file location:</dt>
<dd>{renderConfigLocation()}</dd>
</dl>
<dl>
<dt>Stash library directories</dt>
<dd>{renderStashLibraries()}</dd>
</dl>
<dl>
<dt>Database file path</dt>
<dd>
<code>
{databaseFile !== ""
? databaseFile
: `<path containing configuration file>/stash-go.sqlite`}
</code>
</dd>
</dl>
<dl>
<dt>Generated directory</dt>
<dd>
<code>
{generatedLocation !== ""
? generatedLocation
: `<path containing configuration file>/generated`}
</code>
</dd>
</dl>
</section>
<section className="mt-5">
<div className="d-flex justify-content-center">
<Button variant="secondary mx-2 p-5" onClick={() => goBack()}>
Back
</Button>
<Button variant="success mx-2 p-5" onClick={() => onSave()}>
Confirm
</Button>
</div>
</section>
</>
);
}
function renderError() {
return (
<>
<section>
<h2>Oh no! Something went wrong!</h2>
<p>
Something went wrong while setting up your system. Here is the error
we received:
<pre>{setupError}</pre>
</p>
<p>
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}.
</p>
</section>
<section className="mt-5">
<div className="d-flex justify-content-center">
<Button variant="secondary mx-2 p-5" onClick={() => goBack(2)}>
Back
</Button>
</div>
</section>
</>
);
}
function renderSuccess() {
return (
<>
<section>
<h2>Success! Your system has been created!</h2>
<p>
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.
</p>
<p>
When you are satisfied with these settings, you can begin scanning
your content into Stash by clicking on <code>Tasks</code>, then{" "}
<code>Scan</code>.
</p>
</section>
<section>
<h3>Getting help</h3>
<p>
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: <Icon icon="question-circle" />
</p>
<p>
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}.
</p>
</section>
<section>
<h3>Support us</h3>
<p>
Check out our{" "}
<a
href="https://opencollective.com/stashapp"
target="_blank"
rel="noreferrer"
>
OpenCollective
</a>{" "}
to see how you can contribute to the continued development of Stash.
</p>
<p>
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.
</p>
</section>
<section>
<p className="lead text-center">Thanks for trying Stash!</p>
</section>
<section className="mt-5">
<div className="d-flex justify-content-center">
<Link to="/settings?tab=configuration">
<Button variant="success mx-2 p-5" onClick={() => goBack(2)}>
Finish
</Button>
</Link>
</div>
</section>
</>
);
}
function renderFinish() {
if (setupError) {
return renderError();
}
return renderSuccess();
}
const steps = [renderWelcome, renderSetPaths, renderConfirm, renderFinish];
// only display setup wizard if system is not setup
if (statusLoading) {
return <LoadingIndicator />;
}
if (
systemStatus &&
systemStatus.systemStatus.status !== GQL.SystemStatusEnum.Setup
) {
// redirect to main page
const newURL = new URL("/", window.location.toString());
window.location.href = newURL.toString();
return <LoadingIndicator />;
}
return (
<Container>
{maybeRenderGeneratedSelectDialog()}
<h1 className="text-center">Stash Setup Wizard</h1>
{loading ? (
<LoadingIndicator message="Creating your system" />
) : (
<Card>{steps[step]()}</Card>
)}
</Container>
);
};

View File

@ -281,6 +281,20 @@ export const useLatestVersion = () =>
});
export const useConfiguration = () => GQL.useConfigurationQuery();
export const mutateSetup = (input: GQL.SetupInput) =>
client.mutate<GQL.SetupMutation>({
mutation: GQL.SetupDocument,
variables: { input },
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
update: deleteCache([GQL.ConfigurationDocument]),
});
export const mutateMigrate = (input: GQL.MigrateInput) =>
client.mutate<GQL.MigrateMutation>({
mutation: GQL.MigrateDocument,
variables: { input },
});
export const useDirectory = (path?: string) =>
GQL.useDirectoryQuery({ variables: { path } });
@ -690,6 +704,17 @@ export const useMetadataUpdate = () => GQL.useMetadataUpdateSubscription();
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
export const querySystemStatus = () =>
client.query<GQL.SystemStatusQuery>({
query: GQL.SystemStatusDocument,
fetchPolicy: "no-cache",
});
export const useSystemStatus = () =>
GQL.useSystemStatusQuery({
fetchPolicy: "no-cache",
});
export const useLogs = () =>
GQL.useLogsQuery({
fetchPolicy: "no-cache",