mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
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:
@@ -53,6 +53,8 @@ FROM ubuntu:20.04 as app
|
|||||||
RUN apt-get update && apt-get -y install ca-certificates
|
RUN apt-get update && apt-get -y install ca-certificates
|
||||||
COPY --from=compiler /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
COPY --from=compiler /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
||||||
|
|
||||||
|
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||||
|
|
||||||
EXPOSE 9999
|
EXPOSE 9999
|
||||||
CMD ["stash"]
|
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/*
|
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/
|
COPY --from=prep /stash /usr/bin/
|
||||||
|
|
||||||
|
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||||
|
|
||||||
EXPOSE 9999
|
EXPOSE 9999
|
||||||
CMD ["stash"]
|
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
|
FROM ubuntu:20.04 as app
|
||||||
RUN apt-get update && apt-get -y install ca-certificates
|
RUN apt-get update && apt-get -y install ca-certificates
|
||||||
COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
||||||
|
|
||||||
|
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||||
|
|
||||||
EXPOSE 9999
|
EXPOSE 9999
|
||||||
CMD ["stash"]
|
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
|
FROM ubuntu:20.04 as app
|
||||||
RUN apt-get update && apt-get -y install ca-certificates
|
RUN apt-get update && apt-get -y install ca-certificates
|
||||||
COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
||||||
|
|
||||||
|
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||||
|
|
||||||
EXPOSE 9999
|
EXPOSE 9999
|
||||||
CMD ["stash"]
|
CMD ["stash"]
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
mutation Setup($input: SetupInput!) {
|
||||||
|
setup(input: $input)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation Migrate($input: MigrateInput!) {
|
||||||
|
migrate(input: $input)
|
||||||
|
}
|
||||||
|
|
||||||
mutation ConfigureGeneral($input: ConfigGeneralInput!) {
|
mutation ConfigureGeneral($input: ConfigGeneralInput!) {
|
||||||
configureGeneral(input: $input) {
|
configureGeneral(input: $input) {
|
||||||
...ConfigGeneralData
|
...ConfigGeneralData
|
||||||
|
|||||||
@@ -5,3 +5,12 @@ query JobStatus {
|
|||||||
message
|
message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query SystemStatus {
|
||||||
|
systemStatus {
|
||||||
|
databaseSchema
|
||||||
|
databasePath
|
||||||
|
appSchema
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ type Query {
|
|||||||
directory(path: String): Directory!
|
directory(path: String): Directory!
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
|
systemStatus: SystemStatus!
|
||||||
jobStatus: MetadataUpdateStatus!
|
jobStatus: MetadataUpdateStatus!
|
||||||
|
|
||||||
# Get everything
|
# Get everything
|
||||||
@@ -126,6 +126,9 @@ type Query {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
setup(input: SetupInput!): Boolean!
|
||||||
|
migrate(input: MigrateInput!): Boolean!
|
||||||
|
|
||||||
sceneUpdate(input: SceneUpdateInput!): Scene
|
sceneUpdate(input: SceneUpdateInput!): Scene
|
||||||
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
|
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
|
||||||
sceneDestroy(input: SceneDestroyInput!): Boolean!
|
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 {
|
enum StreamingResolutionEnum {
|
||||||
"240p", LOW
|
"240p", LOW
|
||||||
"480p", STANDARD
|
"480p", STANDARD
|
||||||
|
|||||||
@@ -106,3 +106,20 @@ input ImportObjectsInput {
|
|||||||
input BackupDatabaseInput {
|
input BackupDatabaseInput {
|
||||||
download: Boolean
|
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 (
|
import (
|
||||||
"github.com/stashapp/stash/pkg/api"
|
"github.com/stashapp/stash/pkg/api"
|
||||||
"github.com/stashapp/stash/pkg/database"
|
|
||||||
"github.com/stashapp/stash/pkg/manager"
|
"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/database/sqlite3"
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
@@ -13,12 +11,6 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
manager.Initialize()
|
manager.Initialize()
|
||||||
|
|
||||||
// perform the post-migration for new databases
|
|
||||||
if database.Initialize(config.GetDatabasePath()) {
|
|
||||||
manager.GetInstance().PostMigrate()
|
|
||||||
}
|
|
||||||
|
|
||||||
api.Start()
|
api.Start()
|
||||||
blockForever()
|
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"
|
"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) {
|
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.ConfigGeneralInput) (*models.ConfigGeneralResult, error) {
|
||||||
|
c := config.GetInstance()
|
||||||
if len(input.Stashes) > 0 {
|
if len(input.Stashes) > 0 {
|
||||||
for _, s := range input.Stashes {
|
for _, s := range input.Stashes {
|
||||||
exists, err := utils.DirExists(s.Path)
|
exists, err := utils.DirExists(s.Path)
|
||||||
@@ -21,7 +32,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
|
|||||||
return makeConfigGeneralResult(), err
|
return makeConfigGeneralResult(), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config.Set(config.Stash, input.Stashes)
|
c.Set(config.Stash, input.Stashes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.DatabasePath != nil {
|
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" {
|
if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" {
|
||||||
return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or 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 input.GeneratedPath != nil {
|
||||||
if err := utils.EnsureDir(*input.GeneratedPath); err != nil {
|
if err := utils.EnsureDir(*input.GeneratedPath); err != nil {
|
||||||
return makeConfigGeneralResult(), err
|
return makeConfigGeneralResult(), err
|
||||||
}
|
}
|
||||||
config.Set(config.Generated, input.GeneratedPath)
|
c.Set(config.Generated, input.GeneratedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.CachePath != nil {
|
if input.CachePath != nil {
|
||||||
if err := utils.EnsureDir(*input.CachePath); err != nil {
|
if *input.CachePath != "" {
|
||||||
return makeConfigGeneralResult(), err
|
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 {
|
if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
|
||||||
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
|
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.VideoFileNamingAlgorithm != config.GetVideoFileNamingAlgorithm() {
|
if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
|
||||||
// validate changing VideoFileNamingAlgorithm
|
// validate changing VideoFileNamingAlgorithm
|
||||||
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil {
|
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil {
|
||||||
return makeConfigGeneralResult(), err
|
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 {
|
if input.ParallelTasks != nil {
|
||||||
config.Set(config.ParallelTasks, *input.ParallelTasks)
|
c.Set(config.ParallelTasks, *input.ParallelTasks)
|
||||||
}
|
}
|
||||||
if input.PreviewSegments != nil {
|
if input.PreviewSegments != nil {
|
||||||
config.Set(config.PreviewSegments, *input.PreviewSegments)
|
c.Set(config.PreviewSegments, *input.PreviewSegments)
|
||||||
}
|
}
|
||||||
if input.PreviewSegmentDuration != nil {
|
if input.PreviewSegmentDuration != nil {
|
||||||
config.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration)
|
c.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration)
|
||||||
}
|
}
|
||||||
if input.PreviewExcludeStart != nil {
|
if input.PreviewExcludeStart != nil {
|
||||||
config.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart)
|
c.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart)
|
||||||
}
|
}
|
||||||
if input.PreviewExcludeEnd != nil {
|
if input.PreviewExcludeEnd != nil {
|
||||||
config.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd)
|
c.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd)
|
||||||
}
|
}
|
||||||
if input.PreviewPreset != nil {
|
if input.PreviewPreset != nil {
|
||||||
config.Set(config.PreviewPreset, input.PreviewPreset.String())
|
c.Set(config.PreviewPreset, input.PreviewPreset.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.MaxTranscodeSize != nil {
|
if input.MaxTranscodeSize != nil {
|
||||||
config.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
|
c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.MaxStreamingTranscodeSize != nil {
|
if input.MaxStreamingTranscodeSize != nil {
|
||||||
config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
|
c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Username != nil {
|
if input.Username != nil {
|
||||||
config.Set(config.Username, input.Username)
|
c.Set(config.Username, input.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Password != nil {
|
if input.Password != nil {
|
||||||
// bit of a hack - check if the passed in password is the same as the stored hash
|
// bit of a hack - check if the passed in password is the same as the stored hash
|
||||||
// and only set if they are different
|
// and only set if they are different
|
||||||
currentPWHash := config.GetPasswordHash()
|
currentPWHash := c.GetPasswordHash()
|
||||||
|
|
||||||
if *input.Password != currentPWHash {
|
if *input.Password != currentPWHash {
|
||||||
config.SetPassword(*input.Password)
|
c.SetPassword(*input.Password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.MaxSessionAge != nil {
|
if input.MaxSessionAge != nil {
|
||||||
config.Set(config.MaxSessionAge, *input.MaxSessionAge)
|
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.LogFile != nil {
|
if input.LogFile != nil {
|
||||||
config.Set(config.LogFile, input.LogFile)
|
c.Set(config.LogFile, input.LogFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Set(config.LogOut, input.LogOut)
|
c.Set(config.LogOut, input.LogOut)
|
||||||
config.Set(config.LogAccess, input.LogAccess)
|
c.Set(config.LogAccess, input.LogAccess)
|
||||||
|
|
||||||
if input.LogLevel != config.GetLogLevel() {
|
if input.LogLevel != c.GetLogLevel() {
|
||||||
config.Set(config.LogLevel, input.LogLevel)
|
c.Set(config.LogLevel, input.LogLevel)
|
||||||
logger.SetLogLevel(input.LogLevel)
|
logger.SetLogLevel(input.LogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Excludes != nil {
|
if input.Excludes != nil {
|
||||||
config.Set(config.Exclude, input.Excludes)
|
c.Set(config.Exclude, input.Excludes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.ImageExcludes != nil {
|
if input.ImageExcludes != nil {
|
||||||
config.Set(config.ImageExclude, input.ImageExcludes)
|
c.Set(config.ImageExclude, input.ImageExcludes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.VideoExtensions != nil {
|
if input.VideoExtensions != nil {
|
||||||
config.Set(config.VideoExtensions, input.VideoExtensions)
|
c.Set(config.VideoExtensions, input.VideoExtensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.ImageExtensions != nil {
|
if input.ImageExtensions != nil {
|
||||||
config.Set(config.ImageExtensions, input.ImageExtensions)
|
c.Set(config.ImageExtensions, input.ImageExtensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.GalleryExtensions != nil {
|
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
|
refreshScraperCache := false
|
||||||
if input.ScraperUserAgent != nil {
|
if input.ScraperUserAgent != nil {
|
||||||
config.Set(config.ScraperUserAgent, input.ScraperUserAgent)
|
c.Set(config.ScraperUserAgent, input.ScraperUserAgent)
|
||||||
refreshScraperCache = true
|
refreshScraperCache = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.ScraperCDPPath != nil {
|
if input.ScraperCDPPath != nil {
|
||||||
config.Set(config.ScraperCDPPath, input.ScraperCDPPath)
|
c.Set(config.ScraperCDPPath, input.ScraperCDPPath)
|
||||||
refreshScraperCache = true
|
refreshScraperCache = true
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Set(config.ScraperCertCheck, input.ScraperCertCheck)
|
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
|
||||||
|
|
||||||
if input.StashBoxes != nil {
|
if input.StashBoxes != nil {
|
||||||
if err := config.ValidateStashBoxes(input.StashBoxes); err != nil {
|
if err := c.ValidateStashBoxes(input.StashBoxes); err != nil {
|
||||||
return nil, err
|
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
|
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) {
|
func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) {
|
||||||
|
c := config.GetInstance()
|
||||||
if input.MenuItems != nil {
|
if input.MenuItems != nil {
|
||||||
config.Set(config.MenuItems, input.MenuItems)
|
c.Set(config.MenuItems, input.MenuItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.SoundOnPreview != nil {
|
if input.SoundOnPreview != nil {
|
||||||
config.Set(config.SoundOnPreview, *input.SoundOnPreview)
|
c.Set(config.SoundOnPreview, *input.SoundOnPreview)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.WallShowTitle != nil {
|
if input.WallShowTitle != nil {
|
||||||
config.Set(config.WallShowTitle, *input.WallShowTitle)
|
c.Set(config.WallShowTitle, *input.WallShowTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.WallPlayback != nil {
|
if input.WallPlayback != nil {
|
||||||
config.Set(config.WallPlayback, *input.WallPlayback)
|
c.Set(config.WallPlayback, *input.WallPlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.MaximumLoopDuration != nil {
|
if input.MaximumLoopDuration != nil {
|
||||||
config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
|
c.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.AutostartVideo != nil {
|
if input.AutostartVideo != nil {
|
||||||
config.Set(config.AutostartVideo, *input.AutostartVideo)
|
c.Set(config.AutostartVideo, *input.AutostartVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.ShowStudioAsText != nil {
|
if input.ShowStudioAsText != nil {
|
||||||
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
|
c.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Language != nil {
|
if input.Language != nil {
|
||||||
config.Set(config.Language, *input.Language)
|
c.Set(config.Language, *input.Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
css := ""
|
css := ""
|
||||||
@@ -211,13 +225,13 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
|||||||
css = *input.CSS
|
css = *input.CSS
|
||||||
}
|
}
|
||||||
|
|
||||||
config.SetCSS(css)
|
c.SetCSS(css)
|
||||||
|
|
||||||
if input.CSSEnabled != nil {
|
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
|
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) {
|
func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) {
|
||||||
|
c := config.GetInstance()
|
||||||
|
|
||||||
var newAPIKey string
|
var newAPIKey string
|
||||||
if input.Clear == nil || !*input.Clear {
|
if input.Clear == nil || !*input.Clear {
|
||||||
username := config.GetUsername()
|
username := c.GetUsername()
|
||||||
if username != "" {
|
if username != "" {
|
||||||
var err error
|
var err error
|
||||||
newAPIKey, err = manager.GenerateAPIKey(username)
|
newAPIKey, err = manager.GenerateAPIKey(username)
|
||||||
@@ -237,8 +253,8 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Set(config.ApiKey, newAPIKey)
|
c.Set(config.ApiKey, newAPIKey)
|
||||||
if err := config.Write(); err != nil {
|
if err := c.Write(); err != nil {
|
||||||
return newAPIKey, err
|
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) {
|
func (r *mutationResolver) MetadataImport(ctx context.Context) (string, error) {
|
||||||
manager.GetInstance().Import()
|
if err := manager.GetInstance().Import(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return "todo", nil
|
return "todo", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) ImportObjects(ctx context.Context, input models.ImportObjectsInput) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) {
|
||||||
manager.GetInstance().Export()
|
if err := manager.GetInstance().Export(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return "todo", nil
|
return "todo", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) ExportObjects(ctx context.Context, input models.ExportObjectsInput) (*string, error) {
|
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)
|
wg, err := manager.GetInstance().RunSingleTask(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
serverConnection := common.StashServerConnection{
|
serverConnection := common.StashServerConnection{
|
||||||
Scheme: "http",
|
Scheme: "http",
|
||||||
Port: config.GetPort(),
|
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
|
// only update the cover image if provided and everything else was successful
|
||||||
if coverImageData != nil {
|
if coverImageData != nil {
|
||||||
err = manager.SetSceneScreenshot(scene.GetHash(config.GetVideoFileNamingAlgorithm()), coverImageData)
|
err = manager.SetSceneScreenshot(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// if delete generated is true, then delete the generated files
|
||||||
// for the scene
|
// for the scene
|
||||||
if input.DeleteGenerated != nil && *input.DeleteGenerated {
|
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
|
// 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()
|
f()
|
||||||
}
|
}
|
||||||
|
|
||||||
fileNamingAlgo := config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||||
for _, scene := range scenes {
|
for _, scene := range scenes {
|
||||||
// if delete generated is true, then delete the generated files
|
// if delete generated is true, then delete the generated files
|
||||||
// for the scene
|
// 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
|
// remove the marker preview if the timestamp was changed
|
||||||
if scene != nil && existingMarker != nil && existingMarker.Seconds != changedMarker.Seconds {
|
if scene != nil && existingMarker != nil && existingMarker.Seconds != changedMarker.Seconds {
|
||||||
seconds := int(existingMarker.Seconds)
|
seconds := int(existingMarker.Seconds)
|
||||||
manager.DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm())
|
manager.DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||||
}
|
}
|
||||||
|
|
||||||
return sceneMarker, nil
|
return sceneMarker, nil
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input models.StashBoxFingerprintSubmissionInput) (bool, error) {
|
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) {
|
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
|
||||||
return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
|
return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func makeConfigResult() *models.ConfigResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
||||||
|
config := config.GetInstance()
|
||||||
logFile := config.GetLogFile()
|
logFile := config.GetLogFile()
|
||||||
|
|
||||||
maxTranscodeSize := config.GetMaxTranscodeSize()
|
maxTranscodeSize := config.GetMaxTranscodeSize()
|
||||||
@@ -81,6 +82,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||||
|
config := config.GetInstance()
|
||||||
menuItems := config.GetMenuItems()
|
menuItems := config.GetMenuItems()
|
||||||
soundOnPreview := config.GetSoundOnPreview()
|
soundOnPreview := config.GetSoundOnPreview()
|
||||||
wallShowTitle := config.GetWallShowTitle()
|
wallShowTitle := config.GetWallShowTitle()
|
||||||
|
|||||||
@@ -17,3 +17,7 @@ func (r *queryResolver) JobStatus(ctx context.Context) (*models.MetadataUpdateSt
|
|||||||
|
|
||||||
return &ret, nil
|
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)
|
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID)
|
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) {
|
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) {
|
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
|
||||||
return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
|
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) {
|
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
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))
|
filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo))
|
||||||
manager.RegisterStream(filepath, &w)
|
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 := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec)
|
||||||
options.StartTime = startTime
|
options.StartTime = startTime
|
||||||
options.MaxTranscodeSize = config.GetMaxStreamingTranscodeSize()
|
options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize()
|
||||||
if requestedSize != "" {
|
if requestedSize != "" {
|
||||||
options.MaxTranscodeSize = models.StreamingResolutionEnum(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) {
|
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
||||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
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
|
// fall back to the scene image blob if the file isn't present
|
||||||
screenshotExists, _ := utils.FileExists(filepath)
|
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) {
|
func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
|
||||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
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)
|
utils.ServeFileNoCache(w, r, filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
|
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
|
||||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
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)
|
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) {
|
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
|
||||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||||
w.Header().Set("Content-Type", "text/vtt")
|
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)
|
http.ServeFile(w, r, filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
|
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
|
||||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
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)
|
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)
|
http.Error(w, http.StatusText(500), 500)
|
||||||
return
|
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)
|
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)
|
http.Error(w, http.StatusText(500), 500)
|
||||||
return
|
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
|
// If the image doesn't exist, send the placeholder
|
||||||
exists, _ := utils.FileExists(filepath)
|
exists, _ := utils.FileExists(filepath)
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,7 +20,6 @@ import (
|
|||||||
"github.com/gobuffalo/packr/v2"
|
"github.com/gobuffalo/packr/v2"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
"github.com/stashapp/stash/pkg/database"
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/manager"
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
@@ -38,7 +35,6 @@ var githash string
|
|||||||
var uiBox *packr.Box
|
var uiBox *packr.Box
|
||||||
|
|
||||||
//var legacyUiBox *packr.Box
|
//var legacyUiBox *packr.Box
|
||||||
var setupUIBox *packr.Box
|
|
||||||
var loginUIBox *packr.Box
|
var loginUIBox *packr.Box
|
||||||
|
|
||||||
const ApiKeyHeader = "ApiKey"
|
const ApiKeyHeader = "ApiKey"
|
||||||
@@ -50,6 +46,7 @@ func allowUnauthenticated(r *http.Request) bool {
|
|||||||
func authenticateHandler() func(http.Handler) http.Handler {
|
func authenticateHandler() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c := config.GetInstance()
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// translate api key into current user, if present
|
// 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
|
// match against configured API and set userID to the
|
||||||
// configured username. In future, we'll want to
|
// configured username. In future, we'll want to
|
||||||
// get the username from the key.
|
// get the username from the key.
|
||||||
if config.GetAPIKey() != apiKey {
|
if c.GetAPIKey() != apiKey {
|
||||||
w.Header().Add("WWW-Authenticate", `FormBased`)
|
w.Header().Add("WWW-Authenticate", `FormBased`)
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID = config.GetUsername()
|
userID = c.GetUsername()
|
||||||
} else {
|
} else {
|
||||||
// handle session
|
// handle session
|
||||||
userID, err = getSessionUserID(w, r)
|
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
|
// 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 we don't have a userID, then redirect
|
||||||
// if graphql was requested, we just return a forbidden error
|
// if graphql was requested, we just return a forbidden error
|
||||||
if r.URL.Path == "/graphql" {
|
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"
|
const loginEndPoint = "/login"
|
||||||
|
|
||||||
func Start() {
|
func Start() {
|
||||||
uiBox = packr.New("UI Box", "../../ui/v2.5/build")
|
uiBox = packr.New("UI Box", "../../ui/v2.5/build")
|
||||||
//legacyUiBox = packr.New("UI Box", "../../ui/v1/dist/stash-frontend")
|
//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")
|
loginUIBox = packr.New("Login UI Box", "../../ui/login")
|
||||||
|
|
||||||
initSessionStore()
|
initSessionStore()
|
||||||
@@ -128,15 +122,14 @@ func Start() {
|
|||||||
r.Use(authenticateHandler())
|
r.Use(authenticateHandler())
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
if config.GetLogAccess() {
|
c := config.GetInstance()
|
||||||
|
if c.GetLogAccess() {
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
}
|
}
|
||||||
r.Use(middleware.DefaultCompress)
|
r.Use(middleware.DefaultCompress)
|
||||||
r.Use(middleware.StripSlashes)
|
r.Use(middleware.StripSlashes)
|
||||||
r.Use(cors.AllowAll().Handler)
|
r.Use(cors.AllowAll().Handler)
|
||||||
r.Use(BaseURLMiddleware)
|
r.Use(BaseURLMiddleware)
|
||||||
r.Use(ConfigCheckMiddleware)
|
|
||||||
r.Use(DatabaseCheckMiddleware)
|
|
||||||
|
|
||||||
recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
|
recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
|
||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
@@ -150,7 +143,7 @@ func Start() {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
maxUploadSize := handler.UploadMaxSize(config.GetMaxUploadSize())
|
maxUploadSize := handler.UploadMaxSize(c.GetMaxUploadSize())
|
||||||
websocketKeepAliveDuration := handler.WebsocketKeepAliveDuration(10 * time.Second)
|
websocketKeepAliveDuration := handler.WebsocketKeepAliveDuration(10 * time.Second)
|
||||||
|
|
||||||
txnManager := manager.GetInstance().TxnManager
|
txnManager := manager.GetInstance().TxnManager
|
||||||
@@ -191,12 +184,12 @@ func Start() {
|
|||||||
|
|
||||||
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/css")
|
w.Header().Set("Content-Type", "text/css")
|
||||||
if !config.GetCSSEnabled() {
|
if !c.GetCSSEnabled() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// search for custom.css in current directory, then $HOME/.stash
|
// search for custom.css in current directory, then $HOME/.stash
|
||||||
fn := config.GetCSSPath()
|
fn := c.GetCSSPath()
|
||||||
exists, _ := utils.FileExists(fn)
|
exists, _ := utils.FileExists(fn)
|
||||||
if !exists {
|
if !exists {
|
||||||
return
|
return
|
||||||
@@ -205,21 +198,6 @@ func Start() {
|
|||||||
http.ServeFile(w, r, fn)
|
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) {
|
r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
ext := path.Ext(r.URL.Path)
|
ext := path.Ext(r.URL.Path)
|
||||||
if ext == ".html" || ext == "" {
|
if ext == ".html" || ext == "" {
|
||||||
@@ -230,62 +208,9 @@ func Start() {
|
|||||||
http.FileServer(loginUIBox).ServeHTTP(w, r)
|
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
|
// Serve static folders
|
||||||
customServedFolders := config.GetCustomServedFolders()
|
customServedFolders := c.GetCustomServedFolders()
|
||||||
if customServedFolders != nil {
|
if customServedFolders != nil {
|
||||||
r.HandleFunc("/custom/*", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/custom/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1)
|
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" {
|
if displayHost == "0.0.0.0" {
|
||||||
displayHost = "localhost"
|
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 {
|
if tlsConfig := makeTLSConfig(); tlsConfig != nil {
|
||||||
httpsServer := &http.Server{
|
httpsServer := &http.Server{
|
||||||
Addr: address,
|
Addr: address,
|
||||||
@@ -417,7 +342,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
baseURL := scheme + "://" + r.Host
|
baseURL := scheme + "://" + r.Host
|
||||||
|
|
||||||
externalHost := config.GetExternalHost()
|
externalHost := config.GetInstance().GetExternalHost()
|
||||||
if externalHost != "" {
|
if externalHost != "" {
|
||||||
baseURL = externalHost
|
baseURL = externalHost
|
||||||
}
|
}
|
||||||
@@ -428,34 +353,3 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
return http.HandlerFunc(fn)
|
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"
|
const returnURLParam = "returnURL"
|
||||||
|
|
||||||
var sessionStore = sessions.NewCookieStore(config.GetSessionStoreKey())
|
var sessionStore = sessions.NewCookieStore(config.GetInstance().GetSessionStoreKey())
|
||||||
|
|
||||||
type loginTemplateData struct {
|
type loginTemplateData struct {
|
||||||
URL string
|
URL string
|
||||||
@@ -27,7 +27,7 @@ type loginTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initSessionStore() {
|
func initSessionStore() {
|
||||||
sessionStore.MaxAge(config.GetMaxSessionAge())
|
sessionStore.MaxAge(config.GetInstance().GetMaxSessionAge())
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) {
|
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) {
|
func getLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !config.HasCredentials() {
|
if !config.GetInstance().HasCredentials() {
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
||||||
// authenticate the user
|
// authenticate the user
|
||||||
if !config.ValidateCredentials(username, password) {
|
if !config.GetInstance().ValidateCredentials(username, password) {
|
||||||
// redirect back to the login page with an error
|
// redirect back to the login page with an error
|
||||||
redirectToLogin(w, url, "Username or password is invalid")
|
redirectToLogin(w, url, "Username or password is invalid")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,8 +26,27 @@ var dbPath string
|
|||||||
var appSchemaVersion uint = 20
|
var appSchemaVersion uint = 20
|
||||||
var databaseSchemaVersion uint
|
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"
|
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() {
|
func init() {
|
||||||
// register custom driver with regexp function
|
// register custom driver with regexp function
|
||||||
registerCustomDriver()
|
registerCustomDriver()
|
||||||
@@ -37,20 +56,20 @@ func init() {
|
|||||||
// performs a full migration to the latest schema version. Otherwise, any
|
// performs a full migration to the latest schema version. Otherwise, any
|
||||||
// necessary migrations must be run separately using RunMigrations.
|
// necessary migrations must be run separately using RunMigrations.
|
||||||
// Returns true if the database is new.
|
// Returns true if the database is new.
|
||||||
func Initialize(databasePath string) bool {
|
func Initialize(databasePath string) error {
|
||||||
dbPath = databasePath
|
dbPath = databasePath
|
||||||
|
|
||||||
if err := getDatabaseSchemaVersion(); err != nil {
|
if err := getDatabaseSchemaVersion(); err != nil {
|
||||||
panic(err)
|
return fmt.Errorf("error getting database schema version: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if databaseSchemaVersion == 0 {
|
if databaseSchemaVersion == 0 {
|
||||||
// new database, just run the migrations
|
// new database, just run the migrations
|
||||||
if err := RunMigrations(); err != nil {
|
if err := RunMigrations(); err != nil {
|
||||||
panic(err)
|
return fmt.Errorf("error running initial schema migrations: %s", err.Error())
|
||||||
}
|
}
|
||||||
// RunMigrations calls Initialise. Just return
|
// RunMigrations calls Initialise. Just return
|
||||||
return true
|
return nil
|
||||||
} else {
|
} else {
|
||||||
if databaseSchemaVersion > appSchemaVersion {
|
if databaseSchemaVersion > appSchemaVersion {
|
||||||
panic(fmt.Sprintf("Database schema version %d is incompatible with required schema version %d", 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 migration is needed, then don't open the connection
|
||||||
if NeedsMigration() {
|
if NeedsMigration() {
|
||||||
logger.Warnf("Database schema version %d does not match required schema version %d.", databaseSchemaVersion, appSchemaVersion)
|
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)
|
DB = open(databasePath, disableForeignKeys)
|
||||||
WriteMu = &sync.Mutex{}
|
WriteMu = &sync.Mutex{}
|
||||||
|
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(databasePath string, disableForeignKeys bool) *sqlx.DB {
|
func open(databasePath string, disableForeignKeys bool) *sqlx.DB {
|
||||||
@@ -150,6 +169,10 @@ func AppSchemaVersion() uint {
|
|||||||
return appSchemaVersion
|
return appSchemaVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DatabasePath() string {
|
||||||
|
return dbPath
|
||||||
|
}
|
||||||
|
|
||||||
func DatabaseBackupPath() string {
|
func DatabaseBackupPath() string {
|
||||||
return fmt.Sprintf("%s.%d.%s", dbPath, databaseSchemaVersion, time.Now().Format("20060102_150405"))
|
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)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
ss, err := token.SignedString(config.GetJWTSignKey())
|
ss, err := token.SignedString(config.GetInstance().GetJWTSignKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func GenerateAPIKey(userID string) (string, error) {
|
|||||||
func GetUserIDFromAPIKey(apiKey string) (string, error) {
|
func GetUserIDFromAPIKey(apiKey string) (string, error) {
|
||||||
claims := &APIKeyClaims{}
|
claims := &APIKeyClaims{}
|
||||||
token, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
return config.GetJWTSignKey(), nil
|
return config.GetInstance().GetJWTSignKey(), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ func setInitialMD5Config(txnManager models.TransactionManager) {
|
|||||||
defaultAlgorithm = models.HashAlgorithmMd5
|
defaultAlgorithm = models.HashAlgorithmMd5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO - this should use the config instance
|
||||||
viper.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm)
|
viper.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm)
|
||||||
viper.SetDefault(config.CalculateMD5, usingMD5)
|
viper.SetDefault(config.CalculateMD5, usingMD5)
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
if err := config.Write(); err != nil {
|
if err := config.Write(); err != nil {
|
||||||
logger.Errorf("Error while writing configuration file: %s", err.Error())
|
logger.Errorf("Error while writing configuration file: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
@@ -126,33 +128,64 @@ const LogAccess = "logAccess"
|
|||||||
// File upload options
|
// File upload options
|
||||||
const MaxUploadSize = "max_upload_size"
|
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)
|
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 blank, don't bother hashing; we want it to be blank
|
||||||
if value == "" {
|
if value == "" {
|
||||||
Set(Password, "")
|
i.Set(Password, "")
|
||||||
} else {
|
} else {
|
||||||
Set(Password, hashPassword(value))
|
i.Set(Password, hashPassword(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Write() error {
|
func (i *Instance) Write() error {
|
||||||
return viper.WriteConfig()
|
return viper.WriteConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
// GetConfigFile returns the full path to the used configuration file.
|
||||||
configFileUsed := viper.ConfigFileUsed()
|
func (i *Instance) GetConfigFile() string {
|
||||||
return filepath.Dir(configFileUsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetConfigFilePath() string {
|
|
||||||
return viper.ConfigFileUsed()
|
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
|
var ret []*models.StashConfig
|
||||||
if err := viper.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 {
|
if err := viper.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 {
|
||||||
// fallback to legacy format
|
// fallback to legacy format
|
||||||
@@ -169,47 +202,51 @@ func GetStashPaths() []*models.StashConfig {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCachePath() string {
|
func (i *Instance) GetConfigFilePath() string {
|
||||||
|
return viper.ConfigFileUsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Instance) GetCachePath() string {
|
||||||
return viper.GetString(Cache)
|
return viper.GetString(Cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetGeneratedPath() string {
|
func (i *Instance) GetGeneratedPath() string {
|
||||||
return viper.GetString(Generated)
|
return viper.GetString(Generated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMetadataPath() string {
|
func (i *Instance) GetMetadataPath() string {
|
||||||
return viper.GetString(Metadata)
|
return viper.GetString(Metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDatabasePath() string {
|
func (i *Instance) GetDatabasePath() string {
|
||||||
return viper.GetString(Database)
|
return viper.GetString(Database)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetJWTSignKey() []byte {
|
func (i *Instance) GetJWTSignKey() []byte {
|
||||||
return []byte(viper.GetString(JWTSignKey))
|
return []byte(viper.GetString(JWTSignKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSessionStoreKey() []byte {
|
func (i *Instance) GetSessionStoreKey() []byte {
|
||||||
return []byte(viper.GetString(SessionStoreKey))
|
return []byte(viper.GetString(SessionStoreKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDefaultScrapersPath() string {
|
func (i *Instance) GetDefaultScrapersPath() string {
|
||||||
// default to the same directory as the config file
|
// default to the same directory as the config file
|
||||||
|
|
||||||
fn := filepath.Join(GetConfigPath(), "scrapers")
|
fn := filepath.Join(i.GetConfigPath(), "scrapers")
|
||||||
|
|
||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExcludes() []string {
|
func (i *Instance) GetExcludes() []string {
|
||||||
return viper.GetStringSlice(Exclude)
|
return viper.GetStringSlice(Exclude)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageExcludes() []string {
|
func (i *Instance) GetImageExcludes() []string {
|
||||||
return viper.GetStringSlice(ImageExclude)
|
return viper.GetStringSlice(ImageExclude)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetVideoExtensions() []string {
|
func (i *Instance) GetVideoExtensions() []string {
|
||||||
ret := viper.GetStringSlice(VideoExtensions)
|
ret := viper.GetStringSlice(VideoExtensions)
|
||||||
if ret == nil {
|
if ret == nil {
|
||||||
ret = defaultVideoExtensions
|
ret = defaultVideoExtensions
|
||||||
@@ -217,7 +254,7 @@ func GetVideoExtensions() []string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageExtensions() []string {
|
func (i *Instance) GetImageExtensions() []string {
|
||||||
ret := viper.GetStringSlice(ImageExtensions)
|
ret := viper.GetStringSlice(ImageExtensions)
|
||||||
if ret == nil {
|
if ret == nil {
|
||||||
ret = defaultImageExtensions
|
ret = defaultImageExtensions
|
||||||
@@ -225,7 +262,7 @@ func GetImageExtensions() []string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetGalleryExtensions() []string {
|
func (i *Instance) GetGalleryExtensions() []string {
|
||||||
ret := viper.GetStringSlice(GalleryExtensions)
|
ret := viper.GetStringSlice(GalleryExtensions)
|
||||||
if ret == nil {
|
if ret == nil {
|
||||||
ret = defaultGalleryExtensions
|
ret = defaultGalleryExtensions
|
||||||
@@ -233,11 +270,11 @@ func GetGalleryExtensions() []string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCreateGalleriesFromFolders() bool {
|
func (i *Instance) GetCreateGalleriesFromFolders() bool {
|
||||||
return viper.GetBool(CreateGalleriesFromFolders)
|
return viper.GetBool(CreateGalleriesFromFolders)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetLanguage() string {
|
func (i *Instance) GetLanguage() string {
|
||||||
ret := viper.GetString(Language)
|
ret := viper.GetString(Language)
|
||||||
|
|
||||||
// default to English
|
// default to English
|
||||||
@@ -250,13 +287,13 @@ func GetLanguage() string {
|
|||||||
|
|
||||||
// IsCalculateMD5 returns true if MD5 checksums should be generated for
|
// IsCalculateMD5 returns true if MD5 checksums should be generated for
|
||||||
// scene video files.
|
// scene video files.
|
||||||
func IsCalculateMD5() bool {
|
func (i *Instance) IsCalculateMD5() bool {
|
||||||
return viper.GetBool(CalculateMD5)
|
return viper.GetBool(CalculateMD5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVideoFileNamingAlgorithm returns what hash algorithm should be used for
|
// GetVideoFileNamingAlgorithm returns what hash algorithm should be used for
|
||||||
// naming generated scene video files.
|
// naming generated scene video files.
|
||||||
func GetVideoFileNamingAlgorithm() models.HashAlgorithm {
|
func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm {
|
||||||
ret := viper.GetString(VideoFileNamingAlgorithm)
|
ret := viper.GetString(VideoFileNamingAlgorithm)
|
||||||
|
|
||||||
// default to oshash
|
// default to oshash
|
||||||
@@ -267,23 +304,23 @@ func GetVideoFileNamingAlgorithm() models.HashAlgorithm {
|
|||||||
return models.HashAlgorithm(ret)
|
return models.HashAlgorithm(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetScrapersPath() string {
|
func (i *Instance) GetScrapersPath() string {
|
||||||
return viper.GetString(ScrapersPath)
|
return viper.GetString(ScrapersPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetScraperUserAgent() string {
|
func (i *Instance) GetScraperUserAgent() string {
|
||||||
return viper.GetString(ScraperUserAgent)
|
return viper.GetString(ScraperUserAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScraperCDPPath gets the path to the Chrome executable or remote address
|
// GetScraperCDPPath gets the path to the Chrome executable or remote address
|
||||||
// to an instance of Chrome.
|
// to an instance of Chrome.
|
||||||
func GetScraperCDPPath() string {
|
func (i *Instance) GetScraperCDPPath() string {
|
||||||
return viper.GetString(ScraperCDPPath)
|
return viper.GetString(ScraperCDPPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScraperCertCheck returns true if the scraper should check for insecure
|
// GetScraperCertCheck returns true if the scraper should check for insecure
|
||||||
// certificates when fetching an image or a page.
|
// certificates when fetching an image or a page.
|
||||||
func GetScraperCertCheck() bool {
|
func (i *Instance) GetScraperCertCheck() bool {
|
||||||
ret := true
|
ret := true
|
||||||
if viper.IsSet(ScraperCertCheck) {
|
if viper.IsSet(ScraperCertCheck) {
|
||||||
ret = viper.GetBool(ScraperCertCheck)
|
ret = viper.GetBool(ScraperCertCheck)
|
||||||
@@ -292,48 +329,48 @@ func GetScraperCertCheck() bool {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetStashBoxes() []*models.StashBox {
|
func (i *Instance) GetStashBoxes() []*models.StashBox {
|
||||||
var boxes []*models.StashBox
|
var boxes []*models.StashBox
|
||||||
viper.UnmarshalKey(StashBoxes, &boxes)
|
viper.UnmarshalKey(StashBoxes, &boxes)
|
||||||
return boxes
|
return boxes
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDefaultPluginsPath() string {
|
func (i *Instance) GetDefaultPluginsPath() string {
|
||||||
// default to the same directory as the config file
|
// default to the same directory as the config file
|
||||||
fn := filepath.Join(GetConfigPath(), "plugins")
|
fn := filepath.Join(i.GetConfigPath(), "plugins")
|
||||||
|
|
||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPluginsPath() string {
|
func (i *Instance) GetPluginsPath() string {
|
||||||
return viper.GetString(PluginsPath)
|
return viper.GetString(PluginsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHost() string {
|
func (i *Instance) GetHost() string {
|
||||||
return viper.GetString(Host)
|
return viper.GetString(Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPort() int {
|
func (i *Instance) GetPort() int {
|
||||||
return viper.GetInt(Port)
|
return viper.GetInt(Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExternalHost() string {
|
func (i *Instance) GetExternalHost() string {
|
||||||
return viper.GetString(ExternalHost)
|
return viper.GetString(ExternalHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPreviewSegmentDuration returns the duration of a single segment in a
|
// GetPreviewSegmentDuration returns the duration of a single segment in a
|
||||||
// scene preview file, in seconds.
|
// scene preview file, in seconds.
|
||||||
func GetPreviewSegmentDuration() float64 {
|
func (i *Instance) GetPreviewSegmentDuration() float64 {
|
||||||
return viper.GetFloat64(PreviewSegmentDuration)
|
return viper.GetFloat64(PreviewSegmentDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetParallelTasks returns the number of parallel tasks that should be started
|
// GetParallelTasks returns the number of parallel tasks that should be started
|
||||||
// by scan or generate task.
|
// by scan or generate task.
|
||||||
func GetParallelTasks() int {
|
func (i *Instance) GetParallelTasks() int {
|
||||||
return viper.GetInt(ParallelTasks)
|
return viper.GetInt(ParallelTasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetParallelTasksWithAutoDetection() int {
|
func (i *Instance) GetParallelTasksWithAutoDetection() int {
|
||||||
parallelTasks := viper.GetInt(ParallelTasks)
|
parallelTasks := viper.GetInt(ParallelTasks)
|
||||||
if parallelTasks <= 0 {
|
if parallelTasks <= 0 {
|
||||||
parallelTasks = (runtime.NumCPU() / 4) + 1
|
parallelTasks = (runtime.NumCPU() / 4) + 1
|
||||||
@@ -342,7 +379,7 @@ func GetParallelTasksWithAutoDetection() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPreviewSegments returns the amount of segments in a scene preview file.
|
// GetPreviewSegments returns the amount of segments in a scene preview file.
|
||||||
func GetPreviewSegments() int {
|
func (i *Instance) GetPreviewSegments() int {
|
||||||
return viper.GetInt(PreviewSegments)
|
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
|
// 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
|
// 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.
|
// '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)
|
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
|
// 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,
|
// when generating previews. If the value is suffixed with a '%' character,
|
||||||
// then it is interpreted as a proportion of the total video duration.
|
// then it is interpreted as a proportion of the total video duration.
|
||||||
func GetPreviewExcludeEnd() string {
|
func (i *Instance) GetPreviewExcludeEnd() string {
|
||||||
return viper.GetString(PreviewExcludeEnd)
|
return viper.GetString(PreviewExcludeEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPreviewPreset returns the preset when generating previews. Defaults to
|
// GetPreviewPreset returns the preset when generating previews. Defaults to
|
||||||
// Slow.
|
// Slow.
|
||||||
func GetPreviewPreset() models.PreviewPreset {
|
func (i *Instance) GetPreviewPreset() models.PreviewPreset {
|
||||||
ret := viper.GetString(PreviewPreset)
|
ret := viper.GetString(PreviewPreset)
|
||||||
|
|
||||||
// default to slow
|
// default to slow
|
||||||
@@ -378,7 +415,7 @@ func GetPreviewPreset() models.PreviewPreset {
|
|||||||
return models.PreviewPreset(ret)
|
return models.PreviewPreset(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMaxTranscodeSize() models.StreamingResolutionEnum {
|
func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum {
|
||||||
ret := viper.GetString(MaxTranscodeSize)
|
ret := viper.GetString(MaxTranscodeSize)
|
||||||
|
|
||||||
// default to original
|
// default to original
|
||||||
@@ -389,7 +426,7 @@ func GetMaxTranscodeSize() models.StreamingResolutionEnum {
|
|||||||
return models.StreamingResolutionEnum(ret)
|
return models.StreamingResolutionEnum(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum {
|
func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum {
|
||||||
ret := viper.GetString(MaxStreamingTranscodeSize)
|
ret := viper.GetString(MaxStreamingTranscodeSize)
|
||||||
|
|
||||||
// default to original
|
// default to original
|
||||||
@@ -400,33 +437,33 @@ func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum {
|
|||||||
return models.StreamingResolutionEnum(ret)
|
return models.StreamingResolutionEnum(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAPIKey() string {
|
func (i *Instance) GetAPIKey() string {
|
||||||
return viper.GetString(ApiKey)
|
return viper.GetString(ApiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUsername() string {
|
func (i *Instance) GetUsername() string {
|
||||||
return viper.GetString(Username)
|
return viper.GetString(Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPasswordHash() string {
|
func (i *Instance) GetPasswordHash() string {
|
||||||
return viper.GetString(Password)
|
return viper.GetString(Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredentials() (string, string) {
|
func (i *Instance) GetCredentials() (string, string) {
|
||||||
if HasCredentials() {
|
if i.HasCredentials() {
|
||||||
return viper.GetString(Username), viper.GetString(Password)
|
return viper.GetString(Username), viper.GetString(Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func HasCredentials() bool {
|
func (i *Instance) HasCredentials() bool {
|
||||||
if !viper.IsSet(Username) || !viper.IsSet(Password) {
|
if !viper.IsSet(Username) || !viper.IsSet(Password) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
username := GetUsername()
|
username := i.GetUsername()
|
||||||
pwHash := GetPasswordHash()
|
pwHash := i.GetPasswordHash()
|
||||||
|
|
||||||
return username != "" && pwHash != ""
|
return username != "" && pwHash != ""
|
||||||
}
|
}
|
||||||
@@ -437,20 +474,20 @@ func hashPassword(password string) string {
|
|||||||
return string(hash)
|
return string(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateCredentials(username string, password string) bool {
|
func (i *Instance) ValidateCredentials(username string, password string) bool {
|
||||||
if !HasCredentials() {
|
if !i.HasCredentials() {
|
||||||
// don't need to authenticate if no credentials saved
|
// don't need to authenticate if no credentials saved
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
authUser, authPWHash := GetCredentials()
|
authUser, authPWHash := i.GetCredentials()
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password))
|
err := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password))
|
||||||
|
|
||||||
return username == authUser && err == nil
|
return username == authUser && err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateStashBoxes(boxes []*models.StashBoxInput) error {
|
func (i *Instance) ValidateStashBoxes(boxes []*models.StashBoxInput) error {
|
||||||
isMulti := len(boxes) > 1
|
isMulti := len(boxes) > 1
|
||||||
|
|
||||||
re, err := regexp.Compile("^http.*graphql$")
|
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.
|
// GetMaxSessionAge gets the maximum age for session cookies, in seconds.
|
||||||
// Session cookie expiry times are refreshed every request.
|
// Session cookie expiry times are refreshed every request.
|
||||||
func GetMaxSessionAge() int {
|
func (i *Instance) GetMaxSessionAge() int {
|
||||||
viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge)
|
viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge)
|
||||||
return viper.GetInt(MaxSessionAge)
|
return viper.GetInt(MaxSessionAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCustomServedFolders gets the map of custom paths to their applicable
|
// GetCustomServedFolders gets the map of custom paths to their applicable
|
||||||
// filesystem locations
|
// filesystem locations
|
||||||
func GetCustomServedFolders() URLMap {
|
func (i *Instance) GetCustomServedFolders() URLMap {
|
||||||
return viper.GetStringMapString(CustomServedFolders)
|
return viper.GetStringMapString(CustomServedFolders)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface options
|
// Interface options
|
||||||
func GetMenuItems() []string {
|
func (i *Instance) GetMenuItems() []string {
|
||||||
if viper.IsSet(MenuItems) {
|
if viper.IsSet(MenuItems) {
|
||||||
return viper.GetStringSlice(MenuItems)
|
return viper.GetStringSlice(MenuItems)
|
||||||
}
|
}
|
||||||
return defaultMenuItems
|
return defaultMenuItems
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSoundOnPreview() bool {
|
func (i *Instance) GetSoundOnPreview() bool {
|
||||||
viper.SetDefault(SoundOnPreview, false)
|
viper.SetDefault(SoundOnPreview, false)
|
||||||
return viper.GetBool(SoundOnPreview)
|
return viper.GetBool(SoundOnPreview)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetWallShowTitle() bool {
|
func (i *Instance) GetWallShowTitle() bool {
|
||||||
viper.SetDefault(WallShowTitle, true)
|
viper.SetDefault(WallShowTitle, true)
|
||||||
return viper.GetBool(WallShowTitle)
|
return viper.GetBool(WallShowTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetWallPlayback() string {
|
func (i *Instance) GetWallPlayback() string {
|
||||||
viper.SetDefault(WallPlayback, "video")
|
viper.SetDefault(WallPlayback, "video")
|
||||||
return viper.GetString(WallPlayback)
|
return viper.GetString(WallPlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMaximumLoopDuration() int {
|
func (i *Instance) GetMaximumLoopDuration() int {
|
||||||
viper.SetDefault(MaximumLoopDuration, 0)
|
viper.SetDefault(MaximumLoopDuration, 0)
|
||||||
return viper.GetInt(MaximumLoopDuration)
|
return viper.GetInt(MaximumLoopDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAutostartVideo() bool {
|
func (i *Instance) GetAutostartVideo() bool {
|
||||||
viper.SetDefault(AutostartVideo, false)
|
viper.SetDefault(AutostartVideo, false)
|
||||||
return viper.GetBool(AutostartVideo)
|
return viper.GetBool(AutostartVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetShowStudioAsText() bool {
|
func (i *Instance) GetShowStudioAsText() bool {
|
||||||
viper.SetDefault(ShowStudioAsText, false)
|
viper.SetDefault(ShowStudioAsText, false)
|
||||||
return viper.GetBool(ShowStudioAsText)
|
return viper.GetBool(ShowStudioAsText)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCSSPath() string {
|
func (i *Instance) GetCSSPath() string {
|
||||||
// use custom.css in the same directory as the config file
|
// use custom.css in the same directory as the config file
|
||||||
configFileUsed := viper.ConfigFileUsed()
|
configFileUsed := viper.ConfigFileUsed()
|
||||||
configDir := filepath.Dir(configFileUsed)
|
configDir := filepath.Dir(configFileUsed)
|
||||||
@@ -533,8 +570,8 @@ func GetCSSPath() string {
|
|||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCSS() string {
|
func (i *Instance) GetCSS() string {
|
||||||
fn := GetCSSPath()
|
fn := i.GetCSSPath()
|
||||||
|
|
||||||
exists, _ := utils.FileExists(fn)
|
exists, _ := utils.FileExists(fn)
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -550,28 +587,28 @@ func GetCSS() string {
|
|||||||
return string(buf)
|
return string(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetCSS(css string) {
|
func (i *Instance) SetCSS(css string) {
|
||||||
fn := GetCSSPath()
|
fn := i.GetCSSPath()
|
||||||
|
|
||||||
buf := []byte(css)
|
buf := []byte(css)
|
||||||
|
|
||||||
ioutil.WriteFile(fn, buf, 0777)
|
ioutil.WriteFile(fn, buf, 0777)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCSSEnabled() bool {
|
func (i *Instance) GetCSSEnabled() bool {
|
||||||
return viper.GetBool(CSSEnabled)
|
return viper.GetBool(CSSEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogFile returns the filename of the file to output logs to.
|
// GetLogFile returns the filename of the file to output logs to.
|
||||||
// An empty string means that file logging will be disabled.
|
// An empty string means that file logging will be disabled.
|
||||||
func GetLogFile() string {
|
func (i *Instance) GetLogFile() string {
|
||||||
return viper.GetString(LogFile)
|
return viper.GetString(LogFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogOut returns true if logging should be output to the terminal
|
// 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
|
// in addition to writing to a log file. Logging will be output to the
|
||||||
// terminal if file logging is disabled. Defaults to true.
|
// terminal if file logging is disabled. Defaults to true.
|
||||||
func GetLogOut() bool {
|
func (i *Instance) GetLogOut() bool {
|
||||||
ret := true
|
ret := true
|
||||||
if viper.IsSet(LogOut) {
|
if viper.IsSet(LogOut) {
|
||||||
ret = viper.GetBool(LogOut)
|
ret = viper.GetBool(LogOut)
|
||||||
@@ -582,7 +619,7 @@ func GetLogOut() bool {
|
|||||||
|
|
||||||
// GetLogLevel returns the lowest log level to write to the log.
|
// GetLogLevel returns the lowest log level to write to the log.
|
||||||
// Should be one of "Debug", "Info", "Warning", "Error"
|
// Should be one of "Debug", "Info", "Warning", "Error"
|
||||||
func GetLogLevel() string {
|
func (i *Instance) GetLogLevel() string {
|
||||||
const defaultValue = "Info"
|
const defaultValue = "Info"
|
||||||
|
|
||||||
value := viper.GetString(LogLevel)
|
value := viper.GetString(LogLevel)
|
||||||
@@ -595,7 +632,7 @@ func GetLogLevel() string {
|
|||||||
|
|
||||||
// GetLogAccess returns true if http requests should be logged to the terminal.
|
// GetLogAccess returns true if http requests should be logged to the terminal.
|
||||||
// HTTP requests are not logged to the log file. Defaults to true.
|
// HTTP requests are not logged to the log file. Defaults to true.
|
||||||
func GetLogAccess() bool {
|
func (i *Instance) GetLogAccess() bool {
|
||||||
ret := true
|
ret := true
|
||||||
if viper.IsSet(LogAccess) {
|
if viper.IsSet(LogAccess) {
|
||||||
ret = viper.GetBool(LogAccess)
|
ret = viper.GetBool(LogAccess)
|
||||||
@@ -605,7 +642,7 @@ func GetLogAccess() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Max allowed graphql upload size in megabytes
|
// Max allowed graphql upload size in megabytes
|
||||||
func GetMaxUploadSize() int64 {
|
func (i *Instance) GetMaxUploadSize() int64 {
|
||||||
ret := int64(1024)
|
ret := int64(1024)
|
||||||
if viper.IsSet(MaxUploadSize) {
|
if viper.IsSet(MaxUploadSize) {
|
||||||
ret = viper.GetInt64(MaxUploadSize)
|
ret = viper.GetInt64(MaxUploadSize)
|
||||||
@@ -613,11 +650,27 @@ func GetMaxUploadSize() int64 {
|
|||||||
return ret << 20
|
return ret << 20
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsValid() bool {
|
func (i *Instance) Validate() error {
|
||||||
setPaths := viper.IsSet(Stash) && viper.IsSet(Cache) && viper.IsSet(Generated) && viper.IsSet(Metadata)
|
mandatoryPaths := []string{
|
||||||
|
Database,
|
||||||
|
Generated,
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: check valid paths
|
var missingFields []string
|
||||||
return setPaths
|
|
||||||
|
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() {
|
func setDefaultValues() {
|
||||||
@@ -629,21 +682,19 @@ func setDefaultValues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetInitialConfig fills in missing required config fields
|
// SetInitialConfig fills in missing required config fields
|
||||||
func SetInitialConfig() error {
|
func (i *Instance) SetInitialConfig() {
|
||||||
// generate some api keys
|
// generate some api keys
|
||||||
const apiKeyLength = 32
|
const apiKeyLength = 32
|
||||||
|
|
||||||
if string(GetJWTSignKey()) == "" {
|
if string(i.GetJWTSignKey()) == "" {
|
||||||
signKey := utils.GenerateRandomKey(apiKeyLength)
|
signKey := utils.GenerateRandomKey(apiKeyLength)
|
||||||
Set(JWTSignKey, signKey)
|
i.Set(JWTSignKey, signKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(GetSessionStoreKey()) == "" {
|
if string(i.GetSessionStoreKey()) == "" {
|
||||||
sessionStoreKey := utils.GenerateRandomKey(apiKeyLength)
|
sessionStoreKey := utils.GenerateRandomKey(apiKeyLength)
|
||||||
Set(SessionStoreKey, sessionStoreKey)
|
i.Set(SessionStoreKey, sessionStoreKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
setDefaultValues()
|
setDefaultValues()
|
||||||
|
|
||||||
return Write()
|
|
||||||
}
|
}
|
||||||
|
|||||||
104
pkg/manager/config/init.go
Normal file
104
pkg/manager/config/init.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
type flagStruct struct {
|
||||||
|
configFilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Initialize() (*Instance, error) {
|
||||||
|
var err error
|
||||||
|
once.Do(func() {
|
||||||
|
instance = &Instance{}
|
||||||
|
|
||||||
|
flags := initFlags()
|
||||||
|
err = initConfig(flags)
|
||||||
|
initEnvs()
|
||||||
|
})
|
||||||
|
return instance, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func initConfig(flags flagStruct) error {
|
||||||
|
// The config file is called config. Leave off the file extension.
|
||||||
|
viper.SetConfigName("config")
|
||||||
|
|
||||||
|
if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists {
|
||||||
|
viper.SetConfigFile(flags.configFilePath)
|
||||||
|
}
|
||||||
|
viper.AddConfigPath(".") // Look for config in the working directory
|
||||||
|
viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory
|
||||||
|
|
||||||
|
// for Docker compatibility, if STASH_CONFIG_FILE is set, then touch the
|
||||||
|
// given filename
|
||||||
|
envConfigFile := os.Getenv("STASH_CONFIG_FILE")
|
||||||
|
if envConfigFile != "" {
|
||||||
|
utils.Touch(envConfigFile)
|
||||||
|
viper.SetConfigFile(envConfigFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := viper.ReadInConfig() // Find and read the config file
|
||||||
|
// continue, but set an error to be handled by caller
|
||||||
|
|
||||||
|
postInitConfig()
|
||||||
|
instance.SetInitialConfig()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func postInitConfig() {
|
||||||
|
c := instance
|
||||||
|
if c.GetConfigFile() != "" {
|
||||||
|
viper.SetDefault(Database, c.GetDefaultDatabaseFilePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set generated to the metadata path for backwards compat
|
||||||
|
viper.SetDefault(Generated, viper.GetString(Metadata))
|
||||||
|
|
||||||
|
// Set default scrapers and plugins paths
|
||||||
|
viper.SetDefault(ScrapersPath, c.GetDefaultScrapersPath())
|
||||||
|
viper.SetDefault(PluginsPath, c.GetDefaultPluginsPath())
|
||||||
|
|
||||||
|
viper.WriteConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initFlags() flagStruct {
|
||||||
|
flags := flagStruct{}
|
||||||
|
|
||||||
|
pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host")
|
||||||
|
pflag.Int("port", 9999, "port to serve from")
|
||||||
|
pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use")
|
||||||
|
|
||||||
|
pflag.Parse()
|
||||||
|
if err := viper.BindPFlags(pflag.CommandLine); err != nil {
|
||||||
|
logger.Infof("failed to bind flags: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func initEnvs() {
|
||||||
|
viper.SetEnvPrefix("stash") // will be uppercased automatically
|
||||||
|
viper.BindEnv("host") // STASH_HOST
|
||||||
|
viper.BindEnv("port") // STASH_PORT
|
||||||
|
viper.BindEnv("external_host") // STASH_EXTERNAL_HOST
|
||||||
|
viper.BindEnv("generated") // STASH_GENERATED
|
||||||
|
viper.BindEnv("metadata") // STASH_METADATA
|
||||||
|
viper.BindEnv("cache") // STASH_CACHE
|
||||||
|
|
||||||
|
// only set stash config flag if not already set
|
||||||
|
if instance.GetStashPaths() == nil {
|
||||||
|
viper.BindEnv("stash") // STASH_STASH
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package manager
|
package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/stashapp/stash/pkg/database"
|
||||||
"github.com/spf13/viper"
|
|
||||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
@@ -18,6 +20,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type singleton struct {
|
type singleton struct {
|
||||||
|
Config *config.Instance
|
||||||
|
|
||||||
Status TaskStatus
|
Status TaskStatus
|
||||||
Paths *paths.Paths
|
Paths *paths.Paths
|
||||||
|
|
||||||
@@ -48,29 +52,35 @@ func GetInstance() *singleton {
|
|||||||
|
|
||||||
func Initialize() *singleton {
|
func Initialize() *singleton {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
_ = utils.EnsureDir(paths.GetConfigDirectory())
|
_ = utils.EnsureDir(paths.GetStashHomeDirectory())
|
||||||
initFlags()
|
cfg, err := config.Initialize()
|
||||||
initConfig()
|
|
||||||
initLog()
|
initLog()
|
||||||
initEnvs()
|
|
||||||
instance = &singleton{
|
instance = &singleton{
|
||||||
Status: TaskStatus{Status: Idle, Progress: -1},
|
Config: cfg,
|
||||||
Paths: paths.NewPaths(),
|
Status: TaskStatus{Status: Idle, Progress: -1},
|
||||||
|
|
||||||
PluginCache: initPluginCache(),
|
|
||||||
|
|
||||||
DownloadStore: NewDownloadStore(),
|
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
|
if err == nil {
|
||||||
// #1021 - only clear these directories if the generated folder is non-empty
|
err = cfg.Validate()
|
||||||
if config.GetGeneratedPath() != "" {
|
}
|
||||||
utils.EmptyDir(instance.Paths.Generated.Downloads)
|
|
||||||
utils.EmptyDir(instance.Paths.Generated.Tmp)
|
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()
|
initFFMPEG()
|
||||||
@@ -79,78 +89,8 @@ func Initialize() *singleton {
|
|||||||
return instance
|
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() {
|
func initFFMPEG() {
|
||||||
configDirectory := paths.GetConfigDirectory()
|
configDirectory := paths.GetStashHomeDirectory()
|
||||||
ffmpegPath, ffprobePath := ffmpeg.GetPaths(configDirectory)
|
ffmpegPath, ffprobePath := ffmpeg.GetPaths(configDirectory)
|
||||||
if ffmpegPath == "" || ffprobePath == "" {
|
if ffmpegPath == "" || ffprobePath == "" {
|
||||||
logger.Infof("couldn't find FFMPEG, attempting to download it")
|
logger.Infof("couldn't find FFMPEG, attempting to download it")
|
||||||
@@ -174,10 +114,12 @@ The error was: %s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initLog() {
|
func initLog() {
|
||||||
|
config := config.GetInstance()
|
||||||
logger.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel())
|
logger.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel())
|
||||||
}
|
}
|
||||||
|
|
||||||
func initPluginCache() *plugin.Cache {
|
func initPluginCache() *plugin.Cache {
|
||||||
|
config := config.GetInstance()
|
||||||
ret, err := plugin.NewCache(config.GetPluginsPath())
|
ret, err := plugin.NewCache(config.GetPluginsPath())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,14 +129,37 @@ func initPluginCache() *plugin.Cache {
|
|||||||
return ret
|
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.
|
// initScraperCache initializes a new scraper cache and returns it.
|
||||||
func (s *singleton) initScraperCache() *scraper.Cache {
|
func (s *singleton) initScraperCache() *scraper.Cache {
|
||||||
scraperConfig := scraper.GlobalConfig{
|
ret, err := scraper.NewCache(config.GetInstance(), s.TxnManager)
|
||||||
Path: config.GetScrapersPath(),
|
|
||||||
UserAgent: config.GetScraperUserAgent(),
|
|
||||||
CDPPath: config.GetScraperCDPPath(),
|
|
||||||
}
|
|
||||||
ret, err := scraper.NewCache(scraperConfig, s.TxnManager)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Error reading scraper configs: %s", err.Error())
|
logger.Errorf("Error reading scraper configs: %s", err.Error())
|
||||||
@@ -204,14 +169,14 @@ func (s *singleton) initScraperCache() *scraper.Cache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *singleton) RefreshConfig() {
|
func (s *singleton) RefreshConfig() {
|
||||||
s.Paths = paths.NewPaths()
|
s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||||
if config.IsValid() {
|
config := s.Config
|
||||||
|
if config.Validate() == nil {
|
||||||
utils.EnsureDir(s.Paths.Generated.Screenshots)
|
utils.EnsureDir(s.Paths.Generated.Screenshots)
|
||||||
utils.EnsureDir(s.Paths.Generated.Vtt)
|
utils.EnsureDir(s.Paths.Generated.Vtt)
|
||||||
utils.EnsureDir(s.Paths.Generated.Markers)
|
utils.EnsureDir(s.Paths.Generated.Markers)
|
||||||
utils.EnsureDir(s.Paths.Generated.Transcodes)
|
utils.EnsureDir(s.Paths.Generated.Transcodes)
|
||||||
utils.EnsureDir(s.Paths.Generated.Downloads)
|
utils.EnsureDir(s.Paths.Generated.Downloads)
|
||||||
paths.EnsureJSONDirs(config.GetMetadataPath())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,3 +185,110 @@ func (s *singleton) RefreshConfig() {
|
|||||||
func (s *singleton) RefreshScraperCache() {
|
func (s *singleton) RefreshScraperCache() {
|
||||||
s.ScraperCache = s.initScraperCache()
|
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 {
|
func isGallery(pathname string) bool {
|
||||||
gExt := config.GetGalleryExtensions()
|
gExt := config.GetInstance().GetGalleryExtensions()
|
||||||
return matchExtension(pathname, gExt)
|
return matchExtension(pathname, gExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isVideo(pathname string) bool {
|
func isVideo(pathname string) bool {
|
||||||
vidExt := config.GetVideoExtensions()
|
vidExt := config.GetInstance().GetVideoExtensions()
|
||||||
return matchExtension(pathname, vidExt)
|
return matchExtension(pathname, vidExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isImage(pathname string) bool {
|
func isImage(pathname string) bool {
|
||||||
imgExt := config.GetImageExtensions()
|
imgExt := config.GetInstance().GetImageExtensions()
|
||||||
return matchExtension(pathname, imgExt)
|
return matchExtension(pathname, imgExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func (t *TaskStatus) updated() {
|
|||||||
|
|
||||||
func getScanPaths(inputPaths []string) []*models.StashConfig {
|
func getScanPaths(inputPaths []string) []*models.StashConfig {
|
||||||
if len(inputPaths) == 0 {
|
if len(inputPaths) == 0 {
|
||||||
return config.GetStashPaths()
|
return config.GetInstance().GetStashPaths()
|
||||||
}
|
}
|
||||||
|
|
||||||
var ret []*models.StashConfig
|
var ret []*models.StashConfig
|
||||||
@@ -181,6 +181,7 @@ func (s *singleton) Scan(input models.ScanMetadataInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
config := config.GetInstance()
|
||||||
parallelTasks := config.GetParallelTasksWithAutoDetection()
|
parallelTasks := config.GetParallelTasksWithAutoDetection()
|
||||||
logger.Infof("Scan started with %d parallel tasks", parallelTasks)
|
logger.Infof("Scan started with %d parallel tasks", parallelTasks)
|
||||||
wg := sizedwaitgroup.New(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 {
|
if s.Status.Status != Idle {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
s.Status.SetStatus(Import)
|
s.Status.SetStatus(Import)
|
||||||
s.Status.indefiniteProgress()
|
s.Status.indefiniteProgress()
|
||||||
@@ -276,9 +283,10 @@ func (s *singleton) Import() {
|
|||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|
||||||
task := ImportTask{
|
task := ImportTask{
|
||||||
txnManager: s.TxnManager,
|
txnManager: s.TxnManager,
|
||||||
BaseDir: config.GetMetadataPath(),
|
BaseDir: metadataPath,
|
||||||
Reset: true,
|
Reset: true,
|
||||||
DuplicateBehaviour: models.ImportDuplicateEnumFail,
|
DuplicateBehaviour: models.ImportDuplicateEnumFail,
|
||||||
MissingRefBehaviour: models.ImportMissingRefEnumFail,
|
MissingRefBehaviour: models.ImportMissingRefEnumFail,
|
||||||
@@ -287,11 +295,19 @@ func (s *singleton) Import() {
|
|||||||
go task.Start(&wg)
|
go task.Start(&wg)
|
||||||
wg.Wait()
|
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 {
|
if s.Status.Status != Idle {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
s.Status.SetStatus(Export)
|
s.Status.SetStatus(Export)
|
||||||
s.Status.indefiniteProgress()
|
s.Status.indefiniteProgress()
|
||||||
@@ -309,6 +325,8 @@ func (s *singleton) Export() {
|
|||||||
go task.Start(&wg)
|
go task.Start(&wg)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *singleton) RunSingleTask(t Task) (*sync.WaitGroup, error) {
|
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) {
|
func setGeneratePreviewOptionsInput(optionsInput *models.GeneratePreviewOptionsInput) {
|
||||||
|
config := config.GetInstance()
|
||||||
if optionsInput.PreviewSegments == nil {
|
if optionsInput.PreviewSegments == nil {
|
||||||
val := config.GetPreviewSegments()
|
val := config.GetPreviewSegments()
|
||||||
optionsInput.PreviewSegments = &val
|
optionsInput.PreviewSegments = &val
|
||||||
@@ -409,6 +428,7 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
parallelTasks := config.GetParallelTasksWithAutoDetection()
|
parallelTasks := config.GetParallelTasksWithAutoDetection()
|
||||||
|
|
||||||
logger.Infof("Generate started with %d parallel tasks", parallelTasks)
|
logger.Infof("Generate started with %d parallel tasks", parallelTasks)
|
||||||
@@ -587,7 +607,7 @@ func (s *singleton) generateScreenshot(sceneId string, at *float64) {
|
|||||||
txnManager: s.TxnManager,
|
txnManager: s.TxnManager,
|
||||||
Scene: *scene,
|
Scene: *scene,
|
||||||
ScreenshotAt: at,
|
ScreenshotAt: at,
|
||||||
fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
|
fileNamingAlgorithm: config.GetInstance().GetVideoFileNamingAlgorithm(),
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -862,7 +882,7 @@ func (s *singleton) Clean(input models.CleanMetadataInput) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
s.Status.Progress = 0
|
s.Status.Progress = 0
|
||||||
total := len(scenes) + len(images) + len(galleries)
|
total := len(scenes) + len(images) + len(galleries)
|
||||||
fileNamingAlgo := config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||||
for i, scene := range scenes {
|
for i, scene := range scenes {
|
||||||
s.Status.setProgress(i, total)
|
s.Status.setProgress(i, total)
|
||||||
if s.Status.stopping {
|
if s.Status.stopping {
|
||||||
@@ -944,7 +964,7 @@ func (s *singleton) MigrateHash() {
|
|||||||
go func() {
|
go func() {
|
||||||
defer s.returnToIdleState()
|
defer s.returnToIdleState()
|
||||||
|
|
||||||
fileNamingAlgo := config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||||
logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String())
|
logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String())
|
||||||
|
|
||||||
var scenes []*models.Scene
|
var scenes []*models.Scene
|
||||||
@@ -1020,7 +1040,7 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate
|
|||||||
chTimeout <- struct{}{}
|
chTimeout <- struct{}{}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
fileNamingAlgo := config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||||
overwrite := false
|
overwrite := false
|
||||||
if input.Overwrite != nil {
|
if input.Overwrite != nil {
|
||||||
overwrite = *input.Overwrite
|
overwrite = *input.Overwrite
|
||||||
|
|||||||
@@ -13,31 +13,27 @@ type Paths struct {
|
|||||||
SceneMarkers *sceneMarkerPaths
|
SceneMarkers *sceneMarkerPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPaths() *Paths {
|
func NewPaths(generatedPath string) *Paths {
|
||||||
p := Paths{}
|
p := Paths{}
|
||||||
p.Generated = newGeneratedPaths()
|
p.Generated = newGeneratedPaths(generatedPath)
|
||||||
|
|
||||||
p.Scene = newScenePaths(p)
|
p.Scene = newScenePaths(p)
|
||||||
p.SceneMarkers = newSceneMarkerPaths(p)
|
p.SceneMarkers = newSceneMarkerPaths(p)
|
||||||
return &p
|
return &p
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigDirectory() string {
|
func GetStashHomeDirectory() string {
|
||||||
return filepath.Join(utils.GetHomeDirectory(), ".stash")
|
return filepath.Join(utils.GetHomeDirectory(), ".stash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDefaultDatabaseFilePath() string {
|
func GetDefaultDatabaseFilePath() string {
|
||||||
return filepath.Join(GetConfigDirectory(), "stash-go.sqlite")
|
return filepath.Join(GetStashHomeDirectory(), "stash-go.sqlite")
|
||||||
}
|
|
||||||
|
|
||||||
func GetDefaultConfigFilePath() string {
|
|
||||||
return filepath.Join(GetConfigDirectory(), "config.yml")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSSLKey() string {
|
func GetSSLKey() string {
|
||||||
return filepath.Join(GetConfigDirectory(), "stash.key")
|
return filepath.Join(GetStashHomeDirectory(), "stash.key")
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSSLCert() string {
|
func GetSSLCert() string {
|
||||||
return filepath.Join(GetConfigDirectory(), "stash.crt")
|
return filepath.Join(GetStashHomeDirectory(), "stash.crt")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,15 +21,15 @@ type generatedPaths struct {
|
|||||||
Tmp string
|
Tmp string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGeneratedPaths() *generatedPaths {
|
func newGeneratedPaths(path string) *generatedPaths {
|
||||||
gp := generatedPaths{}
|
gp := generatedPaths{}
|
||||||
gp.Screenshots = filepath.Join(config.GetGeneratedPath(), "screenshots")
|
gp.Screenshots = filepath.Join(path, "screenshots")
|
||||||
gp.Thumbnails = filepath.Join(config.GetGeneratedPath(), "thumbnails")
|
gp.Thumbnails = filepath.Join(path, "thumbnails")
|
||||||
gp.Vtt = filepath.Join(config.GetGeneratedPath(), "vtt")
|
gp.Vtt = filepath.Join(path, "vtt")
|
||||||
gp.Markers = filepath.Join(config.GetGeneratedPath(), "markers")
|
gp.Markers = filepath.Join(path, "markers")
|
||||||
gp.Transcodes = filepath.Join(config.GetGeneratedPath(), "transcodes")
|
gp.Transcodes = filepath.Join(path, "transcodes")
|
||||||
gp.Downloads = filepath.Join(config.GetGeneratedPath(), "download_stage")
|
gp.Downloads = filepath.Join(path, "download_stage")
|
||||||
gp.Tmp = filepath.Join(config.GetGeneratedPath(), "tmp")
|
gp.Tmp = filepath.Join(path, "tmp")
|
||||||
return &gp
|
return &gp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func DestroySceneMarker(scene *models.Scene, sceneMarker *models.SceneMarker, qb
|
|||||||
// delete the preview for the marker
|
// delete the preview for the marker
|
||||||
return func() {
|
return func() {
|
||||||
seconds := int(sceneMarker.Seconds)
|
seconds := int(sceneMarker.Seconds)
|
||||||
DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm())
|
DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami
|
|||||||
// don't care if we can't get the container
|
// don't care if we can't get the container
|
||||||
container, _ := GetSceneFileContainer(scene)
|
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"
|
label := "Direct stream"
|
||||||
ret = append(ret, &models.SceneStreamEndpoint{
|
ret = append(ret, &models.SceneStreamEndpoint{
|
||||||
URL: directStreamURL,
|
URL: directStreamURL,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (t *CleanTask) shouldClean(path string) bool {
|
|||||||
fileExists := image.FileExists(path)
|
fileExists := image.FileExists(path)
|
||||||
|
|
||||||
// #1102 - clean anything in generated path
|
// #1102 - clean anything in generated path
|
||||||
generatedPath := config.GetGeneratedPath()
|
generatedPath := config.GetInstance().GetGeneratedPath()
|
||||||
if !fileExists || getStashFromPath(path) == nil || utils.IsPathInDir(generatedPath, path) {
|
if !fileExists || getStashFromPath(path) == nil || utils.IsPathInDir(generatedPath, path) {
|
||||||
logger.Infof("File not found. Cleaning: \"%s\"", path)
|
logger.Infof("File not found. Cleaning: \"%s\"", path)
|
||||||
return true
|
return true
|
||||||
@@ -62,6 +62,7 @@ func (t *CleanTask) shouldCleanScene(s *models.Scene) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
if !matchExtension(s.Path, config.GetVideoExtensions()) {
|
if !matchExtension(s.Path, config.GetVideoExtensions()) {
|
||||||
logger.Infof("File extension does not match video extensions. Cleaning: \"%s\"", s.Path)
|
logger.Infof("File extension does not match video extensions. Cleaning: \"%s\"", s.Path)
|
||||||
return true
|
return true
|
||||||
@@ -92,6 +93,7 @@ func (t *CleanTask) shouldCleanGallery(g *models.Gallery) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
if !matchExtension(path, config.GetGalleryExtensions()) {
|
if !matchExtension(path, config.GetGalleryExtensions()) {
|
||||||
logger.Infof("File extension does not match gallery extensions. Cleaning: \"%s\"", path)
|
logger.Infof("File extension does not match gallery extensions. Cleaning: \"%s\"", path)
|
||||||
return true
|
return true
|
||||||
@@ -121,6 +123,7 @@ func (t *CleanTask) shouldCleanImage(s *models.Image) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
if !matchExtension(s.Path, config.GetImageExtensions()) {
|
if !matchExtension(s.Path, config.GetImageExtensions()) {
|
||||||
logger.Infof("File extension does not match image extensions. Cleaning: \"%s\"", s.Path)
|
logger.Infof("File extension does not match image extensions. Cleaning: \"%s\"", s.Path)
|
||||||
return true
|
return true
|
||||||
@@ -199,7 +202,7 @@ func (t *CleanTask) fileExists(filename string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getStashFromPath(pathToCheck string) *models.StashConfig {
|
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)) {
|
if utils.IsPathInDir(s.Path, filepath.Dir(pathToCheck)) {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -208,7 +211,7 @@ func getStashFromPath(pathToCheck string) *models.StashConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getStashFromDirPath(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) {
|
if utils.IsPathInDir(s.Path, pathToCheck) {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
if t.full {
|
if t.full {
|
||||||
t.baseDir = config.GetMetadataPath()
|
t.baseDir = config.GetInstance().GetMetadataPath()
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
t.baseDir, err = instance.Paths.Generated.TempDir("export")
|
t.baseDir, err = instance.Paths.Generated.TempDir("export")
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ func (t *ImportTask) Start(wg *sync.WaitGroup) {
|
|||||||
t.scraped = scraped
|
t.scraped = scraped
|
||||||
|
|
||||||
if t.Reset {
|
if t.Reset {
|
||||||
err := database.Reset(config.GetDatabasePath())
|
err := database.Reset(config.GetInstance().GetDatabasePath())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Error resetting database: %s", err.Error())
|
logger.Errorf("Error resetting database: %s", err.Error())
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func (t *ScanTask) Start(wg *sizedwaitgroup.SizedWaitGroup) {
|
|||||||
if t.GeneratePreview {
|
if t.GeneratePreview {
|
||||||
iwg.Add()
|
iwg.Add()
|
||||||
|
|
||||||
|
config := config.GetInstance()
|
||||||
var previewSegmentDuration = config.GetPreviewSegmentDuration()
|
var previewSegmentDuration = config.GetPreviewSegmentDuration()
|
||||||
var previewSegments = config.GetPreviewSegments()
|
var previewSegments = config.GetPreviewSegments()
|
||||||
var previewExcludeStart = config.GetPreviewExcludeStart()
|
var previewExcludeStart = config.GetPreviewExcludeStart()
|
||||||
@@ -313,7 +314,7 @@ func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) {
|
|||||||
|
|
||||||
basename := strings.TrimSuffix(t.FilePath, filepath.Ext(t.FilePath))
|
basename := strings.TrimSuffix(t.FilePath, filepath.Ext(t.FilePath))
|
||||||
var relatedFiles []string
|
var relatedFiles []string
|
||||||
vExt := config.GetVideoExtensions()
|
vExt := config.GetInstance().GetVideoExtensions()
|
||||||
// make a list of media files that can be related to the gallery
|
// make a list of media files that can be related to the gallery
|
||||||
for _, ext := range vExt {
|
for _, ext := range vExt {
|
||||||
related := basename + "." + ext
|
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
|
// if the mod time of the file is different than that of the associated
|
||||||
// scene, then recalculate the checksum and regenerate the thumbnail
|
// scene, then recalculate the checksum and regenerate the thumbnail
|
||||||
modified := t.isFileModified(fileModTime, s.FileModTime)
|
modified := t.isFileModified(fileModTime, s.FileModTime)
|
||||||
|
config := config.GetInstance()
|
||||||
if modified || !s.Size.Valid {
|
if modified || !s.Size.Valid {
|
||||||
oldHash := s.GetHash(config.GetVideoFileNamingAlgorithm())
|
oldHash := s.GetHash(config.GetVideoFileNamingAlgorithm())
|
||||||
s, err = t.rescanScene(s, fileModTime)
|
s, err = t.rescanScene(s, fileModTime)
|
||||||
@@ -874,7 +876,7 @@ func (t *ScanTask) scanImage() {
|
|||||||
logger.Error(err.Error())
|
logger.Error(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if config.GetCreateGalleriesFromFolders() {
|
} else if config.GetInstance().GetCreateGalleriesFromFolders() {
|
||||||
// create gallery from folder or associate with existing gallery
|
// create gallery from folder or associate with existing gallery
|
||||||
logger.Infof("Associating image %s with folder gallery", i.Path)
|
logger.Infof("Associating image %s with folder gallery", i.Path)
|
||||||
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
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 {
|
func (t *ScanTask) doesPathExist() bool {
|
||||||
|
config := config.GetInstance()
|
||||||
vidExt := config.GetVideoExtensions()
|
vidExt := config.GetVideoExtensions()
|
||||||
imgExt := config.GetImageExtensions()
|
imgExt := config.GetImageExtensions()
|
||||||
gExt := config.GetGalleryExtensions()
|
gExt := config.GetGalleryExtensions()
|
||||||
@@ -1057,6 +1060,7 @@ func (t *ScanTask) doesPathExist() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error {
|
func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error {
|
||||||
|
config := config.GetInstance()
|
||||||
vidExt := config.GetVideoExtensions()
|
vidExt := config.GetVideoExtensions()
|
||||||
imgExt := config.GetImageExtensions()
|
imgExt := config.GetImageExtensions()
|
||||||
gExt := config.GetGalleryExtensions()
|
gExt := config.GetGalleryExtensions()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (t *GenerateTranscodeTask) Start(wg *sizedwaitgroup.SizedWaitGroup) {
|
|||||||
|
|
||||||
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
|
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
|
||||||
outputPath := instance.Paths.Generated.GetTmpPath(sceneHash + ".mp4")
|
outputPath := instance.Paths.Generated.GetTmpPath(sceneHash + ".mp4")
|
||||||
transcodeSize := config.GetMaxTranscodeSize()
|
transcodeSize := config.GetInstance().GetMaxTranscodeSize()
|
||||||
options := ffmpeg.TranscodeOptions{
|
options := ffmpeg.TranscodeOptions{
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
MaxTranscodeSize: transcodeSize,
|
MaxTranscodeSize: transcodeSize,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
stashConfig "github.com/stashapp/stash/pkg/manager/config"
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"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) {
|
func getImage(url string, globalConfig GlobalConfig) (*string, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: &http.Transport{ // ignore insecure certificates
|
Transport: &http.Transport{ // ignore insecure certificates
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: !stashConfig.GetScraperCertCheck()}},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: !globalConfig.GetScraperCertCheck()}},
|
||||||
Timeout: imageGetTimeout,
|
Timeout: imageGetTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ func getImage(url string, globalConfig GlobalConfig) (*string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userAgent := globalConfig.UserAgent
|
userAgent := globalConfig.GetScraperUserAgent()
|
||||||
if userAgent != "" {
|
if userAgent != "" {
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,21 +14,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GlobalConfig contains the global scraper options.
|
// GlobalConfig contains the global scraper options.
|
||||||
type GlobalConfig struct {
|
type GlobalConfig interface {
|
||||||
// User Agent used when scraping using http.
|
GetScraperUserAgent() string
|
||||||
UserAgent string
|
GetScrapersPath() string
|
||||||
|
GetScraperCDPPath() string
|
||||||
// Path (file or remote address) to a Chrome CDP instance.
|
GetScraperCertCheck() bool
|
||||||
CDPPath string
|
|
||||||
Path string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c GlobalConfig) isCDPPathHTTP() bool {
|
func isCDPPathHTTP(c GlobalConfig) bool {
|
||||||
return strings.HasPrefix(c.CDPPath, "http://") || strings.HasPrefix(c.CDPPath, "https://")
|
return strings.HasPrefix(c.GetScraperCDPPath(), "http://") || strings.HasPrefix(c.GetScraperCDPPath(), "https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c GlobalConfig) isCDPPathWS() bool {
|
func isCDPPathWS(c GlobalConfig) bool {
|
||||||
return strings.HasPrefix(c.CDPPath, "ws://")
|
return strings.HasPrefix(c.GetScraperCDPPath(), "ws://")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache stores scraper details.
|
// Cache stores scraper details.
|
||||||
@@ -45,7 +43,7 @@ type Cache struct {
|
|||||||
// Scraper configurations are loaded from yml files in the provided scrapers
|
// Scraper configurations are loaded from yml files in the provided scrapers
|
||||||
// directory and any subdirectories.
|
// directory and any subdirectories.
|
||||||
func NewCache(globalConfig GlobalConfig, txnManager models.TransactionManager) (*Cache, error) {
|
func NewCache(globalConfig GlobalConfig, txnManager models.TransactionManager) (*Cache, error) {
|
||||||
scrapers, err := loadScrapers(globalConfig.Path)
|
scrapers, err := loadScrapers(globalConfig.GetScrapersPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// In the event of an error during loading, the cache will be left empty.
|
||||||
func (c *Cache) ReloadScrapers() error {
|
func (c *Cache) ReloadScrapers() error {
|
||||||
c.scrapers = nil
|
c.scrapers = nil
|
||||||
scrapers, err := loadScrapers(c.globalConfig.Path)
|
scrapers, err := loadScrapers(c.globalConfig.GetScrapersPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -102,6 +100,7 @@ func (c *Cache) ReloadScrapers() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO - don't think this is needed
|
||||||
// UpdateConfig updates the global config for the cache. If the scraper path
|
// UpdateConfig updates the global config for the cache. If the scraper path
|
||||||
// has changed, ReloadScrapers will need to be called separately.
|
// has changed, ReloadScrapers will need to be called separately.
|
||||||
func (c *Cache) UpdateConfig(globalConfig GlobalConfig) {
|
func (c *Cache) UpdateConfig(globalConfig GlobalConfig) {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import (
|
|||||||
"golang.org/x/net/publicsuffix"
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"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
|
// 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{
|
client := &http.Client{
|
||||||
Transport: &http.Transport{ // ignore insecure certificates
|
Transport: &http.Transport{ // ignore insecure certificates
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: !stashConfig.GetScraperCertCheck()},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: !globalConfig.GetScraperCertCheck()},
|
||||||
},
|
},
|
||||||
Timeout: scrapeGetTimeout,
|
Timeout: scrapeGetTimeout,
|
||||||
// defaultCheckRedirect code with max changed from 10 to 20
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userAgent := globalConfig.UserAgent
|
userAgent := globalConfig.GetScraperUserAgent()
|
||||||
if userAgent != "" {
|
if userAgent != "" {
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
}
|
}
|
||||||
@@ -114,14 +113,15 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo
|
|||||||
act := context.Background()
|
act := context.Background()
|
||||||
|
|
||||||
// if scraperCDPPath is a remote address, then allocate accordingly
|
// if scraperCDPPath is a remote address, then allocate accordingly
|
||||||
if globalConfig.CDPPath != "" {
|
cdpPath := globalConfig.GetScraperCDPPath()
|
||||||
|
if cdpPath != "" {
|
||||||
var cancelAct context.CancelFunc
|
var cancelAct context.CancelFunc
|
||||||
|
|
||||||
if globalConfig.isCDPPathHTTP() || globalConfig.isCDPPathWS() {
|
if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) {
|
||||||
remote := globalConfig.CDPPath
|
remote := cdpPath
|
||||||
|
|
||||||
// if CDPPath is http(s) then we need to get the websocket URL
|
// if CDPPath is http(s) then we need to get the websocket URL
|
||||||
if globalConfig.isCDPPathHTTP() {
|
if isCDPPathHTTP(globalConfig) {
|
||||||
var err error
|
var err error
|
||||||
remote, err = getRemoteCDPWSAddress(remote)
|
remote, err = getRemoteCDPWSAddress(remote)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,7 +140,7 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo
|
|||||||
|
|
||||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
chromedp.UserDataDir(dir),
|
chromedp.UserDataDir(dir),
|
||||||
chromedp.ExecPath(globalConfig.CDPPath),
|
chromedp.ExecPath(cdpPath),
|
||||||
)
|
)
|
||||||
act, cancelAct = chromedp.NewExecAllocator(act, opts...)
|
act, cancelAct = chromedp.NewExecAllocator(act, opts...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -758,6 +758,24 @@ func TestLoadInvalidXPath(t *testing.T) {
|
|||||||
config.process(q, nil)
|
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) {
|
func TestSubScrape(t *testing.T) {
|
||||||
retHTML := `
|
retHTML := `
|
||||||
<div>
|
<div>
|
||||||
@@ -805,7 +823,7 @@ xPathScrapers:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfig := GlobalConfig{}
|
globalConfig := mockGlobalConfig{}
|
||||||
|
|
||||||
performer, err := c.ScrapePerformerURL(ts.URL, nil, globalConfig)
|
performer, err := c.ScrapePerformerURL(ts.URL, nil, globalConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func (t *transaction) Begin() error {
|
|||||||
return errors.New("transaction already begun")
|
return errors.New("transaction already begun")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := database.Ready(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
t.tx, err = database.DB.BeginTxx(t.Ctx, nil)
|
t.tx, err = database.DB.BeginTxx(t.Ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,6 +128,10 @@ func (t *transaction) Tag() models.TagReaderWriter {
|
|||||||
type ReadTransaction struct{}
|
type ReadTransaction struct{}
|
||||||
|
|
||||||
func (t *ReadTransaction) Begin() error {
|
func (t *ReadTransaction) Begin() error {
|
||||||
|
if err := database.Ready(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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>
|
|
||||||
11
ui/setup/milligram.min.css
vendored
11
ui/setup/milligram.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch, useRouteMatch } from "react-router-dom";
|
||||||
import { IntlProvider } from "react-intl";
|
import { IntlProvider } from "react-intl";
|
||||||
import { ToastProvider } from "src/hooks/Toast";
|
import { ToastProvider } from "src/hooks/Toast";
|
||||||
import LightboxProvider from "src/hooks/Lightbox/context";
|
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 { initPolyfills } from "src/polyfills";
|
||||||
|
|
||||||
import locales from "src/locale";
|
import locales from "src/locale";
|
||||||
import { useConfiguration } from "src/core/StashService";
|
import { useConfiguration, useSystemStatus } from "src/core/StashService";
|
||||||
import { flattenMessages } from "src/utils";
|
import { flattenMessages } from "src/utils";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import MousetrapPause from "mousetrap-pause";
|
import MousetrapPause from "mousetrap-pause";
|
||||||
@@ -25,6 +25,10 @@ import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilen
|
|||||||
import Movies from "./components/Movies/Movies";
|
import Movies from "./components/Movies/Movies";
|
||||||
import Tags from "./components/Tags/Tags";
|
import Tags from "./components/Tags/Tags";
|
||||||
import Images from "./components/Images/Images";
|
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();
|
initPolyfills();
|
||||||
|
|
||||||
@@ -41,35 +45,78 @@ const intlFormats = {
|
|||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const config = useConfiguration();
|
const config = useConfiguration();
|
||||||
|
const { data: systemStatusData } = useSystemStatus();
|
||||||
const language = config.data?.configuration?.interface?.language ?? "en-GB";
|
const language = config.data?.configuration?.interface?.language ?? "en-GB";
|
||||||
const messageLanguage = language.replace(/-/, "");
|
const messageLanguage = language.replace(/-/, "");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const messages = flattenMessages((locales as any)[messageLanguage]);
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
|
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<LightboxProvider>
|
<LightboxProvider>
|
||||||
<MainNavbar />
|
{maybeRenderNavbar()}
|
||||||
<div className="main container-fluid">
|
<div className="main container-fluid">{renderContent()}</div>
|
||||||
<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>
|
|
||||||
</LightboxProvider>
|
</LightboxProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Added scene queue.
|
* Added scene queue.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Revamped setup wizard and migration UI.
|
||||||
* Add various `count` filter criteria and sort options.
|
* Add various `count` filter criteria and sort options.
|
||||||
* Scroll to top when changing page number.
|
* Scroll to top when changing page number.
|
||||||
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
|
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
|
||||||
|
|||||||
@@ -116,9 +116,13 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||||||
|
|
||||||
function onImport() {
|
function onImport() {
|
||||||
setIsImportAlertOpen(false);
|
setIsImportAlertOpen(false);
|
||||||
mutateMetadataImport().then(() => {
|
mutateMetadataImport()
|
||||||
jobStatus.refetch();
|
.then(() => {
|
||||||
});
|
jobStatus.refetch();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
Toast.error(e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImportAlert() {
|
function renderImportAlert() {
|
||||||
@@ -535,9 +539,11 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
mutateMetadataExport().then(() => {
|
mutateMetadataExport()
|
||||||
jobStatus.refetch();
|
.then(() => {
|
||||||
})
|
jobStatus.refetch();
|
||||||
|
})
|
||||||
|
.catch((e) => Toast.error(e))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Full Export
|
Full Export
|
||||||
|
|||||||
158
ui/v2.5/src/components/Setup/Migrate.tsx
Normal file
158
ui/v2.5/src/components/Setup/Migrate.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Button, Card, Container, Form } from "react-bootstrap";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { useSystemStatus, mutateMigrate } from "src/core/StashService";
|
||||||
|
import { LoadingIndicator } from "../Shared";
|
||||||
|
|
||||||
|
export const Migrate: React.FC = () => {
|
||||||
|
const { data: systemStatus, loading } = useSystemStatus();
|
||||||
|
const [backupPath, setBackupPath] = useState<string | undefined>();
|
||||||
|
const [migrateLoading, setMigrateLoading] = useState(false);
|
||||||
|
const [migrateError, setMigrateError] = useState("");
|
||||||
|
|
||||||
|
// make suffix based on current time
|
||||||
|
const now = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/T/g, "_")
|
||||||
|
.replace(/-/g, "")
|
||||||
|
.replace(/:/g, "")
|
||||||
|
.replace(/\..*/, "");
|
||||||
|
const defaultBackupPath = systemStatus
|
||||||
|
? `${systemStatus.systemStatus.databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const discordLink = (
|
||||||
|
<a href="https://discord.gg/2TsNFKt" target="_blank" rel="noreferrer">
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
const githubLink = (
|
||||||
|
<a
|
||||||
|
href="https://github.com/stashapp/stash/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Github repository
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (backupPath === undefined && defaultBackupPath) {
|
||||||
|
setBackupPath(defaultBackupPath);
|
||||||
|
}
|
||||||
|
}, [defaultBackupPath, backupPath]);
|
||||||
|
|
||||||
|
// only display setup wizard if system is not setup
|
||||||
|
if (loading || !systemStatus) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrateLoading) {
|
||||||
|
return <LoadingIndicator message="Migrating database" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
systemStatus.systemStatus.status !== GQL.SystemStatusEnum.NeedsMigration
|
||||||
|
) {
|
||||||
|
// redirect to main page
|
||||||
|
const newURL = new URL("/", window.location.toString());
|
||||||
|
window.location.href = newURL.toString();
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = systemStatus.systemStatus;
|
||||||
|
|
||||||
|
async function onMigrate() {
|
||||||
|
try {
|
||||||
|
setMigrateLoading(true);
|
||||||
|
setMigrateError("");
|
||||||
|
await mutateMigrate({
|
||||||
|
backupPath: backupPath ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newURL = new URL("/", window.location.toString());
|
||||||
|
window.location.href = newURL.toString();
|
||||||
|
} catch (e) {
|
||||||
|
setMigrateError(e.message ?? e.toString());
|
||||||
|
setMigrateLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderError() {
|
||||||
|
if (!migrateError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-danger">Migration failed</h2>
|
||||||
|
|
||||||
|
<p>The following error was encountered while migrating the database:</p>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<pre>{migrateError}</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please make any necessary corrections and try again. Otherwise, raise
|
||||||
|
a bug on the {githubLink} or seek help in the {discordLink}.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<h1 className="text-center mb-3">Migration required</h1>
|
||||||
|
<Card>
|
||||||
|
<section>
|
||||||
|
<p>
|
||||||
|
Your current stash database is schema version{" "}
|
||||||
|
<strong>{status.databaseSchema}</strong> and needs to be migrated to
|
||||||
|
version <strong>{status.appSchema}</strong>. This version of Stash
|
||||||
|
will not function without migrating the database.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="lead text-center my-5">
|
||||||
|
The schema migration process is not reversible. Once the migration
|
||||||
|
is performed, your database will be incompatible with previous
|
||||||
|
versions of stash.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
It is recommended that you backup your existing database before you
|
||||||
|
migrate. We can do this for you, making a copy of your writing a
|
||||||
|
backup to <code>{defaultBackupPath}</code> if required.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Form.Group id="migrate">
|
||||||
|
<Form.Label>
|
||||||
|
Backup database path (leave empty to disable backup):
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
name="backupPath"
|
||||||
|
defaultValue={backupPath}
|
||||||
|
placeholder="database filename (empty for default)"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setBackupPath(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="d-flex justify-content-center">
|
||||||
|
<Button variant="primary mx-2 p-5" onClick={() => onMigrate()}>
|
||||||
|
Perform schema migration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{maybeRenderError()}
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
460
ui/v2.5/src/components/Setup/Setup.tsx
Normal file
460
ui/v2.5/src/components/Setup/Setup.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Form,
|
||||||
|
InputGroup,
|
||||||
|
} from "react-bootstrap";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { mutateSetup, useSystemStatus } from "src/core/StashService";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import StashConfiguration from "../Settings/StashConfiguration";
|
||||||
|
import { Icon, LoadingIndicator } from "../Shared";
|
||||||
|
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
|
||||||
|
|
||||||
|
export const Setup: React.FC = () => {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [configLocation, setConfigLocation] = useState("");
|
||||||
|
const [stashes, setStashes] = useState<GQL.StashConfig[]>([]);
|
||||||
|
const [generatedLocation, setGeneratedLocation] = useState("");
|
||||||
|
const [databaseFile, setDatabaseFile] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [setupError, setSetupError] = useState("");
|
||||||
|
|
||||||
|
const [showGeneratedDialog, setShowGeneratedDialog] = useState(false);
|
||||||
|
|
||||||
|
const { data: systemStatus, loading: statusLoading } = useSystemStatus();
|
||||||
|
|
||||||
|
const discordLink = (
|
||||||
|
<a href="https://discord.gg/2TsNFKt" target="_blank" rel="noreferrer">
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
const githubLink = (
|
||||||
|
<a
|
||||||
|
href="https://github.com/stashapp/stash/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Github repository
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
function onConfigLocationChosen(loc: string) {
|
||||||
|
setConfigLocation(loc);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(n?: number) {
|
||||||
|
let dec = n;
|
||||||
|
if (!dec) {
|
||||||
|
dec = 1;
|
||||||
|
}
|
||||||
|
setStep(Math.max(0, step - dec));
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setStep(step + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWelcome() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-5">Welcome to Stash</h2>
|
||||||
|
<p className="lead text-center">
|
||||||
|
If you'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 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) =>
|
export const useDirectory = (path?: string) =>
|
||||||
GQL.useDirectoryQuery({ variables: { path } });
|
GQL.useDirectoryQuery({ variables: { path } });
|
||||||
|
|
||||||
@@ -690,6 +704,17 @@ export const useMetadataUpdate = () => GQL.useMetadataUpdateSubscription();
|
|||||||
|
|
||||||
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
|
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 = () =>
|
export const useLogs = () =>
|
||||||
GQL.useLogsQuery({
|
GQL.useLogsQuery({
|
||||||
fetchPolicy: "no-cache",
|
fetchPolicy: "no-cache",
|
||||||
|
|||||||
Reference in New Issue
Block a user