mirror of https://github.com/stashapp/stash.git
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:
parent
c38660d209
commit
f6ffda7504
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,3 +5,12 @@ query JobStatus {
|
|||
message
|
||||
}
|
||||
}
|
||||
|
||||
query SystemStatus {
|
||||
systemStatus {
|
||||
databaseSchema
|
||||
databasePath
|
||||
appSchema
|
||||
status
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
|
|
8
main.go
8
main.go
|
@ -3,9 +3,7 @@ package main
|
|||
|
||||
import (
|
||||
"github.com/stashapp/stash/pkg/api"
|
||||
"github.com/stashapp/stash/pkg/database"
|
||||
"github.com/stashapp/stash/pkg/manager"
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
|
@ -13,12 +11,6 @@ import (
|
|||
|
||||
func main() {
|
||||
manager.Initialize()
|
||||
|
||||
// perform the post-migration for new databases
|
||||
if database.Initialize(config.GetDatabasePath()) {
|
||||
manager.GetInstance().PostMigrate()
|
||||
}
|
||||
|
||||
api.Start()
|
||||
blockForever()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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're reading this, then Stash couldn't find an
|
||||
existing configuration. This wizard will guide you through the
|
||||
process of setting up a new configuration.
|
||||
</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 <path to config file></code> or{" "}
|
||||
<code>--config <path to config file></code> options.
|
||||
</p>
|
||||
<Alert variant="info text-center">
|
||||
If you'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're ready to proceed
|
||||
with setting up a new system, choose where you'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><current working directory>/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'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>
|
||||
);
|
||||
};
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue