From 7cff71c35f0334c36a0d75872cc7757346cac141 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:52:49 +1100 Subject: [PATCH] Add filesystem based blob storage (#3187) * Refactor transaction hooks. Add preCommit * Add BlobStore * Use blobStore for tag images * Use blobStore for studio images * Use blobStore for performer images * Use blobStore for scene covers * Don't generate screenshots in legacy directory * Run post-hooks outside original transaction * Use blobStore for movie images * Remove unnecessary DestroyImage methods * Add missing filter for scene cover * Add covers to generate options * Add generate cover option to UI * Add screenshot migration * Delete thumb files as part of screenshot migration --- gqlgen.yml | 2 + graphql/documents/data/config.graphql | 4 + graphql/documents/mutations/migration.graphql | 7 + graphql/schema/schema.graphql | 5 + graphql/schema/types/config.graphql | 17 + graphql/schema/types/metadata.graphql | 10 +- graphql/schema/types/migration.graphql | 11 + internal/api/resolver_mutation_configure.go | 27 +- internal/api/resolver_mutation_migrate.go | 40 ++ internal/api/resolver_mutation_movie.go | 42 +- internal/api/resolver_mutation_scene.go | 9 - internal/api/resolver_mutation_stash_box.go | 2 +- internal/api/resolver_mutation_studio.go | 7 +- internal/api/resolver_mutation_tag.go | 7 +- internal/api/resolver_query_configuration.go | 2 + internal/api/routes_movie.go | 10 +- internal/api/routes_performer.go | 5 +- internal/api/routes_studio.go | 5 +- internal/api/routes_tag.go | 5 +- internal/identify/identify.go | 3 +- internal/manager/config/config.go | 25 ++ internal/manager/config/enums.go | 50 +++ internal/manager/config/tasks.go | 2 + internal/manager/manager.go | 57 ++- internal/manager/manager_tasks.go | 10 +- internal/manager/repository.go | 2 - internal/manager/running_streams.go | 30 +- internal/manager/task/migrate_blobs.go | 129 ++++++ .../manager/task/migrate_scene_screenshots.go | 135 +++++++ internal/manager/task_generate.go | 66 +-- internal/manager/task_generate_screenshot.go | 69 ++-- internal/manager/task_identify.go | 8 +- internal/manager/task_scan.go | 43 +- pkg/file/clean.go | 4 +- pkg/file/delete.go | 8 +- pkg/file/fs.go | 20 + pkg/file/scan.go | 3 +- pkg/image/scan.go | 4 +- pkg/models/generate.go | 19 +- pkg/models/mocks/MovieReaderWriter.go | 56 +-- pkg/models/mocks/SceneReaderWriter.go | 35 +- pkg/models/mocks/StudioReaderWriter.go | 14 - pkg/models/mocks/TagReaderWriter.go | 14 - pkg/models/model_movie.go | 4 + pkg/models/model_studio.go | 2 + pkg/models/model_tag.go | 14 +- pkg/models/movie.go | 4 +- pkg/models/paths/paths.go | 5 +- pkg/models/paths/paths_scenes.go | 6 +- pkg/models/scene.go | 2 +- pkg/models/studio.go | 1 - pkg/models/tag.go | 1 - pkg/movie/import.go | 17 +- pkg/movie/import_test.go | 5 +- pkg/plugin/plugins.go | 3 +- pkg/scene/create.go | 14 - pkg/scene/delete.go | 12 - pkg/scene/generate/generator.go | 23 +- pkg/scene/generate/screenshot.go | 53 +-- pkg/scene/merge.go | 4 +- pkg/scene/migrate_hash.go | 8 - pkg/scene/migrate_screenshots.go | 143 +++++++ pkg/scene/scan.go | 13 +- pkg/scene/screenshot.go | 103 ----- pkg/scene/service.go | 1 + pkg/scene/update.go | 6 +- pkg/scene/update_test.go | 8 +- pkg/scraper/stashbox/stash_box.go | 2 +- pkg/sqlite/anonymise.go | 18 +- pkg/sqlite/blob.go | 382 ++++++++++++++++++ pkg/sqlite/blob/fs.go | 108 +++++ pkg/sqlite/blob_migrate.go | 116 ++++++ pkg/sqlite/blob_test.go | 45 +++ pkg/sqlite/database.go | 25 +- pkg/sqlite/migrations/45_blobs.up.sql | 19 + pkg/sqlite/migrations/45_postmigrate.go | 286 +++++++++++++ pkg/sqlite/movies.go | 75 ++-- pkg/sqlite/movies_test.go | 182 +++++---- pkg/sqlite/performer.go | 38 +- pkg/sqlite/performer_test.go | 21 +- pkg/sqlite/repository.go | 23 -- pkg/sqlite/scene.go | 42 +- pkg/sqlite/scene_test.go | 48 +-- pkg/sqlite/setup_test.go | 18 +- pkg/sqlite/sql.go | 25 -- pkg/sqlite/studio.go | 55 +-- pkg/sqlite/studio_test.go | 115 ++---- pkg/sqlite/tables.go | 7 + pkg/sqlite/tag.go | 67 ++- pkg/sqlite/tag_test.go | 101 +---- pkg/sqlite/transaction.go | 6 +- pkg/txn/hooks.go | 53 ++- pkg/txn/transaction.go | 48 ++- .../Settings/SettingsSystemPanel.tsx | 54 ++- .../Settings/Tasks/DataManagementTasks.tsx | 112 +++++ .../Settings/Tasks/GenerateOptions.tsx | 6 + .../Settings/Tasks/LibraryTasks.tsx | 1 + .../components/Settings/Tasks/ScanOptions.tsx | 7 + ui/v2.5/src/components/Setup/Setup.tsx | 83 ++++ ui/v2.5/src/core/StashService.ts | 14 + ui/v2.5/src/docs/en/Changelog/v0200.md | 13 + ui/v2.5/src/docs/en/ReleaseNotes/index.ts | 2 +- ui/v2.5/src/docs/en/ReleaseNotes/v0200.md | 8 +- ui/v2.5/src/locales/en-GB.json | 32 ++ .../models/list-filter/criteria/is-missing.ts | 1 + 105 files changed, 2647 insertions(+), 1086 deletions(-) create mode 100644 graphql/documents/mutations/migration.graphql create mode 100644 graphql/schema/types/migration.graphql create mode 100644 internal/api/resolver_mutation_migrate.go create mode 100644 internal/manager/config/enums.go create mode 100644 internal/manager/task/migrate_blobs.go create mode 100644 internal/manager/task/migrate_scene_screenshots.go create mode 100644 pkg/scene/migrate_screenshots.go delete mode 100644 pkg/scene/screenshot.go create mode 100644 pkg/sqlite/blob.go create mode 100644 pkg/sqlite/blob/fs.go create mode 100644 pkg/sqlite/blob_migrate.go create mode 100644 pkg/sqlite/blob_test.go create mode 100644 pkg/sqlite/migrations/45_blobs.up.sql create mode 100644 pkg/sqlite/migrations/45_postmigrate.go diff --git a/gqlgen.yml b/gqlgen.yml index 9e419a002..5be8c743a 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -34,6 +34,8 @@ models: title: resolver: true # autobind on config causes generation issues + BlobsStorageType: + model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType StashConfig: model: github.com/stashapp/stash/internal/manager/config.StashConfig StashConfigInput: diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 40c8e9228..173a7948e 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -10,6 +10,8 @@ fragment ConfigGeneralData on ConfigGeneralResult { metadataPath scrapersPath cachePath + blobsPath + blobsStorage calculateMD5 videoFileNamingAlgorithm parallelTasks @@ -131,6 +133,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { scan { useFileMetadata stripFileExtension + scanGenerateCovers scanGeneratePreviews scanGenerateImagePreviews scanGenerateSprites @@ -159,6 +162,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { } generate { + covers sprites previews imagePreviews diff --git a/graphql/documents/mutations/migration.graphql b/graphql/documents/mutations/migration.graphql new file mode 100644 index 000000000..edf483276 --- /dev/null +++ b/graphql/documents/mutations/migration.graphql @@ -0,0 +1,7 @@ +mutation MigrateSceneScreenshots($input: MigrateSceneScreenshotsInput!) { + migrateSceneScreenshots(input: $input) +} + +mutation MigrateBlobs($input: MigrateBlobsInput!) { + migrateBlobs(input: $input) +} \ No newline at end of file diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 85de2d826..87b0a916f 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -287,8 +287,13 @@ type Mutation { metadataClean(input: CleanMetadataInput!): ID! """Identifies scenes using scrapers. Returns the job ID""" metadataIdentify(input: IdentifyMetadataInput!): ID! + """Migrate generated files for the current hash naming""" migrateHashNaming: ID! + """Migrates legacy scene screenshot files into the blob storage""" + migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID! + """Migrates blobs from the old storage system to the current one""" + migrateBlobs(input: MigrateBlobsInput!): ID! """Anonymise the database in a separate file. Optionally returns a link to download the database file""" anonymiseDatabase(input: AnonymiseDatabaseInput!): String diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 9e7ee529e..df0aba092 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -8,6 +8,8 @@ input SetupInput { generatedLocation: String! """Empty to indicate default""" cacheLocation: String! + """Empty to indicate database storage for blobs""" + blobsLocation: String! } enum StreamingResolutionEnum { @@ -34,6 +36,13 @@ enum HashAlgorithm { "oshash", OSHASH } +enum BlobsStorageType { + # blobs are stored in the database + "Database", DATABASE + # blobs are stored in the filesystem under the configured blobs directory + "Filesystem", FILESYSTEM +} + input ConfigGeneralInput { """Array of file paths to content""" stashes: [StashConfigInput!] @@ -49,6 +58,10 @@ input ConfigGeneralInput { scrapersPath: String """Path to cache""" cachePath: String + """Path to blobs - required for filesystem blob storage""" + blobsPath: String + """Where to store blobs""" + blobsStorage: BlobsStorageType """Whether to calculate MD5 checksums for scene video files""" calculateMD5: Boolean """Hash algorithm to use for generated file naming""" @@ -154,6 +167,10 @@ type ConfigGeneralResult { scrapersPath: String! """Path to cache""" cachePath: String! + """Path to blobs - required for filesystem blob storage""" + blobsPath: String! + """Where to store blobs""" + blobsStorage: BlobsStorageType! """Whether to calculate MD5 checksums for scene video files""" calculateMD5: Boolean! """Hash algorithm to use for generated file naming""" diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index bf3ee2566..ecde11eac 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -1,6 +1,7 @@ scalar Upload input GenerateMetadataInput { + covers: Boolean sprites: Boolean previews: Boolean imagePreviews: Boolean @@ -37,6 +38,7 @@ input GeneratePreviewOptionsInput { } type GenerateMetadataOptions { + covers: Boolean sprites: Boolean previews: Boolean imagePreviews: Boolean @@ -84,6 +86,8 @@ input ScanMetadataInput { """Strip file extension from title""" stripFileExtension: Boolean @deprecated(reason: "Not implemented") + """Generate covers during scan""" + scanGenerateCovers: Boolean """Generate previews during scan""" scanGeneratePreviews: Boolean """Generate image previews during scan""" @@ -101,9 +105,11 @@ input ScanMetadataInput { type ScanMetadataOptions { """Set name, date, details from metadata (if present)""" - useFileMetadata: Boolean! + useFileMetadata: Boolean! @deprecated(reason: "Not implemented") """Strip file extension from title""" - stripFileExtension: Boolean! + stripFileExtension: Boolean! @deprecated(reason: "Not implemented") + """Generate covers during scan""" + scanGenerateCovers: Boolean! """Generate previews during scan""" scanGeneratePreviews: Boolean! """Generate image previews during scan""" diff --git a/graphql/schema/types/migration.graphql b/graphql/schema/types/migration.graphql new file mode 100644 index 000000000..231f79555 --- /dev/null +++ b/graphql/schema/types/migration.graphql @@ -0,0 +1,11 @@ +input MigrateSceneScreenshotsInput { + # if true, delete screenshot files after migrating + deleteFiles: Boolean + # if true, overwrite existing covers with the covers from the screenshots directory + overwriteExisting: Boolean +} + +input MigrateBlobsInput { + # if true, delete blob data from old storage system + deleteOld: Boolean +} diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 81c3138f5..5342182f9 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -134,6 +134,28 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen refreshStreamManager = true } + refreshBlobStorage := false + existingBlobsPath := c.GetBlobsPath() + if input.BlobsPath != nil && existingBlobsPath != *input.BlobsPath { + if err := validateDir(config.BlobsPath, *input.BlobsPath, true); err != nil { + return makeConfigGeneralResult(), err + } + + c.Set(config.BlobsPath, input.BlobsPath) + refreshBlobStorage = true + } + + if input.BlobsStorage != nil && *input.BlobsStorage != c.GetBlobsStorage() { + if *input.BlobsStorage == config.BlobStorageTypeFilesystem && c.GetBlobsPath() == "" { + return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage") + } + + // TODO - migrate between systems + c.Set(config.BlobsStorage, input.BlobsStorage) + + refreshBlobStorage = true + } + if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() { calculateMD5 := c.IsCalculateMD5() if input.CalculateMd5 != nil { @@ -336,6 +358,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen if refreshStreamManager { manager.GetInstance().RefreshStreamManager() } + if refreshBlobStorage { + manager.GetInstance().SetBlobStoreOptions() + } return makeConfigGeneralResult(), nil } @@ -530,7 +555,7 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDe } if input.Scan != nil { - c.Set(config.DefaultScanSettings, input.Scan.ScanMetadataOptions) + c.Set(config.DefaultScanSettings, input.Scan) } if input.AutoTag != nil { diff --git a/internal/api/resolver_mutation_migrate.go b/internal/api/resolver_mutation_migrate.go new file mode 100644 index 000000000..477f46b44 --- /dev/null +++ b/internal/api/resolver_mutation_migrate.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "strconv" + + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/task" + "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/utils" +) + +func (r *mutationResolver) MigrateSceneScreenshots(ctx context.Context, input MigrateSceneScreenshotsInput) (string, error) { + db := manager.GetInstance().Database + t := &task.MigrateSceneScreenshotsJob{ + ScreenshotsPath: manager.GetInstance().Paths.Generated.Screenshots, + Input: scene.MigrateSceneScreenshotsInput{ + DeleteFiles: utils.IsTrue(input.DeleteFiles), + OverwriteExisting: utils.IsTrue(input.OverwriteExisting), + }, + SceneRepo: db.Scene, + TxnManager: db, + } + jobID := manager.GetInstance().JobManager.Add(ctx, "Migrating scene screenshots to blobs...", t) + + return strconv.Itoa(jobID), nil +} + +func (r *mutationResolver) MigrateBlobs(ctx context.Context, input MigrateBlobsInput) (string, error) { + db := manager.GetInstance().Database + t := &task.MigrateBlobsJob{ + TxnManager: db, + BlobStore: db.Blobs, + Vacuumer: db, + DeleteOld: utils.IsTrue(input.DeleteOld), + } + jobID := manager.GetInstance().JobManager.Add(ctx, "Migrating blobs...", t) + + return strconv.Itoa(jobID), nil +} diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index f3d3e529d..009e9bc92 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -111,7 +111,13 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp // update image table if len(frontimageData) > 0 { - if err := qb.UpdateImages(ctx, movie.ID, frontimageData, backimageData); err != nil { + if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil { + return err + } + } + + if len(backimageData) > 0 { + if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil { return err } } @@ -184,35 +190,15 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp } // update image table - if frontImageIncluded || backImageIncluded { - if !frontImageIncluded { - frontimageData, err = qb.GetFrontImage(ctx, updatedMovie.ID) - if err != nil { - return err - } - } - if !backImageIncluded { - backimageData, err = qb.GetBackImage(ctx, updatedMovie.ID) - if err != nil { - return err - } + if frontImageIncluded { + if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil { + return err } + } - if len(frontimageData) == 0 && len(backimageData) == 0 { - // both images are being nulled. Destroy them. - if err := qb.DestroyImages(ctx, movie.ID); err != nil { - return err - } - } else { - // HACK - if front image is null and back image is not null, then set the front image - // to the default image since we can't have a null front image and a non-null back image - if frontimageData == nil && backimageData != nil { - frontimageData, _ = utils.ProcessImageInput(ctx, models.DefaultMovieImage) - } - - if err := qb.UpdateImages(ctx, movie.ID, frontimageData, backimageData); err != nil { - return err - } + if backImageIncluded { + if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil { + return err } } diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 3abc917a9..dfdb29507 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -9,14 +9,12 @@ import ( "time" "github.com/stashapp/stash/internal/manager" - "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice" - "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) @@ -320,13 +318,6 @@ func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models. if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil { return err } - - if s.Path != "" { - // update the file-based screenshot after commit - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { - return scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData) - }) - } } return nil diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index c01625688..9abb7179c 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -62,7 +62,7 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S return fmt.Errorf("scene with id %d not found", id) } - cover, err := r.sceneService.GetCover(ctx, scene) + cover, err := qb.GetCover(ctx, id) if err != nil { return fmt.Errorf("getting scene cover: %w", err) } diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 98c871323..f9862d9be 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -176,15 +176,10 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateI } // update image table - if len(imageData) > 0 { + if imageIncluded { if err := qb.UpdateImage(ctx, s.ID, imageData); err != nil { return err } - } else if imageIncluded { - // must be unsetting - if err := qb.DestroyImage(ctx, s.ID); err != nil { - return err - } } // Save the stash_ids diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 47986c56e..04f10ce88 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -208,15 +208,10 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) } // update image table - if len(imageData) > 0 { + if imageIncluded { if err := qb.UpdateImage(ctx, tagID, imageData); err != nil { return err } - } else if imageIncluded { - // must be unsetting - if err := qb.DestroyImage(ctx, tagID); err != nil { - return err - } } if translator.hasField("aliases") { diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 65b922d3e..fd598ce92 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -91,6 +91,8 @@ func makeConfigGeneralResult() *ConfigGeneralResult { ConfigFilePath: config.GetConfigFile(), ScrapersPath: config.GetScrapersPath(), CachePath: config.GetCachePath(), + BlobsPath: config.GetBlobsPath(), + BlobsStorage: config.GetBlobsStorage(), CalculateMd5: config.IsCalculateMD5(), VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), ParallelTasks: config.GetParallelTasks(), diff --git a/internal/api/routes_movie.go b/internal/api/routes_movie.go index c29718566..7b77586a6 100644 --- a/internal/api/routes_movie.go +++ b/internal/api/routes_movie.go @@ -42,8 +42,9 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { - image, _ = rs.movieFinder.GetFrontImage(ctx, movie.ID) - return nil + var err error + image, err = rs.movieFinder.GetFrontImage(ctx, movie.ID) + return err }) if errors.Is(readTxnErr, context.Canceled) { return @@ -68,8 +69,9 @@ func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { - image, _ = rs.movieFinder.GetBackImage(ctx, movie.ID) - return nil + var err error + image, err = rs.movieFinder.GetBackImage(ctx, movie.ID) + return err }) if errors.Is(readTxnErr, context.Canceled) { return diff --git a/internal/api/routes_performer.go b/internal/api/routes_performer.go index c8295467a..1717e99f9 100644 --- a/internal/api/routes_performer.go +++ b/internal/api/routes_performer.go @@ -42,8 +42,9 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { - image, _ = rs.performerFinder.GetImage(ctx, performer.ID) - return nil + var err error + image, err = rs.performerFinder.GetImage(ctx, performer.ID) + return err }) if errors.Is(readTxnErr, context.Canceled) { return diff --git a/internal/api/routes_studio.go b/internal/api/routes_studio.go index 2ddeb51a3..a77763e8d 100644 --- a/internal/api/routes_studio.go +++ b/internal/api/routes_studio.go @@ -42,8 +42,9 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { - image, _ = rs.studioFinder.GetImage(ctx, studio.ID) - return nil + var err error + image, err = rs.studioFinder.GetImage(ctx, studio.ID) + return err }) if errors.Is(readTxnErr, context.Canceled) { return diff --git a/internal/api/routes_tag.go b/internal/api/routes_tag.go index 1f72928c2..e3ee439e9 100644 --- a/internal/api/routes_tag.go +++ b/internal/api/routes_tag.go @@ -42,8 +42,9 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { - image, _ = rs.tagFinder.GetImage(ctx, tag.ID) - return nil + var err error + image, err = rs.tagFinder.GetImage(ctx, tag.ID) + return err }) if errors.Is(readTxnErr, context.Canceled) { return diff --git a/internal/identify/identify.go b/internal/identify/identify.go index c828f4164..04eccb7b0 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -35,7 +35,6 @@ type SceneIdentifier struct { DefaultOptions *MetadataOptions Sources []ScraperSource - ScreenshotSetter scene.ScreenshotSetter SceneUpdatePostHookExecutor SceneUpdatePostHookExecutor } @@ -216,7 +215,7 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager txn.Manage return nil } - if _, err := updater.Update(ctx, t.SceneReaderUpdater, t.ScreenshotSetter); err != nil { + if _, err := updater.Update(ctx, t.SceneReaderUpdater); err != nil { return fmt.Errorf("error updating scene: %w", err) } diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 1ba0ce5b3..d5c6b7ac6 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -31,12 +31,15 @@ const ( BackupDirectoryPath = "backup_directory_path" Generated = "generated" Metadata = "metadata" + BlobsPath = "blobs_path" Downloads = "downloads" ApiKey = "api_key" Username = "username" Password = "password" MaxSessionAge = "max_session_age" + BlobsStorage = "blobs_storage" + DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours Database = "database" @@ -551,6 +554,22 @@ func (i *Instance) GetGeneratedPath() string { return i.getString(Generated) } +func (i *Instance) GetBlobsPath() string { + return i.getString(BlobsPath) +} + +func (i *Instance) GetBlobsStorage() BlobsStorageType { + ret := BlobsStorageType(i.getString(BlobsStorage)) + + if !ret.IsValid() { + // default to database storage + // for legacy systems this is probably the safer option + ret = BlobStorageTypeDatabase + } + + return ret +} + func (i *Instance) GetMetadataPath() string { return i.getString(Metadata) } @@ -1458,6 +1477,12 @@ func (i *Instance) Validate() error { } } + if i.GetBlobsStorage() == BlobStorageTypeFilesystem && i.viper(BlobsPath).GetString(BlobsPath) == "" { + return MissingConfigError{ + missingFields: []string{BlobsPath}, + } + } + return nil } diff --git a/internal/manager/config/enums.go b/internal/manager/config/enums.go new file mode 100644 index 000000000..c1e045344 --- /dev/null +++ b/internal/manager/config/enums.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "io" + "strconv" +) + +type BlobsStorageType string + +const ( + // Database + BlobStorageTypeDatabase BlobsStorageType = "DATABASE" + // Filesystem + BlobStorageTypeFilesystem BlobsStorageType = "FILESYSTEM" +) + +var AllBlobStorageType = []BlobsStorageType{ + BlobStorageTypeDatabase, + BlobStorageTypeFilesystem, +} + +func (e BlobsStorageType) IsValid() bool { + switch e { + case BlobStorageTypeDatabase, BlobStorageTypeFilesystem: + return true + } + return false +} + +func (e BlobsStorageType) String() string { + return string(e) +} + +func (e *BlobsStorageType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = BlobsStorageType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid BlobStorageType", str) + } + return nil +} + +func (e BlobsStorageType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/internal/manager/config/tasks.go b/internal/manager/config/tasks.go index 2f69c8a50..1e541fcc5 100644 --- a/internal/manager/config/tasks.go +++ b/internal/manager/config/tasks.go @@ -7,6 +7,8 @@ type ScanMetadataOptions struct { // Strip file extension from title // Deprecated: not implemented StripFileExtension bool `json:"stripFileExtension"` + // Generate scene covers during scan + ScanGenerateCovers bool `json:"scanGenerateCovers"` // Generate previews during scan ScanGeneratePreviews bool `json:"scanGeneratePreviews"` // Generate image previews during scan diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 1ed4d163a..a591e98ab 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -26,11 +26,9 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sqlite" @@ -102,6 +100,8 @@ type SetupInput struct { GeneratedLocation string `json:"generatedLocation"` // Empty to indicate default CacheLocation string `json:"cacheLocation"` + // Empty to indicate database storage for blobs + BlobsLocation string `json:"blobsLocation"` } type Manager struct { @@ -290,20 +290,6 @@ func galleryFileFilter(ctx context.Context, f file.File) bool { return isZip(f.Base().Basename) } -type coverGenerator struct { -} - -func (g *coverGenerator) GenerateCover(ctx context.Context, scene *models.Scene, f *file.VideoFile) error { - gg := generate.Generator{ - Encoder: instance.FFMPEG, - FFMpegConfig: instance.Config, - LockManager: instance.ReadLockManager, - ScenePaths: instance.Paths.Scene, - } - - return gg.Screenshot(ctx, f.Path, scene.GetHash(instance.Config.GetVideoFileNamingAlgorithm()), f.Width, f.Duration, generate.ScreenshotOptions{}) -} - func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner { return &file.Scanner{ Repository: file.Repository{ @@ -458,7 +444,7 @@ func (s *Manager) PostInit(ctx context.Context) error { logger.Warnf("could not set initial configuration: %v", err) } - *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath(), s.Config.GetBlobsPath()) s.RefreshConfig() s.SessionStore = session.NewStore(s.Config) s.PluginCache.RegisterSessionStore(s.SessionStore) @@ -467,6 +453,8 @@ func (s *Manager) PostInit(ctx context.Context) error { logger.Errorf("Error reading plugin configs: %s", err.Error()) } + s.SetBlobStoreOptions() + s.ScraperCache = instance.initScraperCache() writeStashIcon() @@ -509,6 +497,17 @@ func (s *Manager) PostInit(ctx context.Context) error { return nil } +func (s *Manager) SetBlobStoreOptions() { + storageType := s.Config.GetBlobsStorage() + blobsPath := s.Config.GetBlobsPath() + + s.Database.SetBlobStoreOptions(sqlite.BlobStoreOptions{ + UseFilesystem: storageType == config.BlobStorageTypeFilesystem, + UseDatabase: storageType == config.BlobStorageTypeDatabase, + Path: blobsPath, + }) +} + func writeStashIcon() { p := FaviconProvider{ UIBox: ui.UIBox, @@ -540,7 +539,7 @@ func (s *Manager) initScraperCache() *scraper.Cache { } func (s *Manager) RefreshConfig() { - *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath(), s.Config.GetBlobsPath()) config := s.Config if config.Validate() == nil { if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil { @@ -617,7 +616,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { configDir := filepath.Dir(configFile) if exists, _ := fsutil.DirExists(configDir); !exists { - if err := os.Mkdir(configDir, 0755); err != nil { + if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("error creating config directory: %v", err) } } @@ -632,7 +631,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { // create the generated directory if it does not exist if !c.HasOverride(config.Generated) { if exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists { - if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil { + if err := os.MkdirAll(input.GeneratedLocation, 0755); err != nil { return fmt.Errorf("error creating generated directory: %v", err) } } @@ -643,7 +642,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { // create the cache directory if it does not exist if !c.HasOverride(config.Cache) { if exists, _ := fsutil.DirExists(input.CacheLocation); !exists { - if err := os.Mkdir(input.CacheLocation, 0755); err != nil { + if err := os.MkdirAll(input.CacheLocation, 0755); err != nil { return fmt.Errorf("error creating cache directory: %v", err) } } @@ -651,6 +650,22 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { s.Config.Set(config.Cache, input.CacheLocation) } + // if blobs path was provided then use filesystem based blob storage + if input.BlobsLocation != "" { + if !c.HasOverride(config.BlobsPath) { + if exists, _ := fsutil.DirExists(input.BlobsLocation); !exists { + if err := os.MkdirAll(input.BlobsLocation, 0755); err != nil { + return fmt.Errorf("error creating blobs directory: %v", err) + } + } + } + + s.Config.Set(config.BlobsPath, input.BlobsLocation) + s.Config.Set(config.BlobsStorage, config.BlobStorageTypeFilesystem) + } else { + s.Config.Set(config.BlobsStorage, config.BlobStorageTypeDatabase) + } + // set the configuration if !c.HasOverride(config.Database) { s.Config.Set(config.Database, input.DatabaseFile) diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 33354073d..de4807760 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -194,11 +194,11 @@ func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *fl return } - task := GenerateScreenshotTask{ - txnManager: s.Repository, - Scene: *scene, - ScreenshotAt: at, - fileNamingAlgorithm: config.GetInstance().GetVideoFileNamingAlgorithm(), + task := GenerateCoverTask{ + txnManager: s.Repository, + Scene: *scene, + ScreenshotAt: at, + Overwrite: true, } task.Start(ctx) diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 59428b146..c6ea17f85 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -102,8 +102,6 @@ type SceneService interface { AssignFile(ctx context.Context, sceneID int, fileID file.ID) error Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial) error Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error - - GetCover(ctx context.Context, scene *models.Scene) ([]byte, error) } type ImageService interface { diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go index 83f93c2ea..5c95743b7 100644 --- a/internal/manager/running_streams.go +++ b/internal/manager/running_streams.go @@ -54,32 +54,32 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) { const defaultSceneImage = "scene/scene.svg" - if scene.Path != "" { - filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) - - // fall back to the scene image blob if the file isn't present - screenshotExists, _ := fsutil.FileExists(filepath) - if screenshotExists { - http.ServeFile(w, r, filepath) - return - } - } - var cover []byte readTxnErr := txn.WithReadTxn(r.Context(), s.TxnManager, func(ctx context.Context) error { - cover, _ = s.SceneCoverGetter.GetCover(ctx, scene.ID) - return nil + var err error + cover, err = s.SceneCoverGetter.GetCover(ctx, scene.ID) + return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch screenshot: %v", readTxnErr) - http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) - return } if cover == nil { + // fallback to legacy image if present + if scene.Path != "" { + filepath := GetInstance().Paths.Scene.GetLegacyScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) + + // fall back to the scene image blob if the file isn't present + screenshotExists, _ := fsutil.FileExists(filepath) + if screenshotExists { + http.ServeFile(w, r, filepath) + return + } + } + // fallback to default cover if none found // should always be there f, _ := static.Scene.Open(defaultSceneImage) diff --git a/internal/manager/task/migrate_blobs.go b/internal/manager/task/migrate_blobs.go new file mode 100644 index 000000000..4cda65725 --- /dev/null +++ b/internal/manager/task/migrate_blobs.go @@ -0,0 +1,129 @@ +package task + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/job" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/txn" +) + +type BlobStoreMigrator interface { + Count(ctx context.Context) (int, error) + FindBlobs(ctx context.Context, n uint, lastChecksum string) ([]string, error) + MigrateBlob(ctx context.Context, checksum string, deleteOld bool) error +} + +type Vacuumer interface { + Vacuum(ctx context.Context) error +} + +type MigrateBlobsJob struct { + TxnManager txn.Manager + BlobStore BlobStoreMigrator + Vacuumer Vacuumer + DeleteOld bool +} + +func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) { + var ( + count int + err error + ) + progress.ExecuteTask("Counting blobs", func() { + count, err = j.countBlobs(ctx) + progress.SetTotal(count) + }) + + if err != nil { + logger.Errorf("Error counting blobs: %s", err.Error()) + return + } + + if count == 0 { + logger.Infof("No blobs to migrate") + return + } + + logger.Infof("Migrating %d blobs", count) + + progress.ExecuteTask(fmt.Sprintf("Migrating %d blobs", count), func() { + err = j.migrateBlobs(ctx, progress) + }) + + if job.IsCancelled(ctx) { + logger.Info("Cancelled migrating blobs") + return + } + + if err != nil { + logger.Errorf("Error migrating blobs: %v", err) + return + } + + // run a vacuum to reclaim space + progress.ExecuteTask("Vacuuming database", func() { + err = j.Vacuumer.Vacuum(ctx) + if err != nil { + logger.Errorf("Error vacuuming database: %v", err) + } + }) + + logger.Infof("Finished migrating blobs") +} + +func (j *MigrateBlobsJob) countBlobs(ctx context.Context) (int, error) { + var count int + if err := txn.WithReadTxn(ctx, j.TxnManager, func(ctx context.Context) error { + var err error + count, err = j.BlobStore.Count(ctx) + return err + }); err != nil { + return 0, err + } + + return count, nil +} + +func (j *MigrateBlobsJob) migrateBlobs(ctx context.Context, progress *job.Progress) error { + lastChecksum := "" + batch, err := j.getBatch(ctx, lastChecksum) + + for len(batch) > 0 && err == nil && ctx.Err() == nil { + for _, checksum := range batch { + if ctx.Err() != nil { + return nil + } + + lastChecksum = checksum + + progress.ExecuteTask("Migrating blob "+checksum, func() { + defer progress.Increment() + + if err := txn.WithTxn(ctx, j.TxnManager, func(ctx context.Context) error { + return j.BlobStore.MigrateBlob(ctx, checksum, j.DeleteOld) + }); err != nil { + logger.Errorf("Error migrating blob %s: %v", checksum, err) + } + }) + } + + batch, err = j.getBatch(ctx, lastChecksum) + } + + return err +} + +func (j *MigrateBlobsJob) getBatch(ctx context.Context, lastChecksum string) ([]string, error) { + const batchSize = 1000 + + var batch []string + err := txn.WithReadTxn(ctx, j.TxnManager, func(ctx context.Context) error { + var err error + batch, err = j.BlobStore.FindBlobs(ctx, batchSize, lastChecksum) + return err + }) + + return batch, err +} diff --git a/internal/manager/task/migrate_scene_screenshots.go b/internal/manager/task/migrate_scene_screenshots.go new file mode 100644 index 000000000..bd758391f --- /dev/null +++ b/internal/manager/task/migrate_scene_screenshots.go @@ -0,0 +1,135 @@ +package task + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/job" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/txn" +) + +type MigrateSceneScreenshotsJob struct { + ScreenshotsPath string + Input scene.MigrateSceneScreenshotsInput + SceneRepo scene.HashFinderCoverUpdater + TxnManager txn.Manager +} + +func (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.Progress) { + var err error + progress.ExecuteTask("Counting files", func() { + var count int + count, err = j.countFiles(ctx) + progress.SetTotal(count) + }) + + if err != nil { + logger.Errorf("Error counting files: %s", err.Error()) + return + } + + progress.ExecuteTask("Migrating files", func() { + err = j.migrateFiles(ctx, progress) + }) + + if job.IsCancelled(ctx) { + logger.Info("Cancelled migrating scene screenshots") + return + } + + if err != nil { + logger.Errorf("Error migrating scene screenshots: %v", err) + return + } + + logger.Infof("Finished migrating scene screenshots") +} + +func (j *MigrateSceneScreenshotsJob) countFiles(ctx context.Context) (int, error) { + f, err := os.Open(j.ScreenshotsPath) + if err != nil { + return 0, err + } + defer f.Close() + + const batchSize = 1000 + ret := 0 + files, err := f.ReadDir(batchSize) + for err == nil && ctx.Err() == nil { + ret += len(files) + + files, err = f.ReadDir(batchSize) + } + + if errors.Is(err, io.EOF) { + // end of directory + return ret, nil + } + + return 0, err +} + +func (j *MigrateSceneScreenshotsJob) migrateFiles(ctx context.Context, progress *job.Progress) error { + f, err := os.Open(j.ScreenshotsPath) + if err != nil { + return err + } + defer f.Close() + + m := scene.ScreenshotMigrator{ + Options: j.Input, + SceneUpdater: j.SceneRepo, + TxnManager: j.TxnManager, + } + + const batchSize = 1000 + files, err := f.ReadDir(batchSize) + for err == nil && ctx.Err() == nil { + for _, f := range files { + if ctx.Err() != nil { + return nil + } + + progress.ExecuteTask("Migrating file "+f.Name(), func() { + defer progress.Increment() + + path := filepath.Join(j.ScreenshotsPath, f.Name()) + + // sanity check - only process files + if f.IsDir() { + logger.Warnf("Skipping directory %s", path) + return + } + + // ignore non-jpg files + if !strings.HasSuffix(f.Name(), ".jpg") { + return + } + + // ignore .thumb files + if strings.HasSuffix(f.Name(), ".thumb.jpg") { + return + } + + if err := m.MigrateScreenshots(ctx, path); err != nil { + logger.Errorf("Error migrating screenshots for %s: %v", path, err) + } + }) + } + + files, err = f.ReadDir(batchSize) + } + + if errors.Is(err, io.EOF) { + // end of directory + return nil + } + + return err +} diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 70ae85a9c..c3b4f16f7 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -13,28 +13,28 @@ import ( "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/sliceutil/stringslice" - "github.com/stashapp/stash/pkg/utils" ) type GenerateMetadataInput struct { - Sprites *bool `json:"sprites"` - Previews *bool `json:"previews"` - ImagePreviews *bool `json:"imagePreviews"` + Covers bool `json:"covers"` + Sprites bool `json:"sprites"` + Previews bool `json:"previews"` + ImagePreviews bool `json:"imagePreviews"` PreviewOptions *GeneratePreviewOptionsInput `json:"previewOptions"` - Markers *bool `json:"markers"` - MarkerImagePreviews *bool `json:"markerImagePreviews"` - MarkerScreenshots *bool `json:"markerScreenshots"` - Transcodes *bool `json:"transcodes"` + Markers bool `json:"markers"` + MarkerImagePreviews bool `json:"markerImagePreviews"` + MarkerScreenshots bool `json:"markerScreenshots"` + Transcodes bool `json:"transcodes"` // Generate transcodes even if not required - ForceTranscodes *bool `json:"forceTranscodes"` - Phashes *bool `json:"phashes"` - InteractiveHeatmapsSpeeds *bool `json:"interactiveHeatmapsSpeeds"` + ForceTranscodes bool `json:"forceTranscodes"` + Phashes bool `json:"phashes"` + InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` // marker ids to generate for MarkerIDs []string `json:"markerIDs"` // overwrite existing media - Overwrite *bool `json:"overwrite"` + Overwrite bool `json:"overwrite"` } type GeneratePreviewOptionsInput struct { @@ -61,6 +61,7 @@ type GenerateJob struct { } type totalsGenerate struct { + covers int64 sprites int64 previews int64 imagePreviews int64 @@ -77,9 +78,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { var err error var markers []*models.SceneMarker - if j.input.Overwrite != nil { - j.overwrite = *j.input.Overwrite - } + j.overwrite = j.input.Overwrite j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm() config := config.GetInstance() @@ -143,7 +142,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { return } - logger.Infof("Generating %d sprites %d previews %d image previews %d markers %d transcodes %d phashes %d heatmaps & speeds", totals.sprites, totals.previews, totals.imagePreviews, totals.markers, totals.transcodes, totals.phashes, totals.interactiveHeatmapSpeeds) + logger.Infof("Generating %d covers %d sprites %d previews %d image previews %d markers %d transcodes %d phashes %d heatmaps & speeds", totals.covers, totals.sprites, totals.previews, totals.imagePreviews, totals.markers, totals.transcodes, totals.phashes, totals.interactiveHeatmapSpeeds) progress.SetTotal(int(totals.tasks)) }() @@ -266,7 +265,20 @@ func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generat } func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task, totals *totalsGenerate) { - if utils.IsTrue(j.input.Sprites) { + if j.input.Covers { + task := &GenerateCoverTask{ + txnManager: j.txnManager, + Scene: *scene, + } + + if j.overwrite || task.required(ctx) { + totals.covers++ + totals.tasks++ + queue <- task + } + } + + if j.input.Sprites { task := &GenerateSpriteTask{ Scene: *scene, Overwrite: j.overwrite, @@ -286,10 +298,10 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } options := getGeneratePreviewOptions(*generatePreviewOptions) - if utils.IsTrue(j.input.Previews) { + if j.input.Previews { task := &GeneratePreviewTask{ Scene: *scene, - ImagePreview: utils.IsTrue(j.input.ImagePreviews), + ImagePreview: j.input.ImagePreviews, Options: options, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, @@ -303,7 +315,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, addTask = true } - if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist()) { + if j.input.ImagePreviews && (j.overwrite || !task.doesImagePreviewExist()) { totals.imagePreviews++ addTask = true } @@ -315,14 +327,14 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } } - if utils.IsTrue(j.input.Markers) { + if j.input.Markers { task := &GenerateMarkersTask{ TxnManager: j.txnManager, Scene: scene, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, - ImagePreview: utils.IsTrue(j.input.MarkerImagePreviews), - Screenshot: utils.IsTrue(j.input.MarkerScreenshots), + ImagePreview: j.input.MarkerImagePreviews, + Screenshot: j.input.MarkerScreenshots, generator: g, } @@ -336,8 +348,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } } - if utils.IsTrue(j.input.Transcodes) { - forceTranscode := utils.IsTrue(j.input.ForceTranscodes) + if j.input.Transcodes { + forceTranscode := j.input.ForceTranscodes task := &GenerateTranscodeTask{ Scene: *scene, Overwrite: j.overwrite, @@ -352,7 +364,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } } - if utils.IsTrue(j.input.Phashes) { + if j.input.Phashes { // generate for all files in scene for _, f := range scene.Files.List() { task := &GeneratePhashTask{ @@ -371,7 +383,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } } - if utils.IsTrue(j.input.InteractiveHeatmapsSpeeds) { + if j.input.InteractiveHeatmapsSpeeds { task := &GenerateInteractiveHeatmapSpeedTask{ Scene: *scene, Overwrite: j.overwrite, diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 8b38e282a..b3ed51a78 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -3,25 +3,32 @@ package manager import ( "context" "fmt" - "io" - "os" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" ) -type GenerateScreenshotTask struct { - Scene models.Scene - ScreenshotAt *float64 - fileNamingAlgorithm models.HashAlgorithm - txnManager Repository +type GenerateCoverTask struct { + Scene models.Scene + ScreenshotAt *float64 + txnManager Repository + Overwrite bool } -func (t *GenerateScreenshotTask) Start(ctx context.Context) { +func (t *GenerateCoverTask) GetDescription() string { + return fmt.Sprintf("Generating cover for %s", t.Scene.GetTitle()) +} + +func (t *GenerateCoverTask) Start(ctx context.Context) { scenePath := t.Scene.Path + if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { + return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) + }); err != nil { + logger.Error(err) + } + videoFile := t.Scene.Files.Primary() if videoFile == nil { return @@ -34,12 +41,8 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) { at = *t.ScreenshotAt } - checksum := t.Scene.GetHash(t.fileNamingAlgorithm) - normalPath := instance.Paths.Scene.GetScreenshotPath(checksum) - // we'll generate the screenshot, grab the generated data and set it - // in the database. We'll use SetSceneScreenshot to set the data - // which also generates the thumbnail + // in the database. logger.Debugf("Creating screenshot for %s", scenePath) @@ -51,35 +54,19 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) { Overwrite: true, } - if err := g.Screenshot(context.TODO(), videoFile.Path, checksum, videoFile.Width, videoFile.Duration, generate.ScreenshotOptions{ + coverImageData, err := g.Screenshot(context.TODO(), videoFile.Path, videoFile.Width, videoFile.Duration, generate.ScreenshotOptions{ At: &at, - }); err != nil { + }) + if err != nil { logger.Errorf("Error generating screenshot: %v", err) logErrorOutput(err) return } - f, err := os.Open(normalPath) - if err != nil { - logger.Errorf("Error reading screenshot: %s", err.Error()) - return - } - defer f.Close() - - coverImageData, err := io.ReadAll(f) - if err != nil { - logger.Errorf("Error reading screenshot: %s", err.Error()) - return - } - if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { qb := t.txnManager.Scene updatedScene := models.NewScenePartial() - if err := scene.SetScreenshot(instance.Paths, checksum, coverImageData); err != nil { - return fmt.Errorf("error writing screenshot: %v", err) - } - // update the scene cover table if err := qb.UpdateCover(ctx, t.Scene.ID, coverImageData); err != nil { return fmt.Errorf("error setting screenshot: %v", err) @@ -96,3 +83,19 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) { logger.Error(err.Error()) } } + +// required returns true if the sprite needs to be generated +func (t GenerateCoverTask) required(ctx context.Context) bool { + if t.Overwrite { + return true + } + + // if the scene has a cover, then we don't need to generate it + hasCover, err := t.txnManager.Scene.HasCover(ctx, t.Scene.ID) + if err != nil { + logger.Errorf("Error getting cover: %v", err) + return false + } + + return !hasCover +} diff --git a/internal/manager/task_identify.go b/internal/manager/task_identify.go index 078e541ee..955dcb2b3 100644 --- a/internal/manager/task_identify.go +++ b/internal/manager/task_identify.go @@ -138,12 +138,8 @@ func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, source PerformerCreator: instance.Repository.Performer, TagCreator: instance.Repository.Tag, - DefaultOptions: j.input.Options, - Sources: sources, - ScreenshotSetter: &scene.PathsCoverSetter{ - Paths: instance.Paths, - FileNamingAlgorithm: instance.Config.GetVideoFileNamingAlgorithm(), - }, + DefaultOptions: j.input.Options, + Sources: sources, SceneUpdatePostHookExecutor: j.postHookExecutor, } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 1d8419dcc..6b24af27b 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -194,22 +194,22 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool { } if isVideoFile { - // check if the screenshot file exists - hash := scene.GetHash(ff, f.videoFileNamingAlgorithm) - ssPath := instance.Paths.Scene.GetScreenshotPath(hash) - if exists, _ := fsutil.FileExists(ssPath); !exists { - // if not, check if the file is a primary file for a scene - scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID) - if err != nil { - // just ignore - return false - } + // TODO - check if the cover exists + // hash := scene.GetHash(ff, f.videoFileNamingAlgorithm) + // ssPath := instance.Paths.Scene.GetScreenshotPath(hash) + // if exists, _ := fsutil.FileExists(ssPath); !exists { + // // if not, check if the file is a primary file for a scene + // scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID) + // if err != nil { + // // just ignore + // return false + // } - if len(scenes) > 0 { - // if it is, then it needs to be re-generated - return true - } - } + // if len(scenes) > 0 { + // // if it is, then it needs to be re-generated + // return true + // } + // } // clean captions - scene handler handles this as well, but // unchanged files aren't processed by the scene handler @@ -349,7 +349,6 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre CreatorUpdater: db.Scene, PluginCache: pluginCache, CaptionUpdater: db.File, - CoverGenerator: &coverGenerator{}, ScanGenerator: &sceneGenerators{ input: options, taskQueue: taskQueue, @@ -485,5 +484,17 @@ func (g *sceneGenerators) Generate(ctx context.Context, s *models.Scene, f *file } } + if t.ScanGenerateCovers { + progress.AddTotal(1) + g.taskQueue.Add(fmt.Sprintf("Generating cover for %s", path), func(ctx context.Context) { + taskCover := GenerateCoverTask{ + Scene: *s, + txnManager: instance.Repository, + } + taskCover.Start(ctx) + progress.Increment() + }) + } + return nil } diff --git a/pkg/file/clean.go b/pkg/file/clean.go index 53c1c981e..44470c5a0 100644 --- a/pkg/file/clean.go +++ b/pkg/file/clean.go @@ -380,7 +380,7 @@ func (j *cleanJob) deleteFile(ctx context.Context, fileID ID, fn string) { // delete associated objects fileDeleter := NewDeleter() if err := txn.WithTxn(ctx, j.Repository, func(ctx context.Context) error { - fileDeleter.RegisterHooks(ctx, j.Repository) + fileDeleter.RegisterHooks(ctx) if err := j.fireHandlers(ctx, fileDeleter, fileID); err != nil { return err @@ -397,7 +397,7 @@ func (j *cleanJob) deleteFolder(ctx context.Context, folderID FolderID, fn strin // delete associated objects fileDeleter := NewDeleter() if err := txn.WithTxn(ctx, j.Repository, func(ctx context.Context) error { - fileDeleter.RegisterHooks(ctx, j.Repository) + fileDeleter.RegisterHooks(ctx) if err := j.fireFolderHandlers(ctx, fileDeleter, folderID); err != nil { return err diff --git a/pkg/file/delete.go b/pkg/file/delete.go index c71d73428..4c7a621b6 100644 --- a/pkg/file/delete.go +++ b/pkg/file/delete.go @@ -69,15 +69,13 @@ func NewDeleter() *Deleter { } // RegisterHooks registers post-commit and post-rollback hooks. -func (d *Deleter) RegisterHooks(ctx context.Context, mgr txn.Manager) { - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { +func (d *Deleter) RegisterHooks(ctx context.Context) { + txn.AddPostCommitHook(ctx, func(ctx context.Context) { d.Commit() - return nil }) - txn.AddPostRollbackHook(ctx, func(ctx context.Context) error { + txn.AddPostRollbackHook(ctx, func(ctx context.Context) { d.Rollback() - return nil }) } diff --git a/pkg/file/fs.go b/pkg/file/fs.go index 0a24aaa53..09c7c7c8e 100644 --- a/pkg/file/fs.go +++ b/pkg/file/fs.go @@ -34,6 +34,26 @@ type FS interface { // OsFS is a file system backed by the OS. type OsFS struct{} +func (f *OsFS) Create(name string) (*os.File, error) { + return os.Create(name) +} + +func (f *OsFS) MkdirAll(path string, perm fs.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (f *OsFS) Remove(name string) error { + return os.Remove(name) +} + +func (f *OsFS) Rename(oldpath, newpath string) error { + return os.Rename(oldpath, newpath) +} + +func (f *OsFS) RemoveAll(path string) error { + return os.RemoveAll(path) +} + func (f *OsFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 18c1955d2..148f18691 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -508,12 +508,11 @@ func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*Folder, erro } } - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + txn.AddPostCommitHook(ctx, func(ctx context.Context) { // log at the end so that if anything fails above due to a locked database // error and the transaction must be retried, then we shouldn't get multiple // logs of the same thing. logger.Infof("%s doesn't exist. Creating new folder entry...", file.Path) - return nil }) if err := s.Repository.FolderStore.Create(ctx, toCreate); err != nil { diff --git a/pkg/image/scan.go b/pkg/image/scan.go index c8d02c26f..4c5280f6b 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -143,15 +143,13 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File if h.ScanConfig.IsGenerateThumbnails() { // do this after the commit so that the transaction isn't held up - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { // just log if cover generation fails. We can try again on rescan logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err) } } - - return nil }) } diff --git a/pkg/models/generate.go b/pkg/models/generate.go index 85685e078..2fc66248c 100644 --- a/pkg/models/generate.go +++ b/pkg/models/generate.go @@ -7,16 +7,17 @@ import ( ) type GenerateMetadataOptions struct { - Sprites *bool `json:"sprites"` - Previews *bool `json:"previews"` - ImagePreviews *bool `json:"imagePreviews"` + Covers bool `json:"covers"` + Sprites bool `json:"sprites"` + Previews bool `json:"previews"` + ImagePreviews bool `json:"imagePreviews"` PreviewOptions *GeneratePreviewOptions `json:"previewOptions"` - Markers *bool `json:"markers"` - MarkerImagePreviews *bool `json:"markerImagePreviews"` - MarkerScreenshots *bool `json:"markerScreenshots"` - Transcodes *bool `json:"transcodes"` - Phashes *bool `json:"phashes"` - InteractiveHeatmapsSpeeds *bool `json:"interactiveHeatmapsSpeeds"` + Markers bool `json:"markers"` + MarkerImagePreviews bool `json:"markerImagePreviews"` + MarkerScreenshots bool `json:"markerScreenshots"` + Transcodes bool `json:"transcodes"` + Phashes bool `json:"phashes"` + InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` } type GeneratePreviewOptions struct { diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index c125fc7b1..ad2171a66 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -137,20 +137,6 @@ func (_m *MovieReaderWriter) Destroy(ctx context.Context, id int) error { return r0 } -// DestroyImages provides a mock function with given fields: ctx, movieID -func (_m *MovieReaderWriter) DestroyImages(ctx context.Context, movieID int) error { - ret := _m.Called(ctx, movieID) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { - r0 = rf(ctx, movieID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Find provides a mock function with given fields: ctx, id func (_m *MovieReaderWriter) Find(ctx context.Context, id int) (*models.Movie, error) { ret := _m.Called(ctx, id) @@ -388,6 +374,34 @@ func (_m *MovieReaderWriter) Update(ctx context.Context, updatedMovie models.Mov return r0, r1 } +// UpdateBackImage provides a mock function with given fields: ctx, movieID, backImage +func (_m *MovieReaderWriter) UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error { + ret := _m.Called(ctx, movieID, backImage) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { + r0 = rf(ctx, movieID, backImage) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateFrontImage provides a mock function with given fields: ctx, movieID, frontImage +func (_m *MovieReaderWriter) UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error { + ret := _m.Called(ctx, movieID, frontImage) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { + r0 = rf(ctx, movieID, frontImage) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateFull provides a mock function with given fields: ctx, updatedMovie func (_m *MovieReaderWriter) UpdateFull(ctx context.Context, updatedMovie models.Movie) (*models.Movie, error) { ret := _m.Called(ctx, updatedMovie) @@ -410,17 +424,3 @@ func (_m *MovieReaderWriter) UpdateFull(ctx context.Context, updatedMovie models return r0, r1 } - -// UpdateImages provides a mock function with given fields: ctx, movieID, frontImage, backImage -func (_m *MovieReaderWriter) UpdateImages(ctx context.Context, movieID int, frontImage []byte, backImage []byte) error { - ret := _m.Called(ctx, movieID, frontImage, backImage) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int, []byte, []byte) error); ok { - r0 = rf(ctx, movieID, frontImage, backImage) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index bb6e6d4a5..5f7191827 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -235,20 +235,6 @@ func (_m *SceneReaderWriter) Destroy(ctx context.Context, id int) error { return r0 } -// DestroyCover provides a mock function with given fields: ctx, sceneID -func (_m *SceneReaderWriter) DestroyCover(ctx context.Context, sceneID int) error { - ret := _m.Called(ctx, sceneID) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { - r0 = rf(ctx, sceneID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Duration provides a mock function with given fields: ctx func (_m *SceneReaderWriter) Duration(ctx context.Context) (float64, error) { ret := _m.Called(ctx) @@ -638,6 +624,27 @@ func (_m *SceneReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]in return r0, r1 } +// HasCover provides a mock function with given fields: ctx, sceneID +func (_m *SceneReaderWriter) HasCover(ctx context.Context, sceneID int) (bool, error) { + ret := _m.Called(ctx, sceneID) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { + r0 = rf(ctx, sceneID) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, sceneID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // IncrementOCounter provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index 043bfdecc..8868efcc8 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -95,20 +95,6 @@ func (_m *StudioReaderWriter) Destroy(ctx context.Context, id int) error { return r0 } -// DestroyImage provides a mock function with given fields: ctx, studioID -func (_m *StudioReaderWriter) DestroyImage(ctx context.Context, studioID int) error { - ret := _m.Called(ctx, studioID) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { - r0 = rf(ctx, studioID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Find provides a mock function with given fields: ctx, id func (_m *StudioReaderWriter) Find(ctx context.Context, id int) (*models.Studio, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 1a53adf05..d67f4f00e 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -95,20 +95,6 @@ func (_m *TagReaderWriter) Destroy(ctx context.Context, id int) error { return r0 } -// DestroyImage provides a mock function with given fields: ctx, tagID -func (_m *TagReaderWriter) DestroyImage(ctx context.Context, tagID int) error { - ret := _m.Called(ctx, tagID) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { - r0 = rf(ctx, tagID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Find provides a mock function with given fields: ctx, id func (_m *TagReaderWriter) Find(ctx context.Context, id int) (*models.Tag, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index 756a6c936..00b87ad0f 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -22,6 +22,10 @@ type Movie struct { URL sql.NullString `db:"url" json:"url"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + + // TODO - this is only here because of database code in the models package + FrontImageBlob sql.NullString `db:"front_image_blob" json:"-"` + BackImageBlob sql.NullString `db:"back_image_blob" json:"-"` } type MoviePartial struct { diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 51a8d332c..989415293 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -19,6 +19,8 @@ type Studio struct { Rating sql.NullInt64 `db:"rating" json:"rating"` Details sql.NullString `db:"details" json:"details"` IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` + // TODO - this is only here because of database code in the models package + ImageBlob sql.NullString `db:"image_blob" json:"-"` } type StudioPartial struct { diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index 043f84bd6..b12574155 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -6,12 +6,14 @@ import ( ) type Tag struct { - ID int `db:"id" json:"id"` - Name string `db:"name" json:"name"` // TODO make schema not null - Description sql.NullString `db:"description" json:"description"` - IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` - CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` - UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` // TODO make schema not null + Description sql.NullString `db:"description" json:"description"` + IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` + // TODO - this is only here because of database code in the models package + ImageBlob sql.NullString `db:"image_blob" json:"-"` + CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` + UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` } type TagPartial struct { diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 8d58d70dd..adaa4fe03 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -50,8 +50,8 @@ type MovieWriter interface { Update(ctx context.Context, updatedMovie MoviePartial) (*Movie, error) UpdateFull(ctx context.Context, updatedMovie Movie) (*Movie, error) Destroy(ctx context.Context, id int) error - UpdateImages(ctx context.Context, movieID int, frontImage []byte, backImage []byte) error - DestroyImages(ctx context.Context, movieID int) error + UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error + UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error } type MovieReaderWriter interface { diff --git a/pkg/models/paths/paths.go b/pkg/models/paths/paths.go index 612d5e2b7..ed35bca56 100644 --- a/pkg/models/paths/paths.go +++ b/pkg/models/paths/paths.go @@ -11,14 +11,17 @@ type Paths struct { Scene *scenePaths SceneMarkers *sceneMarkerPaths + Blobs string } -func NewPaths(generatedPath string) Paths { +func NewPaths(generatedPath string, blobsPath string) Paths { p := Paths{} p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) p.SceneMarkers = newSceneMarkerPaths(p) + p.Blobs = blobsPath + return p } diff --git a/pkg/models/paths/paths_scenes.go b/pkg/models/paths/paths_scenes.go index d54fbfb59..003e63304 100644 --- a/pkg/models/paths/paths_scenes.go +++ b/pkg/models/paths/paths_scenes.go @@ -17,14 +17,10 @@ func newScenePaths(p Paths) *scenePaths { return &sp } -func (sp *scenePaths) GetScreenshotPath(checksum string) string { +func (sp *scenePaths) GetLegacyScreenshotPath(checksum string) string { return filepath.Join(sp.Screenshots, checksum+".jpg") } -func (sp *scenePaths) GetThumbnailScreenshotPath(checksum string) string { - return filepath.Join(sp.Screenshots, checksum+".thumb.jpg") -} - func (sp *scenePaths) GetTranscodePath(checksum string) string { return filepath.Join(sp.Transcodes, checksum+".mp4") } diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 3d6842943..55a27606a 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -176,6 +176,7 @@ type SceneReader interface { All(ctx context.Context) ([]*Scene, error) Query(ctx context.Context, options SceneQueryOptions) (*SceneQueryResult, error) GetCover(ctx context.Context, sceneID int) ([]byte, error) + HasCover(ctx context.Context, sceneID int) (bool, error) } type SceneWriter interface { @@ -189,7 +190,6 @@ type SceneWriter interface { IncrementWatchCount(ctx context.Context, id int) (int, error) Destroy(ctx context.Context, id int) error UpdateCover(ctx context.Context, sceneID int, cover []byte) error - DestroyCover(ctx context.Context, sceneID int) error } type SceneReaderWriter interface { diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 26443edf7..7ccf33be0 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -66,7 +66,6 @@ type StudioWriter interface { UpdateFull(ctx context.Context, updatedStudio Studio) (*Studio, error) Destroy(ctx context.Context, id int) error UpdateImage(ctx context.Context, studioID int, image []byte) error - DestroyImage(ctx context.Context, studioID int) error UpdateStashIDs(ctx context.Context, studioID int, stashIDs []StashID) error UpdateAliases(ctx context.Context, studioID int, aliases []string) error } diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 440d147d3..601dfcc16 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -74,7 +74,6 @@ type TagWriter interface { UpdateFull(ctx context.Context, updatedTag Tag) (*Tag, error) Destroy(ctx context.Context, id int) error UpdateImage(ctx context.Context, tagID int, image []byte) error - DestroyImage(ctx context.Context, tagID int) error UpdateAliases(ctx context.Context, tagID int, aliases []string) error Merge(ctx context.Context, source []int, destination int) error UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 461df0f84..75bc28d4a 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -12,10 +12,15 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +type ImageUpdater interface { + UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error + UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error +} + type NameFinderCreatorUpdater interface { NameFinderCreator UpdateFull(ctx context.Context, updatedMovie models.Movie) (*models.Movie, error) - UpdateImages(ctx context.Context, movieID int, frontImage []byte, backImage []byte) error + ImageUpdater } type Importer struct { @@ -126,8 +131,14 @@ func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { func (i *Importer) PostImport(ctx context.Context, id int) error { if len(i.frontImageData) > 0 { - if err := i.ReaderWriter.UpdateImages(ctx, id, i.frontImageData, i.backImageData); err != nil { - return fmt.Errorf("error setting movie images: %v", err) + if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { + return fmt.Errorf("error setting movie front image: %v", err) + } + } + + if len(i.backImageData) > 0 { + if err := i.ReaderWriter.UpdateBackImage(ctx, id, i.backImageData); err != nil { + return fmt.Errorf("error setting movie back image: %v", err) } } diff --git a/pkg/movie/import_test.go b/pkg/movie/import_test.go index 26b6d9f27..c33d4baa2 100644 --- a/pkg/movie/import_test.go +++ b/pkg/movie/import_test.go @@ -162,8 +162,9 @@ func TestImporterPostImport(t *testing.T) { updateMovieImageErr := errors.New("UpdateImages error") - readerWriter.On("UpdateImages", testCtx, movieID, frontImageBytes, backImageBytes).Return(nil).Once() - readerWriter.On("UpdateImages", testCtx, errImageID, frontImageBytes, backImageBytes).Return(updateMovieImageErr).Once() + readerWriter.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() + readerWriter.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() + readerWriter.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() err := i.PostImport(testCtx, movieID) assert.Nil(t, err) diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 6cfb27bcd..8e9354d0b 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -210,9 +210,8 @@ func (c Cache) ExecutePostHooks(ctx context.Context, id int, hookType HookTrigge } func (c Cache) RegisterPostHooks(ctx context.Context, id int, hookType HookTriggerEnum, input interface{}, inputFields []string) { - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + txn.AddPostCommitHook(ctx, func(ctx context.Context) { c.ExecutePostHooks(ctx, id, hookType, input, inputFields) - return nil }) } diff --git a/pkg/scene/create.go b/pkg/scene/create.go index 83fd5e56c..c2345d2ef 100644 --- a/pkg/scene/create.go +++ b/pkg/scene/create.go @@ -7,10 +7,8 @@ import ( "time" "github.com/stashapp/stash/pkg/file" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" - "github.com/stashapp/stash/pkg/txn" ) func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error) { @@ -55,18 +53,6 @@ func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []fil if err := s.Repository.UpdateCover(ctx, ret.ID, coverImage); err != nil { return nil, fmt.Errorf("setting cover on new scene: %w", err) } - - // only update the cover image if provided and everything else was successful - // only do this if there is a file associated - if len(fileIDs) > 0 { - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { - if err := SetScreenshot(s.Paths, ret.GetHash(s.Config.GetVideoFileNamingAlgorithm()), coverImage); err != nil { - logger.Errorf("Error setting screenshot: %v", err) - } - - return nil - }) - } } s.PluginCache.RegisterPostHooks(ctx, ret.ID, plugin.SceneCreatePost, nil, nil) diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index 622b54377..f7fdda0c9 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -38,18 +38,6 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { var files []string - thumbPath := d.Paths.Scene.GetThumbnailScreenshotPath(sceneHash) - exists, _ = fsutil.FileExists(thumbPath) - if exists { - files = append(files, thumbPath) - } - - normalPath := d.Paths.Scene.GetScreenshotPath(sceneHash) - exists, _ = fsutil.FileExists(normalPath) - if exists { - files = append(files, normalPath) - } - streamPreviewPath := d.Paths.Scene.GetVideoPreviewPath(sceneHash) exists, _ = fsutil.FileExists(streamPreviewPath) if exists { diff --git a/pkg/scene/generate/generator.go b/pkg/scene/generate/generator.go index 000082414..49568fb2a 100644 --- a/pkg/scene/generate/generator.go +++ b/pkg/scene/generate/generator.go @@ -38,9 +38,6 @@ type ScenePaths interface { GetVideoPreviewPath(checksum string) string GetWebpPreviewPath(checksum string) string - GetScreenshotPath(checksum string) string - GetThumbnailScreenshotPath(checksum string) string - GetSpriteImageFilePath(checksum string) string GetSpriteVttFilePath(checksum string) string @@ -106,6 +103,26 @@ func (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern st return nil } +// generateBytes performs a generate operation by generating a temporary file using p and pattern, returns the contents, then deletes it. +func (g Generator) generateBytes(lockCtx *fsutil.LockContext, p Paths, pattern string, generateFn generateFn) ([]byte, error) { + tmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly + if err != nil { + return nil, err + } + + tmpFn := tmpFile.Name() + defer func() { + _ = os.Remove(tmpFn) + }() + + if err := generateFn(lockCtx, tmpFn); err != nil { + return nil, err + } + + defer os.Remove(tmpFn) + return os.ReadFile(tmpFn) +} + // generate runs ffmpeg with the given args and waits for it to finish. // Returns an error if the command fails. If the command fails, the return // value will be of type *exec.ExitError. diff --git a/pkg/scene/generate/screenshot.go b/pkg/scene/generate/screenshot.go index 41ecc8fe8..917a481ef 100644 --- a/pkg/scene/generate/screenshot.go +++ b/pkg/scene/generate/screenshot.go @@ -9,8 +9,8 @@ import ( ) const ( - thumbnailWidth = 320 - thumbnailQuality = 5 + // thumbnailWidth = 320 + // thumbnailQuality = 5 screenshotQuality = 2 @@ -21,17 +21,10 @@ type ScreenshotOptions struct { At *float64 } -func (g Generator) Screenshot(ctx context.Context, input string, hash string, videoWidth int, videoDuration float64, options ScreenshotOptions) error { +func (g Generator) Screenshot(ctx context.Context, input string, videoWidth int, videoDuration float64, options ScreenshotOptions) ([]byte, error) { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() - output := g.ScenePaths.GetScreenshotPath(hash) - if !g.Overwrite { - if exists, _ := fsutil.FileExists(output); exists { - return nil - } - } - logger.Infof("Creating screenshot for %s", input) at := screenshotDurationProportion * videoDuration @@ -39,46 +32,16 @@ func (g Generator) Screenshot(ctx context.Context, input string, hash string, vi at = *options.At } - if err := g.generateFile(lockCtx, g.ScenePaths, jpgPattern, output, g.screenshot(input, screenshotOptions{ + ret, err := g.generateBytes(lockCtx, g.ScenePaths, jpgPattern, g.screenshot(input, screenshotOptions{ Time: at, Quality: screenshotQuality, // default Width is video width - })); err != nil { - return err + })) + if err != nil { + return nil, err } - logger.Debug("created screenshot: ", output) - - return nil -} - -func (g Generator) Thumbnail(ctx context.Context, input string, hash string, videoDuration float64, options ScreenshotOptions) error { - lockCtx := g.LockManager.ReadLock(ctx, input) - defer lockCtx.Cancel() - - output := g.ScenePaths.GetThumbnailScreenshotPath(hash) - if !g.Overwrite { - if exists, _ := fsutil.FileExists(output); exists { - return nil - } - } - - at := screenshotDurationProportion * videoDuration - if options.At != nil { - at = *options.At - } - - if err := g.generateFile(lockCtx, g.ScenePaths, jpgPattern, output, g.screenshot(input, screenshotOptions{ - Time: at, - Quality: thumbnailQuality, - Width: thumbnailWidth, - })); err != nil { - return err - } - - logger.Debug("created thumbnail: ", output) - - return nil + return ret, nil } type screenshotOptions struct { diff --git a/pkg/scene/merge.go b/pkg/scene/merge.go index d14f9621f..238d5233c 100644 --- a/pkg/scene/merge.go +++ b/pkg/scene/merge.go @@ -123,7 +123,7 @@ func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src } if len(toRename) > 0 { - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + txn.AddPostCommitHook(ctx, func(ctx context.Context) { // rename the files if they exist for _, e := range toRename { srcExists, _ := fsutil.FileExists(e.src) @@ -135,8 +135,6 @@ func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src } } } - - return nil }) } diff --git a/pkg/scene/migrate_hash.go b/pkg/scene/migrate_hash.go index 7f39d5370..09b25297f 100644 --- a/pkg/scene/migrate_hash.go +++ b/pkg/scene/migrate_hash.go @@ -16,14 +16,6 @@ func MigrateHash(p *paths.Paths, oldHash string, newHash string) { migrateSceneFiles(oldPath, newPath) scenePaths := p.Scene - oldPath = scenePaths.GetThumbnailScreenshotPath(oldHash) - newPath = scenePaths.GetThumbnailScreenshotPath(newHash) - migrateSceneFiles(oldPath, newPath) - - oldPath = scenePaths.GetScreenshotPath(oldHash) - newPath = scenePaths.GetScreenshotPath(newHash) - migrateSceneFiles(oldPath, newPath) - oldPath = scenePaths.GetVideoPreviewPath(oldHash) newPath = scenePaths.GetVideoPreviewPath(newHash) migrateSceneFiles(oldPath, newPath) diff --git a/pkg/scene/migrate_screenshots.go b/pkg/scene/migrate_screenshots.go new file mode 100644 index 000000000..94d73643f --- /dev/null +++ b/pkg/scene/migrate_screenshots.go @@ -0,0 +1,143 @@ +package scene + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/txn" +) + +type MigrateSceneScreenshotsInput struct { + DeleteFiles bool `json:"deleteFiles"` + OverwriteExisting bool `json:"overwriteExisting"` +} + +type HashFinderCoverUpdater interface { + FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) + FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) + CoverUpdater +} + +type ScreenshotMigrator struct { + Options MigrateSceneScreenshotsInput + SceneUpdater HashFinderCoverUpdater + TxnManager txn.Manager +} + +func (m *ScreenshotMigrator) MigrateScreenshots(ctx context.Context, screenshotPath string) error { + // find the scene based on the screenshot path + s, err := m.findScenes(ctx, screenshotPath) + if err != nil { + return fmt.Errorf("finding scenes for screenshot: %w", err) + } + + for _, scene := range s { + // migrate each scene in its own transaction + if err := txn.WithTxn(ctx, m.TxnManager, func(ctx context.Context) error { + return m.migrateSceneScreenshot(ctx, scene, screenshotPath) + }); err != nil { + return fmt.Errorf("migrating screenshot for scene %s: %w", scene.DisplayName(), err) + } + } + + // if deleteFiles is true, delete the file + if m.Options.DeleteFiles { + if err := os.Remove(screenshotPath); err != nil { + // log and continue + logger.Errorf("Error deleting screenshot file %s: %v", screenshotPath, err) + } else { + logger.Debugf("Deleted screenshot file %s", screenshotPath) + } + + // also delete the thumb file + thumbPath := strings.TrimSuffix(screenshotPath, ".jpg") + ".thumb.jpg" + // ignore errors for thumb files + if err := os.Remove(thumbPath); err == nil { + logger.Debugf("Deleted thumb file %s", thumbPath) + } + } + + return nil +} + +func (m *ScreenshotMigrator) findScenes(ctx context.Context, screenshotPath string) ([]*models.Scene, error) { + basename := filepath.Base(screenshotPath) + ext := filepath.Ext(basename) + basename = basename[:len(basename)-len(ext)] + + // use the basename to determine the hash type + algo := m.getHashType(basename) + + if algo == "" { + // log and return + return nil, fmt.Errorf("could not determine hash type") + } + + // use the hash type to get the scene + var ret []*models.Scene + err := txn.WithReadTxn(ctx, m.TxnManager, func(ctx context.Context) error { + var err error + + if algo == models.HashAlgorithmOshash { + // use oshash + ret, err = m.SceneUpdater.FindByOSHash(ctx, basename) + } else { + // use md5 + ret, err = m.SceneUpdater.FindByChecksum(ctx, basename) + } + + return err + }) + + return ret, err +} + +func (m *ScreenshotMigrator) getHashType(basename string) models.HashAlgorithm { + // if the basename is 16 characters long, must be oshash + if len(basename) == 16 { + return models.HashAlgorithmOshash + } + + // if its 32 characters long, must be md5 + if len(basename) == 32 { + return models.HashAlgorithmMd5 + } + + // otherwise, it's undefined + return "" +} + +func (m *ScreenshotMigrator) migrateSceneScreenshot(ctx context.Context, scene *models.Scene, screenshotPath string) error { + if !m.Options.OverwriteExisting { + // check if the scene has a cover already + hasCover, err := m.SceneUpdater.HasCover(ctx, scene.ID) + if err != nil { + return fmt.Errorf("checking for existing cover: %w", err) + } + + if hasCover { + // already has cover, just silently return + logger.Debugf("Scene %s already has a screenshot, skipping", scene.DisplayName()) + return nil + } + } + + // get the data from the file + data, err := os.ReadFile(screenshotPath) + if err != nil { + return fmt.Errorf("reading screenshot file: %w", err) + } + + if err := m.SceneUpdater.UpdateCover(ctx, scene.ID, data); err != nil { + return fmt.Errorf("updating scene screenshot: %w", err) + } + + logger.Infof("Updated screenshot for scene %s from %s", scene.DisplayName(), filepath.Base(screenshotPath)) + + return nil +} diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index 8abbfc2d3..5ccdee256 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -35,7 +35,6 @@ type ScanGenerator interface { type ScanHandler struct { CreatorUpdater CreatorUpdater - CoverGenerator CoverGenerator ScanGenerator ScanGenerator CaptionUpdater video.CaptionUpdater PluginCache *plugin.Cache @@ -48,9 +47,6 @@ func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } - if h.CoverGenerator == nil { - return errors.New("CoverGenerator is required") - } if h.ScanGenerator == nil { return errors.New("ScanGenerator is required") } @@ -132,20 +128,13 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File } // do this after the commit so that cover generation doesn't hold up the transaction - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { - if err := h.CoverGenerator.GenerateCover(ctx, s, videoFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating cover for %s: %v", videoFile.Path, err) - } - if err := h.ScanGenerator.Generate(ctx, s, videoFile); err != nil { // just log if cover generation fails. We can try again on rescan logger.Errorf("Error generating content for %s: %v", videoFile.Path, err) } } - - return nil }) return nil diff --git a/pkg/scene/screenshot.go b/pkg/scene/screenshot.go deleted file mode 100644 index c33203327..000000000 --- a/pkg/scene/screenshot.go +++ /dev/null @@ -1,103 +0,0 @@ -package scene - -import ( - "bytes" - "context" - "image" - "image/jpeg" - "os" - - "github.com/stashapp/stash/pkg/file" - "github.com/stashapp/stash/pkg/fsutil" - "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/models/paths" - - "github.com/disintegration/imaging" - - // needed to decode other image formats - _ "image/gif" - _ "image/png" -) - -type CoverGenerator interface { - GenerateCover(ctx context.Context, scene *models.Scene, f *file.VideoFile) error -} - -type ScreenshotSetter interface { - SetScreenshot(scene *models.Scene, imageData []byte) error -} - -type PathsCoverSetter struct { - Paths *paths.Paths - FileNamingAlgorithm models.HashAlgorithm -} - -func (ss *PathsCoverSetter) SetScreenshot(scene *models.Scene, imageData []byte) error { - // don't set where scene has no file - if scene.Path == "" { - return nil - } - checksum := scene.GetHash(ss.FileNamingAlgorithm) - return SetScreenshot(ss.Paths, checksum, imageData) -} - -func writeImage(path string, imageData []byte) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - _, err = f.Write(imageData) - return err -} - -func writeThumbnail(path string, thumbnail image.Image) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - return jpeg.Encode(f, thumbnail, nil) -} - -func SetScreenshot(paths *paths.Paths, checksum string, imageData []byte) error { - thumbPath := paths.Scene.GetThumbnailScreenshotPath(checksum) - normalPath := paths.Scene.GetScreenshotPath(checksum) - - img, _, err := image.Decode(bytes.NewReader(imageData)) - if err != nil { - return err - } - - // resize to 320 width maintaining aspect ratio, for the thumbnail - const width = 320 - origWidth := img.Bounds().Max.X - origHeight := img.Bounds().Max.Y - height := width / origWidth * origHeight - - thumbnail := imaging.Resize(img, width, height, imaging.Lanczos) - err = writeThumbnail(thumbPath, thumbnail) - if err != nil { - return err - } - - err = writeImage(normalPath, imageData) - - return err -} - -func (s *Service) GetCover(ctx context.Context, scene *models.Scene) ([]byte, error) { - if scene.Path != "" { - filepath := s.Paths.Scene.GetScreenshotPath(scene.GetHash(s.Config.GetVideoFileNamingAlgorithm())) - - // fall back to the scene image blob if the file isn't present - screenshotExists, _ := fsutil.FileExists(filepath) - if screenshotExists { - return os.ReadFile(filepath) - } - } - - return s.Repository.GetCover(ctx, scene.ID) -} diff --git a/pkg/scene/service.go b/pkg/scene/service.go index c162858af..a3d01dd3d 100644 --- a/pkg/scene/service.go +++ b/pkg/scene/service.go @@ -22,6 +22,7 @@ type Creator interface { } type CoverUpdater interface { + HasCover(ctx context.Context, sceneID int) (bool, error) UpdateCover(ctx context.Context, sceneID int, cover []byte) error } diff --git a/pkg/scene/update.go b/pkg/scene/update.go index c38597da7..e3f3e252b 100644 --- a/pkg/scene/update.go +++ b/pkg/scene/update.go @@ -46,7 +46,7 @@ func (u *UpdateSet) IsEmpty() bool { // Update updates a scene by updating the fields in the Partial field, then // updates non-nil relationships. Returns an error if there is no work to // be done. -func (u *UpdateSet) Update(ctx context.Context, qb Updater, screenshotSetter ScreenshotSetter) (*models.Scene, error) { +func (u *UpdateSet) Update(ctx context.Context, qb Updater) (*models.Scene, error) { if u.IsEmpty() { return nil, ErrEmptyUpdater } @@ -64,10 +64,6 @@ func (u *UpdateSet) Update(ctx context.Context, qb Updater, screenshotSetter Scr if err := qb.UpdateCover(ctx, u.ID, u.CoverImage); err != nil { return nil, fmt.Errorf("error updating scene cover: %w", err) } - - if err := screenshotSetter.SetScreenshot(ret, u.CoverImage); err != nil { - return nil, fmt.Errorf("error setting scene screenshot: %w", err) - } } return ret, nil diff --git a/pkg/scene/update_test.go b/pkg/scene/update_test.go index ffd84f00c..c89be66f3 100644 --- a/pkg/scene/update_test.go +++ b/pkg/scene/update_test.go @@ -93,12 +93,6 @@ func TestUpdater_IsEmpty(t *testing.T) { } } -type mockScreenshotSetter struct{} - -func (s *mockScreenshotSetter) SetScreenshot(scene *models.Scene, imageData []byte) error { - return nil -} - func TestUpdater_Update(t *testing.T) { const ( sceneID = iota + 1 @@ -210,7 +204,7 @@ func TestUpdater_Update(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.u.Update(ctx, &qb, &mockScreenshotSetter{}) + got, err := tt.u.Update(ctx, &qb) if (err != nil) != tt.wantErr { t.Errorf("Updater.Update() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 8265d8050..9d8e65d0c 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -932,7 +932,7 @@ func (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, endpo } draft.Tags = tags - if cover != nil { + if len(cover) > 0 { image = bytes.NewReader(cover) } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index abf919f15..c16d1160d 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -70,6 +70,11 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { return nil } +func (db *Anonymiser) truncateColumn(tableName string, column string) error { + _, err := db.db.Exec("UPDATE " + tableName + " SET " + column + " = NULL") + return err +} + func (db *Anonymiser) truncateTable(tableName string) error { _, err := db.db.Exec("DELETE FROM " + tableName) return err @@ -77,11 +82,14 @@ func (db *Anonymiser) truncateTable(tableName string) error { func (db *Anonymiser) deleteBlobs() error { return utils.Do([]func() error{ - func() error { return db.truncateTable("scenes_cover") }, - func() error { return db.truncateTable("movies_images") }, - func() error { return db.truncateTable("performers_image") }, - func() error { return db.truncateTable("studios_image") }, - func() error { return db.truncateTable("tags_image") }, + func() error { return db.truncateColumn("tags", "image_blob") }, + func() error { return db.truncateColumn("studios", "image_blob") }, + func() error { return db.truncateColumn("performers", "image_blob") }, + func() error { return db.truncateColumn("scenes", "cover_blob") }, + func() error { return db.truncateColumn("movies", "front_image_blob") }, + func() error { return db.truncateColumn("movies", "back_image_blob") }, + + func() error { return db.truncateTable("blobs") }, }) } diff --git a/pkg/sqlite/blob.go b/pkg/sqlite/blob.go new file mode 100644 index 000000000..96c597fa5 --- /dev/null +++ b/pkg/sqlite/blob.go @@ -0,0 +1,382 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/jmoiron/sqlx" + "github.com/mattn/go-sqlite3" + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/hash/md5" + "github.com/stashapp/stash/pkg/sqlite/blob" + "github.com/stashapp/stash/pkg/utils" + "gopkg.in/guregu/null.v4" +) + +const ( + blobTable = "blobs" + blobChecksumColumn = "checksum" +) + +type BlobStoreOptions struct { + // UseFilesystem should be true if blob data should be stored in the filesystem + UseFilesystem bool + // UseDatabase should be true if blob data should be stored in the database + UseDatabase bool + // Path is the filesystem path to use for storing blobs + Path string +} + +type BlobStore struct { + repository + + tableMgr *table + + fsStore *blob.FilesystemStore + options BlobStoreOptions +} + +func NewBlobStore(options BlobStoreOptions) *BlobStore { + return &BlobStore{ + repository: repository{ + tableName: blobTable, + idColumn: blobChecksumColumn, + }, + + tableMgr: blobTableMgr, + + fsStore: blob.NewFilesystemStore(options.Path, &file.OsFS{}), + options: options, + } +} + +type blobRow struct { + Checksum string `db:"checksum"` + Blob []byte `db:"blob"` +} + +func (qb *BlobStore) table() exp.IdentifierExpression { + return qb.tableMgr.table +} + +func (qb *BlobStore) Count(ctx context.Context) (int, error) { + table := qb.table() + q := dialect.From(table).Select(goqu.COUNT(table.Col(blobChecksumColumn))) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +// Write stores the data and its checksum in enabled stores. +// Always writes at least the checksum to the database. +func (qb *BlobStore) Write(ctx context.Context, data []byte) (string, error) { + if !qb.options.UseDatabase && !qb.options.UseFilesystem { + panic("no blob store configured") + } + + if len(data) == 0 { + return "", fmt.Errorf("cannot write empty data") + } + + checksum := md5.FromBytes(data) + + // only write blob to the database if UseDatabase is true + // always at least write the checksum + var storedData []byte + if qb.options.UseDatabase { + storedData = data + } + + if err := qb.write(ctx, checksum, storedData); err != nil { + return "", fmt.Errorf("writing to database: %w", err) + } + + if qb.options.UseFilesystem { + if err := qb.fsStore.Write(ctx, checksum, data); err != nil { + return "", fmt.Errorf("writing to filesystem: %w", err) + } + } + + return checksum, nil +} + +func (qb *BlobStore) write(ctx context.Context, checksum string, data []byte) error { + table := qb.table() + q := dialect.Insert(table).Prepared(true).Rows(blobRow{ + Checksum: checksum, + Blob: data, + }).OnConflict(goqu.DoNothing()) + + _, err := exec(ctx, q) + if err != nil { + return fmt.Errorf("inserting into %s: %w", table, err) + } + + return nil +} + +func (qb *BlobStore) update(ctx context.Context, checksum string, data []byte) error { + table := qb.table() + q := dialect.Update(table).Prepared(true).Set(goqu.Record{ + "blob": data, + }).Where(goqu.C(blobChecksumColumn).Eq(checksum)) + + _, err := exec(ctx, q) + if err != nil { + return fmt.Errorf("updating %s: %w", table, err) + } + + return nil +} + +type ChecksumNotFoundError struct { + Checksum string +} + +func (e *ChecksumNotFoundError) Error() string { + return fmt.Sprintf("checksum %s does not exist", e.Checksum) +} + +type ChecksumBlobNotExistError struct { + Checksum string +} + +func (e *ChecksumBlobNotExistError) Error() string { + return fmt.Sprintf("blob for checksum %s does not exist", e.Checksum) +} + +func (qb *BlobStore) readSQL(ctx context.Context, querySQL string, args ...interface{}) ([]byte, string, error) { + if !qb.options.UseDatabase && !qb.options.UseFilesystem { + panic("no blob store configured") + } + + // always try to get from the database first, even if set to use filesystem + var row blobRow + found := false + const single = true + if err := qb.queryFunc(ctx, querySQL, args, single, func(r *sqlx.Rows) error { + found = true + if err := r.StructScan(&row); err != nil { + return err + } + + return nil + }); err != nil { + return nil, "", fmt.Errorf("reading from database: %w", err) + } + + if !found { + // not found in the database - does not exist + return nil, "", nil + } + + checksum := row.Checksum + + if row.Blob != nil { + return row.Blob, checksum, nil + } + + // don't use the filesystem if not configured to do so + if qb.options.UseFilesystem { + ret, err := qb.fsStore.Read(ctx, checksum) + if err == nil { + return ret, checksum, nil + } + + if !errors.Is(err, fs.ErrNotExist) { + return nil, checksum, fmt.Errorf("reading from filesystem: %w", err) + } + } + + return nil, checksum, &ChecksumBlobNotExistError{ + Checksum: checksum, + } +} + +// Read reads the data from the database or filesystem, depending on which is enabled. +func (qb *BlobStore) Read(ctx context.Context, checksum string) ([]byte, error) { + if !qb.options.UseDatabase && !qb.options.UseFilesystem { + panic("no blob store configured") + } + + // always try to get from the database first, even if set to use filesystem + ret, err := qb.readFromDatabase(ctx, checksum) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("reading from database: %w", err) + } + + // not found in the database - does not exist + return nil, &ChecksumNotFoundError{ + Checksum: checksum, + } + } + + if ret != nil { + return ret, nil + } + + // don't use the filesystem if not configured to do so + if qb.options.UseFilesystem { + ret, err := qb.fsStore.Read(ctx, checksum) + if err == nil { + return ret, nil + } + + if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("reading from filesystem: %w", err) + } + } + + // blob not found - should not happen + return nil, &ChecksumBlobNotExistError{ + Checksum: checksum, + } +} + +func (qb *BlobStore) readFromDatabase(ctx context.Context, checksum string) ([]byte, error) { + q := dialect.From(qb.table()).Select(qb.table().All()).Where(qb.tableMgr.byID(checksum)) + + var row blobRow + const single = true + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + if err := r.StructScan(&row); err != nil { + return err + } + + return nil + }); err != nil { + return nil, fmt.Errorf("querying %s: %w", qb.table(), err) + } + + return row.Blob, nil +} + +// Delete marks a checksum as no longer in use by a single reference. +// If no references remain, the blob is deleted from the database and filesystem. +func (qb *BlobStore) Delete(ctx context.Context, checksum string) error { + // try to delete the blob from the database + if err := qb.delete(ctx, checksum); err != nil { + if qb.isConstraintError(err) { + // blob is still referenced - do not delete + return nil + } + + // unexpected error + return fmt.Errorf("deleting from database: %w", err) + } + + // blob was deleted from the database - delete from filesystem if enabled + if qb.options.UseFilesystem { + if err := qb.fsStore.Delete(ctx, checksum); err != nil { + return fmt.Errorf("deleting from filesystem: %w", err) + } + } + + return nil +} + +func (qb *BlobStore) isConstraintError(err error) bool { + var sqliteError sqlite3.Error + if errors.As(err, &sqliteError) { + return sqliteError.Code == sqlite3.ErrConstraint + } + return false +} + +func (qb *BlobStore) delete(ctx context.Context, checksum string) error { + table := qb.table() + + q := dialect.Delete(table).Where(goqu.C(blobChecksumColumn).Eq(checksum)) + + _, err := exec(ctx, q) + if err != nil { + return fmt.Errorf("deleting from %s: %w", table, err) + } + + return nil +} + +type blobJoinQueryBuilder struct { + repository + blobStore *BlobStore + + joinTable string +} + +func (qb *blobJoinQueryBuilder) GetImage(ctx context.Context, id int, blobCol string) ([]byte, error) { + sqlQuery := utils.StrFormat(` +SELECT blobs.checksum, blobs.blob FROM {joinTable} INNER JOIN blobs ON {joinTable}.{joinCol} = blobs.checksum +WHERE {joinTable}.id = ? +`, utils.StrFormatMap{ + "joinTable": qb.joinTable, + "joinCol": blobCol, + }) + + ret, _, err := qb.blobStore.readSQL(ctx, sqlQuery, id) + return ret, err +} + +func (qb *blobJoinQueryBuilder) UpdateImage(ctx context.Context, id int, blobCol string, image []byte) error { + if len(image) == 0 { + return qb.DestroyImage(ctx, id, blobCol) + } + checksum, err := qb.blobStore.Write(ctx, image) + if err != nil { + return err + } + + sqlQuery := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", qb.joinTable, blobCol) + _, err = qb.tx.Exec(ctx, sqlQuery, checksum, id) + return err +} + +func (qb *blobJoinQueryBuilder) DestroyImage(ctx context.Context, id int, blobCol string) error { + sqlQuery := utils.StrFormat(` +SELECT {joinTable}.{joinCol} FROM {joinTable} WHERE {joinTable}.id = ? +`, utils.StrFormatMap{ + "joinTable": qb.joinTable, + "joinCol": blobCol, + }) + + var checksum null.String + err := qb.repository.querySimple(ctx, sqlQuery, []interface{}{id}, &checksum) + if err != nil { + return err + } + + if !checksum.Valid { + // no image to delete + return nil + } + + updateQuery := fmt.Sprintf("UPDATE %s SET %s = NULL WHERE id = ?", qb.joinTable, blobCol) + if _, err = qb.tx.Exec(ctx, updateQuery, id); err != nil { + return err + } + + return qb.blobStore.Delete(ctx, checksum.String) +} + +func (qb *blobJoinQueryBuilder) HasImage(ctx context.Context, id int, blobCol string) (bool, error) { + stmt := utils.StrFormat("SELECT COUNT(*) as count FROM (SELECT {joinCol} FROM {joinTable} WHERE id = ? AND {joinCol} IS NOT NULL LIMIT 1)", utils.StrFormatMap{ + "joinTable": qb.joinTable, + "joinCol": blobCol, + }) + + c, err := qb.runCountQuery(ctx, stmt, []interface{}{id}) + if err != nil { + return false, err + } + + return c == 1, nil +} diff --git a/pkg/sqlite/blob/fs.go b/pkg/sqlite/blob/fs.go new file mode 100644 index 000000000..37ec4b064 --- /dev/null +++ b/pkg/sqlite/blob/fs.go @@ -0,0 +1,108 @@ +package blob + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" +) + +const ( + blobsDirDepth int = 2 + blobsDirLength int = 2 // thumbDirDepth * thumbDirLength must be smaller than the length of checksum +) + +type FS interface { + Create(name string) (*os.File, error) + MkdirAll(path string, perm fs.FileMode) error + Open(name string) (fs.ReadDirFile, error) + Remove(name string) error + + file.RenamerRemover +} + +type FilesystemStore struct { + deleter *file.Deleter + path string + fs FS +} + +func NewFilesystemStore(path string, fs FS) *FilesystemStore { + deleter := &file.Deleter{ + RenamerRemover: fs, + } + + return &FilesystemStore{ + deleter: deleter, + path: path, + fs: fs, + } +} + +func (s *FilesystemStore) checksumToPath(checksum string) string { + return filepath.Join(s.path, fsutil.GetIntraDir(checksum, blobsDirDepth, blobsDirLength), checksum) +} + +func (s *FilesystemStore) Write(ctx context.Context, checksum string, data []byte) error { + if s.path == "" { + return fmt.Errorf("no path set") + } + + fn := s.checksumToPath(checksum) + + // create the directory if it doesn't exist + if err := s.fs.MkdirAll(filepath.Dir(fn), 0755); err != nil { + return fmt.Errorf("creating directory %q: %w", filepath.Dir(fn), err) + } + + out, err := s.fs.Create(fn) + if err != nil { + return fmt.Errorf("creating file %q: %w", fn, err) + } + + r := bytes.NewReader(data) + + if _, err = io.Copy(out, r); err != nil { + return fmt.Errorf("writing file %q: %w", fn, err) + } + + return nil +} + +func (s *FilesystemStore) Read(ctx context.Context, checksum string) ([]byte, error) { + if s.path == "" { + return nil, fmt.Errorf("no path set") + } + + fn := s.checksumToPath(checksum) + f, err := s.fs.Open(fn) + if err != nil { + return nil, fmt.Errorf("opening file %q: %w", fn, err) + } + + defer f.Close() + + return io.ReadAll(f) +} + +func (s *FilesystemStore) Delete(ctx context.Context, checksum string) error { + if s.path == "" { + return fmt.Errorf("no path set") + } + + s.deleter.RegisterHooks(ctx) + + fn := s.checksumToPath(checksum) + + if err := s.deleter.Files([]string{fn}); err != nil { + return fmt.Errorf("deleting file %q: %w", fn, err) + } + + return nil +} diff --git a/pkg/sqlite/blob_migrate.go b/pkg/sqlite/blob_migrate.go new file mode 100644 index 000000000..e121d0792 --- /dev/null +++ b/pkg/sqlite/blob_migrate.go @@ -0,0 +1,116 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/jmoiron/sqlx" +) + +func (qb *BlobStore) FindBlobs(ctx context.Context, n uint, lastChecksum string) ([]string, error) { + table := qb.table() + q := dialect.From(table).Select(table.Col(blobChecksumColumn)).Order(table.Col(blobChecksumColumn).Asc()).Limit(n) + + if lastChecksum != "" { + q = q.Where(table.Col(blobChecksumColumn).Gt(lastChecksum)) + } + + const single = false + var checksums []string + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var checksum string + if err := rows.Scan(&checksum); err != nil { + return err + } + checksums = append(checksums, checksum) + return nil + }); err != nil { + return nil, err + } + + return checksums, nil +} + +// MigrateBlob migrates a blob from the filesystem to the database, or vice versa. +// The target is determined by the UseDatabase and UseFilesystem options. +// If deleteOld is true, the blob is deleted from the source after migration. +func (qb *BlobStore) MigrateBlob(ctx context.Context, checksum string, deleteOld bool) error { + if !qb.options.UseDatabase && !qb.options.UseFilesystem { + panic("no blob store configured") + } + + if qb.options.UseDatabase && qb.options.UseFilesystem { + panic("both filesystem and database configured") + } + + if qb.options.Path == "" { + panic("no blob path configured") + } + + if qb.options.UseDatabase { + return qb.migrateBlobDatabase(ctx, checksum, deleteOld) + } + + return qb.migrateBlobFilesystem(ctx, checksum, deleteOld) +} + +// migrateBlobDatabase migrates a blob from the filesystem to the database +func (qb *BlobStore) migrateBlobDatabase(ctx context.Context, checksum string, deleteOld bool) error { + // ignore if the blob is already present in the database + // (still delete the old data if requested) + existing, err := qb.readFromDatabase(ctx, checksum) + if err != nil { + return fmt.Errorf("reading from database: %w", err) + } + + if len(existing) == 0 { + // find the blob in the filesystem + blob, err := qb.fsStore.Read(ctx, checksum) + if err != nil { + return fmt.Errorf("reading from filesystem: %w", err) + } + + // write the blob to the database + if err := qb.update(ctx, checksum, blob); err != nil { + return fmt.Errorf("writing to database: %w", err) + } + } + + if deleteOld { + // delete the blob from the filesystem after commit + if err := qb.fsStore.Delete(ctx, checksum); err != nil { + return fmt.Errorf("deleting from filesystem: %w", err) + } + } + + return nil +} + +// migrateBlobFilesystem migrates a blob from the database to the filesystem +func (qb *BlobStore) migrateBlobFilesystem(ctx context.Context, checksum string, deleteOld bool) error { + // find the blob in the database + blob, err := qb.readFromDatabase(ctx, checksum) + if err != nil { + return fmt.Errorf("reading from database: %w", err) + } + + if len(blob) == 0 { + // it's possible that the blob is already present in the filesystem + // just ignore + return nil + } + + // write the blob to the filesystem + if err := qb.fsStore.Write(ctx, checksum, blob); err != nil { + return fmt.Errorf("writing to filesystem: %w", err) + } + + if deleteOld { + // delete the blob from the database row + if err := qb.update(ctx, checksum, nil); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/blob_test.go b/pkg/sqlite/blob_test.go new file mode 100644 index 000000000..4c6e0ccc2 --- /dev/null +++ b/pkg/sqlite/blob_test.go @@ -0,0 +1,45 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type updateImageFunc func(ctx context.Context, id int, image []byte) error +type getImageFunc func(ctx context.Context, movieID int) ([]byte, error) + +func testUpdateImage(t *testing.T, ctx context.Context, id int, updateFn updateImageFunc, getFn getImageFunc) error { + image := []byte("image") + err := updateFn(ctx, id, image) + if err != nil { + return fmt.Errorf("Error updating performer image: %s", err.Error()) + } + + // ensure image set + storedImage, err := getFn(ctx, id) + if err != nil { + return fmt.Errorf("Error getting image: %s", err.Error()) + } + assert.Equal(t, storedImage, image) + + // set nil image + err = updateFn(ctx, id, nil) + if err != nil { + return fmt.Errorf("error setting nil image: %w", err) + } + + // ensure image null + storedImage, err = getFn(ctx, id) + if err != nil { + return fmt.Errorf("Error getting image: %s", err.Error()) + } + assert.Nil(t, storedImage) + + return nil +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index c107986c1..ee3fa5c3d 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -32,7 +32,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 44 +var appSchemaVersion uint = 45 //go:embed migrations/*.sql var migrationsBox embed.FS @@ -64,12 +64,16 @@ func (e *MismatchedSchemaVersionError) Error() string { } type Database struct { + Blobs *BlobStore File *FileStore Folder *FolderStore Image *ImageStore Gallery *GalleryStore Scene *SceneStore Performer *PerformerStore + Studio *studioQueryBuilder + Tag *tagQueryBuilder + Movie *movieQueryBuilder db *sqlx.DB dbPath string @@ -82,20 +86,29 @@ type Database struct { func NewDatabase() *Database { fileStore := NewFileStore() folderStore := NewFolderStore() + blobStore := NewBlobStore(BlobStoreOptions{}) ret := &Database{ + Blobs: blobStore, File: fileStore, Folder: folderStore, - Scene: NewSceneStore(fileStore), + Scene: NewSceneStore(fileStore, blobStore), Image: NewImageStore(fileStore), Gallery: NewGalleryStore(fileStore, folderStore), - Performer: NewPerformerStore(), + Performer: NewPerformerStore(blobStore), + Studio: NewStudioReaderWriter(blobStore), + Tag: NewTagReaderWriter(blobStore), + Movie: NewMovieReaderWriter(blobStore), lockChan: make(chan struct{}, 1), } return ret } +func (db *Database) SetBlobStoreOptions(options BlobStoreOptions) { + *db.Blobs = *NewBlobStore(options) +} + // Ready returns an error if the database is not ready to begin transactions. func (db *Database) Ready() error { if db.db == nil { @@ -433,6 +446,12 @@ func (db *Database) optimise() { } } +// Vacuum runs a VACUUM on the database, rebuilding the database file into a minimal amount of disk space. +func (db *Database) Vacuum(ctx context.Context) error { + _, err := db.db.ExecContext(ctx, "VACUUM") + return err +} + func (db *Database) runCustomMigrations(ctx context.Context, fns []customMigrationFunc) error { for _, fn := range fns { if err := db.runCustomMigration(ctx, fn); err != nil { diff --git a/pkg/sqlite/migrations/45_blobs.up.sql b/pkg/sqlite/migrations/45_blobs.up.sql new file mode 100644 index 000000000..ea62fe5bc --- /dev/null +++ b/pkg/sqlite/migrations/45_blobs.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE `blobs` ( + `checksum` varchar(255) NOT NULL PRIMARY KEY, + `blob` blob +); + +ALTER TABLE `tags` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`); +ALTER TABLE `studios` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`); +ALTER TABLE `performers` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`); +ALTER TABLE `scenes` ADD COLUMN `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`); + +ALTER TABLE `movies` ADD COLUMN `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`); +ALTER TABLE `movies` ADD COLUMN `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`); + +-- performed in the post-migration +-- DROP TABLE `tags_image`; +-- DROP TABLE `studios_image`; +-- DROP TABLE `performers_image`; +-- DROP TABLE `scenes_cover`; +-- DROP TABLE `movies_images`; diff --git a/pkg/sqlite/migrations/45_postmigrate.go b/pkg/sqlite/migrations/45_postmigrate.go new file mode 100644 index 000000000..8618838a2 --- /dev/null +++ b/pkg/sqlite/migrations/45_postmigrate.go @@ -0,0 +1,286 @@ +package migrations + +import ( + "context" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/hash/md5" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" + "github.com/stashapp/stash/pkg/utils" +) + +type schema45Migrator struct { + migrator + hasBlobs bool +} + +func post45(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 45") + + m := schema45Migrator{ + migrator: migrator{ + db: db, + }, + } + + if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ + joinTable: "tags_image", + joinIDCol: "tag_id", + destTable: "tags", + cols: []migrateImageToBlobOptions{ + { + joinImageCol: "image", + destCol: "image_blob", + }, + }, + }); err != nil { + return err + } + + if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ + joinTable: "studios_image", + joinIDCol: "studio_id", + destTable: "studios", + cols: []migrateImageToBlobOptions{ + { + joinImageCol: "image", + destCol: "image_blob", + }, + }, + }); err != nil { + return err + } + + if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ + joinTable: "performers_image", + joinIDCol: "performer_id", + destTable: "performers", + cols: []migrateImageToBlobOptions{ + { + joinImageCol: "image", + destCol: "image_blob", + }, + }, + }); err != nil { + return err + } + + if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ + joinTable: "scenes_cover", + joinIDCol: "scene_id", + destTable: "scenes", + cols: []migrateImageToBlobOptions{ + { + joinImageCol: "cover", + destCol: "cover_blob", + }, + }, + }); err != nil { + return err + } + + if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ + joinTable: "movies_images", + joinIDCol: "movie_id", + destTable: "movies", + cols: []migrateImageToBlobOptions{ + { + joinImageCol: "front_image", + destCol: "front_image_blob", + }, + { + joinImageCol: "back_image", + destCol: "back_image_blob", + }, + }, + }); err != nil { + return err + } + + tablesToDrop := []string{ + "tags_image", + "studios_image", + "performers_image", + "scenes_cover", + "movies_images", + } + + for _, table := range tablesToDrop { + if err := m.dropTable(ctx, table); err != nil { + return err + } + } + + if err := m.migrateConfig(ctx); err != nil { + return err + } + + return nil +} + +type migrateImageToBlobOptions struct { + joinImageCol string + destCol string +} + +type migrateImagesTableOptions struct { + joinTable string + joinIDCol string + destTable string + cols []migrateImageToBlobOptions +} + +func (o migrateImagesTableOptions) selectColumns() string { + var cols []string + for _, c := range o.cols { + cols = append(cols, "`"+c.joinImageCol+"`") + } + + return strings.Join(cols, ", ") +} + +func (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migrateImagesTableOptions) error { + logger.Infof("Moving %s to blobs table", options.joinTable) + + const ( + limit = 1000 + logEvery = 10000 + ) + + count := 0 + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := fmt.Sprintf("SELECT %s, %s FROM `%s`", options.joinIDCol, options.selectColumns(), options.joinTable) + + query += fmt.Sprintf(" LIMIT %d", limit) + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + m.hasBlobs = true + + var id int + + result := make([]interface{}, len(options.cols)+1) + result[0] = &id + for i := range options.cols { + v := []byte{} + result[i+1] = &v + } + + err := rows.Scan(result...) + if err != nil { + return err + } + + gotSome = true + count++ + + for i, col := range options.cols { + image := result[i+1].(*[]byte) + + if len(*image) > 0 { + if err := m.insertImage(*image, id, options.destTable, col.destCol); err != nil { + return err + } + } + } + + // delete the row from the join table so we don't process it again + deleteSQL := utils.StrFormat("DELETE FROM `{joinTable}` WHERE `{joinIDCol}` = ?", utils.StrFormatMap{ + "joinTable": options.joinTable, + "joinIDCol": options.joinIDCol, + }) + if _, err := m.db.Exec(deleteSQL, id); err != nil { + return err + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d images", count) + } + } + + return nil +} + +func (m *schema45Migrator) insertImage(data []byte, id int, destTable string, destCol string) error { + // calculate checksum and insert into blobs table + checksum := md5.FromBytes(data) + + if _, err := m.db.Exec("INSERT INTO `blobs` (`checksum`, `blob`) VALUES (?, ?) ON CONFLICT DO NOTHING", checksum, data); err != nil { + return err + } + + // set the tag image checksum + updateSQL := utils.StrFormat("UPDATE `{destTable}` SET `{destCol}` = ? WHERE `id` = ?", utils.StrFormatMap{ + "destTable": destTable, + "destCol": destCol, + }) + if _, err := m.db.Exec(updateSQL, checksum, id); err != nil { + return err + } + + return nil +} + +func (m *schema45Migrator) dropTable(ctx context.Context, table string) error { + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + logger.Debugf("Dropping %s", table) + _, err := m.db.Exec(fmt.Sprintf("DROP TABLE `%s`", table)) + return err + }); err != nil { + return err + } + + return nil +} + +func (m *schema45Migrator) migrateConfig(ctx context.Context) error { + c := config.GetInstance() + + // if we don't have blobs, and storage is already set, then don't overwrite + if !m.hasBlobs && c.GetBlobsStorage().IsValid() { + logger.Infof("Blobs storage already set, not overwriting") + return nil + } + + // if we have blobs in the database, then default to database storage + // otherwise default to filesystem storage + defaultStorage := config.BlobStorageTypeFilesystem + if m.hasBlobs || c.GetBlobsPath() == "" { + defaultStorage = config.BlobStorageTypeDatabase + } + + logger.Infof("Setting blobs storage to %s", defaultStorage.String()) + c.Set(config.BlobsStorage, defaultStorage) + if err := c.Write(); err != nil { + logger.Errorf("Error while writing configuration file: %s", err.Error()) + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(45, post45) +} diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 5d9d5cb36..2fa429546 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -12,18 +12,30 @@ import ( "github.com/stashapp/stash/pkg/sliceutil/intslice" ) -const movieTable = "movies" -const movieIDColumn = "movie_id" +const ( + movieTable = "movies" + movieIDColumn = "movie_id" + + movieFrontImageBlobColumn = "front_image_blob" + movieBackImageBlobColumn = "back_image_blob" +) type movieQueryBuilder struct { repository + blobJoinQueryBuilder } -var MovieReaderWriter = &movieQueryBuilder{ - repository{ - tableName: movieTable, - idColumn: idColumn, - }, +func NewMovieReaderWriter(blobStore *BlobStore) *movieQueryBuilder { + return &movieQueryBuilder{ + repository{ + tableName: movieTable, + idColumn: idColumn, + }, + blobJoinQueryBuilder{ + blobStore: blobStore, + joinTable: movieTable, + }, + } } func (qb *movieQueryBuilder) Create(ctx context.Context, newObject models.Movie) (*models.Movie, error) { @@ -54,6 +66,11 @@ func (qb *movieQueryBuilder) UpdateFull(ctx context.Context, updatedObject model } func (qb *movieQueryBuilder) Destroy(ctx context.Context, id int) error { + // must handle image checksums manually + if err := qb.destroyImages(ctx, id); err != nil { + return err + } + return qb.destroyExisting(ctx, []int{id}) } @@ -209,11 +226,9 @@ func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) cr if isMissing != nil && *isMissing != "" { switch *isMissing { case "front_image": - f.addLeftJoin("movies_images", "", "movies_images.movie_id = movies.id") - f.addWhere("movies_images.front_image IS NULL") + f.addWhere("movies.front_image_blob IS NULL") case "back_image": - f.addLeftJoin("movies_images", "", "movies_images.movie_id = movies.id") - f.addWhere("movies_images.back_image IS NULL") + f.addWhere("movies.back_image_blob IS NULL") case "scenes": f.addLeftJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id") f.addWhere("movies_scenes.scene_id IS NULL") @@ -322,39 +337,31 @@ func (qb *movieQueryBuilder) queryMovies(ctx context.Context, query string, args return []*models.Movie(ret), nil } -func (qb *movieQueryBuilder) UpdateImages(ctx context.Context, movieID int, frontImage []byte, backImage []byte) error { - // Delete the existing cover and then create new - if err := qb.DestroyImages(ctx, movieID); err != nil { - return err - } - - _, err := qb.tx.Exec(ctx, - `INSERT INTO movies_images (movie_id, front_image, back_image) VALUES (?, ?, ?)`, - movieID, - frontImage, - backImage, - ) - - return err +func (qb *movieQueryBuilder) UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error { + return qb.UpdateImage(ctx, movieID, movieFrontImageBlobColumn, frontImage) } -func (qb *movieQueryBuilder) DestroyImages(ctx context.Context, movieID int) error { - // Delete the existing joins - _, err := qb.tx.Exec(ctx, "DELETE FROM movies_images WHERE movie_id = ?", movieID) - if err != nil { +func (qb *movieQueryBuilder) UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error { + return qb.UpdateImage(ctx, movieID, movieBackImageBlobColumn, backImage) +} + +func (qb *movieQueryBuilder) destroyImages(ctx context.Context, movieID int) error { + if err := qb.DestroyImage(ctx, movieID, movieFrontImageBlobColumn); err != nil { return err } - return err + if err := qb.DestroyImage(ctx, movieID, movieBackImageBlobColumn); err != nil { + return err + } + + return nil } func (qb *movieQueryBuilder) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) { - query := `SELECT front_image from movies_images WHERE movie_id = ?` - return getImage(ctx, qb.tx, query, movieID) + return qb.GetImage(ctx, movieID, movieFrontImageBlobColumn) } func (qb *movieQueryBuilder) GetBackImage(ctx context.Context, movieID int) ([]byte, error) { - query := `SELECT back_image from movies_images WHERE movie_id = ?` - return getImage(ctx, qb.tx, query, movieID) + return qb.GetImage(ctx, movieID, movieBackImageBlobColumn) } func (qb *movieQueryBuilder) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Movie, error) { diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index eff0cf50b..9180dde20 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -15,12 +15,11 @@ import ( "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sqlite" ) func TestMovieFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { - mqb := sqlite.MovieReaderWriter + mqb := db.Movie name := movieNames[movieIdxWithScene] // find a movie by name @@ -53,7 +52,7 @@ func TestMovieFindByNames(t *testing.T) { withTxn(func(ctx context.Context) error { var names []string - mqb := sqlite.MovieReaderWriter + mqb := db.Movie names = append(names, movieNames[movieIdxWithScene]) // find movies by names @@ -76,9 +75,80 @@ func TestMovieFindByNames(t *testing.T) { }) } +func moviesToIDs(i []*models.Movie) []int { + ret := make([]int, len(i)) + for i, v := range i { + ret[i] = v.ID + } + + return ret +} + +func TestMovieQuery(t *testing.T) { + var ( + frontImage = "front_image" + backImage = "back_image" + ) + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.MovieFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "is missing front image", + nil, + &models.MovieFilterType{ + IsMissing: &frontImage, + }, + // just ensure that it doesn't error + nil, + nil, + false, + }, + { + "is missing back image", + nil, + &models.MovieFilterType{ + IsMissing: &backImage, + }, + // just ensure that it doesn't error + nil, + nil, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + results, _, err := db.Movie.Query(ctx, tt.filter, tt.findFilter) + if (err != nil) != tt.wantErr { + t.Errorf("MovieQueryBuilder.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + ids := moviesToIDs(results) + include := indexesToIDs(performerIDs, tt.includeIdxs) + exclude := indexesToIDs(performerIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + func TestMovieQueryStudio(t *testing.T) { withTxn(func(ctx context.Context) error { - mqb := sqlite.MovieReaderWriter + mqb := db.Movie studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithMovie]), @@ -163,7 +233,7 @@ func TestMovieQueryURL(t *testing.T) { func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) { withTxn(func(ctx context.Context) error { t.Helper() - sqb := sqlite.MovieReaderWriter + sqb := db.Movie movies := queryMovie(ctx, t, sqb, &filter, nil) @@ -196,7 +266,7 @@ func TestMovieQuerySorting(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.MovieReaderWriter + sqb := db.Movie movies := queryMovie(ctx, t, sqb, nil, &findFilter) // scenes should be in same order as indexes @@ -216,122 +286,50 @@ func TestMovieQuerySorting(t *testing.T) { }) } -func TestMovieUpdateMovieImages(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - mqb := sqlite.MovieReaderWriter +func TestMovieUpdateFrontImage(t *testing.T) { + if err := withRollbackTxn(func(ctx context.Context) error { + qb := db.Movie // create movie to test against const name = "TestMovieUpdateMovieImages" - movie := models.Movie{ + toCreate := models.Movie{ Name: sql.NullString{String: name, Valid: true}, Checksum: md5.FromString(name), } - created, err := mqb.Create(ctx, movie) + movie, err := qb.Create(ctx, toCreate) if err != nil { return fmt.Errorf("Error creating movie: %s", err.Error()) } - frontImage := []byte("frontImage") - backImage := []byte("backImage") - err = mqb.UpdateImages(ctx, created.ID, frontImage, backImage) - if err != nil { - return fmt.Errorf("Error updating movie images: %s", err.Error()) - } - - // ensure images are set - storedFront, err := mqb.GetFrontImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting front image: %s", err.Error()) - } - assert.Equal(t, storedFront, frontImage) - - storedBack, err := mqb.GetBackImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting back image: %s", err.Error()) - } - assert.Equal(t, storedBack, backImage) - - // set front image only - newImage := []byte("newImage") - err = mqb.UpdateImages(ctx, created.ID, newImage, nil) - if err != nil { - return fmt.Errorf("Error updating movie images: %s", err.Error()) - } - - storedFront, err = mqb.GetFrontImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting front image: %s", err.Error()) - } - assert.Equal(t, storedFront, newImage) - - // back image should be nil - storedBack, err = mqb.GetBackImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting back image: %s", err.Error()) - } - assert.Nil(t, nil) - - // set back image only - err = mqb.UpdateImages(ctx, created.ID, nil, newImage) - if err == nil { - return fmt.Errorf("Expected error setting nil front image") - } - - return nil + return testUpdateImage(t, ctx, movie.ID, qb.UpdateFrontImage, qb.GetFrontImage) }); err != nil { t.Error(err.Error()) } } -func TestMovieDestroyMovieImages(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - mqb := sqlite.MovieReaderWriter +func TestMovieUpdateBackImage(t *testing.T) { + if err := withRollbackTxn(func(ctx context.Context) error { + qb := db.Movie // create movie to test against - const name = "TestMovieDestroyMovieImages" - movie := models.Movie{ + const name = "TestMovieUpdateMovieImages" + toCreate := models.Movie{ Name: sql.NullString{String: name, Valid: true}, Checksum: md5.FromString(name), } - created, err := mqb.Create(ctx, movie) + movie, err := qb.Create(ctx, toCreate) if err != nil { return fmt.Errorf("Error creating movie: %s", err.Error()) } - frontImage := []byte("frontImage") - backImage := []byte("backImage") - err = mqb.UpdateImages(ctx, created.ID, frontImage, backImage) - if err != nil { - return fmt.Errorf("Error updating movie images: %s", err.Error()) - } - - err = mqb.DestroyImages(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error destroying movie images: %s", err.Error()) - } - - // front image should be nil - storedFront, err := mqb.GetFrontImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting front image: %s", err.Error()) - } - assert.Nil(t, storedFront) - - // back image should be nil - storedBack, err := mqb.GetBackImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting back image: %s", err.Error()) - } - assert.Nil(t, storedBack) - - return nil + return testUpdateImage(t, ctx, movie.ID, qb.UpdateBackImage, qb.GetBackImage) }); err != nil { t.Error(err.Error()) } } // TODO Update -// TODO Destroy +// TODO Destroy - ensure image is destroyed // TODO Find // TODO Count // TODO All diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 024679007..f288401d3 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -23,7 +23,8 @@ const ( performersAliasesTable = "performer_aliases" performerAliasColumn = "alias" performersTagsTable = "performers_tags" - performersImageTable = "performers_image" // performer cover image + + performerImageBlobColumn = "image_blob" ) type performerRow struct { @@ -54,6 +55,9 @@ type performerRow struct { HairColor zero.String `db:"hair_color"` Weight null.Int `db:"weight"` IgnoreAutoTag bool `db:"ignore_auto_tag"` + + // not used for resolution + ImageBlob zero.String `db:"image_blob"` } func (r *performerRow) fromPerformer(o models.Performer) { @@ -159,16 +163,21 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { type PerformerStore struct { repository + blobJoinQueryBuilder tableMgr *table } -func NewPerformerStore() *PerformerStore { +func NewPerformerStore(blobStore *BlobStore) *PerformerStore { return &PerformerStore{ repository: repository{ tableName: performerTable, idColumn: idColumn, }, + blobJoinQueryBuilder: blobJoinQueryBuilder{ + blobStore: blobStore, + joinTable: performerTable, + }, tableMgr: performerTableMgr, } } @@ -275,6 +284,11 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Perf } func (qb *PerformerStore) Destroy(ctx context.Context, id int) error { + // must handle image checksums manually + if err := qb.DestroyImage(ctx, id); err != nil { + return err + } + return qb.destroyExisting(ctx, []int{id}) } @@ -690,8 +704,7 @@ func performerIsMissingCriterionHandler(qb *PerformerStore, isMissing *string) c f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") f.addWhere("scenes_join.scene_id IS NULL") case "image": - f.addLeftJoin(performersImageTable, "image_join", "image_join.performer_id = performers.id") - f.addWhere("image_join.performer_id IS NULL") + f.addWhere("performers.image_blob IS NULL") case "stash_id": performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") f.addWhere("performer_stash_ids.performer_id IS NULL") @@ -911,27 +924,16 @@ func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) return qb.tagsRepository().getIDs(ctx, id) } -func (qb *PerformerStore) imageRepository() *imageRepository { - return &imageRepository{ - repository: repository{ - tx: qb.tx, - tableName: "performers_image", - idColumn: performerIDColumn, - }, - imageColumn: "image", - } -} - func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) { - return qb.imageRepository().get(ctx, performerID) + return qb.blobJoinQueryBuilder.GetImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error { - return qb.imageRepository().replace(ctx, performerID, image) + return qb.blobJoinQueryBuilder.UpdateImage(ctx, performerID, performerImageBlobColumn, image) } func (qb *PerformerStore) DestroyImage(ctx context.Context, performerID int) error { - return qb.imageRepository().destroy(ctx, []int{performerID}) + return qb.blobJoinQueryBuilder.DestroyImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) stashIDRepository() *stashIDRepository { diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 0033e98a3..732c427a7 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -1029,26 +1029,7 @@ func TestPerformerUpdatePerformerImage(t *testing.T) { return fmt.Errorf("Error creating performer: %s", err.Error()) } - image := []byte("image") - err = qb.UpdateImage(ctx, performer.ID, image) - if err != nil { - return fmt.Errorf("Error updating performer image: %s", err.Error()) - } - - // ensure image set - storedImage, err := qb.GetImage(ctx, performer.ID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Equal(t, storedImage, image) - - // set nil image - err = qb.UpdateImage(ctx, performer.ID, nil) - if err == nil { - return fmt.Errorf("Expected error setting nil image") - } - - return nil + return testUpdateImage(t, ctx, performer.ID, qb.UpdateImage, qb.GetImage) }); err != nil { t.Error(err.Error()) } diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index fe7c6c7da..c3b1d74aa 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -387,29 +387,6 @@ func (r *joinRepository) replace(ctx context.Context, id int, foreignIDs []int) return nil } -type imageRepository struct { - repository - imageColumn string -} - -func (r *imageRepository) get(ctx context.Context, id int) ([]byte, error) { - query := fmt.Sprintf("SELECT %s from %s WHERE %s = ?", r.imageColumn, r.tableName, r.idColumn) - var ret []byte - err := r.querySimple(ctx, query, []interface{}{id}, &ret) - return ret, err -} - -func (r *imageRepository) replace(ctx context.Context, id int, image []byte) error { - if err := r.destroy(ctx, []int{id}); err != nil { - return err - } - - stmt := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.imageColumn) - _, err := r.tx.Exec(ctx, stmt, id, image) - - return err -} - type captionRepository struct { repository } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 58c652a2c..a5e903653 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -31,6 +31,8 @@ const ( scenesTagsTable = "scenes_tags" scenesGalleriesTable = "scenes_galleries" moviesScenesTable = "movies_scenes" + + sceneCoverBlobColumn = "cover_blob" ) var findExactDuplicateQuery = ` @@ -72,6 +74,9 @@ type sceneRow struct { ResumeTime float64 `db:"resume_time"` PlayDuration float64 `db:"play_duration"` PlayCount int `db:"play_count"` + + // not used in resolutions or updates + CoverBlob zero.String `db:"cover_blob"` } func (r *sceneRow) fromScene(o models.Scene) { @@ -172,6 +177,7 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { type SceneStore struct { repository + blobJoinQueryBuilder tableMgr *table oCounterManager @@ -179,12 +185,16 @@ type SceneStore struct { fileStore *FileStore } -func NewSceneStore(fileStore *FileStore) *SceneStore { +func NewSceneStore(fileStore *FileStore, blobStore *BlobStore) *SceneStore { return &SceneStore{ repository: repository{ tableName: sceneTable, idColumn: idColumn, }, + blobJoinQueryBuilder: blobJoinQueryBuilder{ + blobStore: blobStore, + joinTable: sceneTable, + }, tableMgr: sceneTableMgr, oCounterManager: oCounterManager{sceneTableMgr}, @@ -353,6 +363,11 @@ func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) e } func (qb *SceneStore) Destroy(ctx context.Context, id int) error { + // must handle image checksums manually + if err := qb.destroyCover(ctx, id); err != nil { + return err + } + // scene markers should be handled prior to calling destroy // galleries should be handled prior to calling destroy @@ -1187,6 +1202,8 @@ func sceneIsMissingCriterionHandler(qb *SceneStore, isMissing *string) criterion qb.addSceneFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") f.addWhere("fingerprints_phash.fingerprint IS NULL") + case "cover": + f.addWhere("scenes.cover_blob IS NULL") default: f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") } @@ -1464,17 +1481,6 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF } } -func (qb *SceneStore) imageRepository() *imageRepository { - return &imageRepository{ - repository: repository{ - tx: qb.tx, - tableName: "scenes_cover", - idColumn: sceneIDColumn, - }, - imageColumn: "cover", - } -} - func (qb *SceneStore) getPlayCount(ctx context.Context, id int) (int, error) { q := dialect.From(qb.tableMgr.table).Select("play_count").Where(goqu.Ex{"id": id}) @@ -1532,15 +1538,19 @@ func (qb *SceneStore) IncrementWatchCount(ctx context.Context, id int) (int, err } func (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) { - return qb.imageRepository().get(ctx, sceneID) + return qb.GetImage(ctx, sceneID, sceneCoverBlobColumn) +} + +func (qb *SceneStore) HasCover(ctx context.Context, sceneID int) (bool, error) { + return qb.HasImage(ctx, sceneID, sceneCoverBlobColumn) } func (qb *SceneStore) UpdateCover(ctx context.Context, sceneID int, image []byte) error { - return qb.imageRepository().replace(ctx, sceneID, image) + return qb.UpdateImage(ctx, sceneID, sceneCoverBlobColumn, image) } -func (qb *SceneStore) DestroyCover(ctx context.Context, sceneID int) error { - return qb.imageRepository().destroy(ctx, []int{sceneID}) +func (qb *SceneStore) destroyCover(ctx context.Context, sceneID int) error { + return qb.DestroyImage(ctx, sceneID, sceneCoverBlobColumn) } func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []file.ID) error { diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 697dba113..e4fc7a3f3 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -4088,53 +4088,7 @@ func TestSceneUpdateSceneCover(t *testing.T) { sceneID := sceneIDs[sceneIdxWithGallery] - image := []byte("image") - if err := qb.UpdateCover(ctx, sceneID, image); err != nil { - return fmt.Errorf("Error updating scene cover: %s", err.Error()) - } - - // ensure image set - storedImage, err := qb.GetCover(ctx, sceneID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Equal(t, storedImage, image) - - // set nil image - err = qb.UpdateCover(ctx, sceneID, nil) - if err == nil { - return fmt.Errorf("Expected error setting nil image") - } - - return nil - }); err != nil { - t.Error(err.Error()) - } -} - -func TestSceneDestroySceneCover(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - qb := db.Scene - - sceneID := sceneIDs[sceneIdxWithGallery] - - image := []byte("image") - if err := qb.UpdateCover(ctx, sceneID, image); err != nil { - return fmt.Errorf("Error updating scene image: %s", err.Error()) - } - - if err := qb.DestroyCover(ctx, sceneID); err != nil { - return fmt.Errorf("Error destroying scene cover: %s", err.Error()) - } - - // image should be nil - storedImage, err := qb.GetCover(ctx, sceneID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Nil(t, storedImage) - - return nil + return testUpdateImage(t, ctx, sceneID, qb.UpdateCover, qb.GetCover) }); err != nil { t.Error(err.Error()) } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 2d034dd50..4300111cf 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -537,6 +537,10 @@ func runTests(m *testing.M) int { f.Close() databaseFile := f.Name() db = sqlite.NewDatabase() + db.SetBlobStoreOptions(sqlite.BlobStoreOptions{ + UseDatabase: true, + // don't use filesystem + }) if err := db.Open(databaseFile); err != nil { panic(fmt.Sprintf("Could not initialize database: %s", err.Error())) @@ -566,11 +570,11 @@ func populateDB() error { // TODO - link folders to zip files - if err := createMovies(ctx, sqlite.MovieReaderWriter, moviesNameCase, moviesNameNoCase); err != nil { + if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil { return fmt.Errorf("error creating movies: %s", err.Error()) } - if err := createTags(ctx, sqlite.TagReaderWriter, tagsNameCase, tagsNameNoCase); err != nil { + if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) } @@ -578,7 +582,7 @@ func populateDB() error { return fmt.Errorf("error creating performers: %s", err.Error()) } - if err := createStudios(ctx, sqlite.StudioReaderWriter, studiosNameCase, studiosNameNoCase); err != nil { + if err := createStudios(ctx, db.Studio, studiosNameCase, studiosNameNoCase); err != nil { return fmt.Errorf("error creating studios: %s", err.Error()) } @@ -594,7 +598,7 @@ func populateDB() error { return fmt.Errorf("error creating images: %s", err.Error()) } - if err := addTagImage(ctx, sqlite.TagReaderWriter, tagIdxWithCoverImage); err != nil { + if err := addTagImage(ctx, db.Tag, tagIdxWithCoverImage); err != nil { return fmt.Errorf("error adding tag image: %s", err.Error()) } @@ -602,15 +606,15 @@ func populateDB() error { return fmt.Errorf("error creating saved filters: %s", err.Error()) } - if err := linkMovieStudios(ctx, sqlite.MovieReaderWriter); err != nil { + if err := linkMovieStudios(ctx, db.Movie); err != nil { return fmt.Errorf("error linking movie studios: %s", err.Error()) } - if err := linkStudiosParent(ctx, sqlite.StudioReaderWriter); err != nil { + if err := linkStudiosParent(ctx, db.Studio); err != nil { return fmt.Errorf("error linking studios parent: %s", err.Error()) } - if err := linkTagsParent(ctx, sqlite.TagReaderWriter); err != nil { + if err := linkTagsParent(ctx, db.Tag); err != nil { return fmt.Errorf("error linking tags parent: %s", err.Error()) } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 95b7d2f10..af864df01 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -1,9 +1,6 @@ package sqlite import ( - "context" - "database/sql" - "errors" "fmt" "math/rand" "regexp" @@ -290,28 +287,6 @@ func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterio return getIntCriterionWhereClause(lhs, criterion) } -func getImage(ctx context.Context, tx dbWrapper, query string, args ...interface{}) ([]byte, error) { - rows, err := tx.Queryx(ctx, query, args...) - - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - defer rows.Close() - - var ret []byte - if rows.Next() { - if err := rows.Scan(&ret); err != nil { - return nil, err - } - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return ret, nil -} - func coalesce(column string) string { return fmt.Sprintf("COALESCE(%s, '')", column) } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index f3dd963a8..04995a1db 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -13,20 +13,31 @@ import ( "github.com/stashapp/stash/pkg/sliceutil/intslice" ) -const studioTable = "studios" -const studioIDColumn = "studio_id" -const studioAliasesTable = "studio_aliases" -const studioAliasColumn = "alias" +const ( + studioTable = "studios" + studioIDColumn = "studio_id" + studioAliasesTable = "studio_aliases" + studioAliasColumn = "alias" + + studioImageBlobColumn = "image_blob" +) type studioQueryBuilder struct { repository + blobJoinQueryBuilder } -var StudioReaderWriter = &studioQueryBuilder{ - repository{ - tableName: studioTable, - idColumn: idColumn, - }, +func NewStudioReaderWriter(blobStore *BlobStore) *studioQueryBuilder { + return &studioQueryBuilder{ + repository{ + tableName: studioTable, + idColumn: idColumn, + }, + blobJoinQueryBuilder{ + blobStore: blobStore, + joinTable: studioTable, + }, + } } func (qb *studioQueryBuilder) Create(ctx context.Context, newObject models.Studio) (*models.Studio, error) { @@ -57,6 +68,11 @@ func (qb *studioQueryBuilder) UpdateFull(ctx context.Context, updatedObject mode } func (qb *studioQueryBuilder) Destroy(ctx context.Context, id int) error { + // must handle image checksums manually + if err := qb.destroyImage(ctx, id); err != nil { + return err + } + // TODO - set null on foreign key in scraped items // remove studio from scraped items _, err := qb.tx.Exec(ctx, "UPDATE scraped_items SET studio_id = null WHERE studio_id = ?", id) @@ -428,31 +444,20 @@ func (qb *studioQueryBuilder) queryStudios(ctx context.Context, query string, ar return []*models.Studio(ret), nil } -func (qb *studioQueryBuilder) imageRepository() *imageRepository { - return &imageRepository{ - repository: repository{ - tx: qb.tx, - tableName: "studios_image", - idColumn: studioIDColumn, - }, - imageColumn: "image", - } -} - func (qb *studioQueryBuilder) GetImage(ctx context.Context, studioID int) ([]byte, error) { - return qb.imageRepository().get(ctx, studioID) + return qb.blobJoinQueryBuilder.GetImage(ctx, studioID, studioImageBlobColumn) } func (qb *studioQueryBuilder) HasImage(ctx context.Context, studioID int) (bool, error) { - return qb.imageRepository().exists(ctx, studioID) + return qb.blobJoinQueryBuilder.HasImage(ctx, studioID, studioImageBlobColumn) } func (qb *studioQueryBuilder) UpdateImage(ctx context.Context, studioID int, image []byte) error { - return qb.imageRepository().replace(ctx, studioID, image) + return qb.blobJoinQueryBuilder.UpdateImage(ctx, studioID, studioImageBlobColumn, image) } -func (qb *studioQueryBuilder) DestroyImage(ctx context.Context, studioID int) error { - return qb.imageRepository().destroy(ctx, []int{studioID}) +func (qb *studioQueryBuilder) destroyImage(ctx context.Context, studioID int) error { + return qb.blobJoinQueryBuilder.DestroyImage(ctx, studioID, studioImageBlobColumn) } func (qb *studioQueryBuilder) stashIDRepository() *stashIDRepository { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 4bffe4517..334ad1a15 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -14,13 +14,12 @@ import ( "testing" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sqlite" "github.com/stretchr/testify/assert" ) func TestStudioFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio name := studioNames[studioIdxWithScene] // find a studio by name @@ -70,7 +69,7 @@ func TestStudioQueryNameOr(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) @@ -101,7 +100,7 @@ func TestStudioQueryNameAndUrl(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) @@ -136,7 +135,7 @@ func TestStudioQueryNameNotUrl(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) @@ -167,7 +166,7 @@ func TestStudioIllegalQuery(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio _, _, err := sqb.Query(ctx, studioFilter, nil) assert.NotNil(err) @@ -193,7 +192,7 @@ func TestStudioQueryIgnoreAutoTag(t *testing.T) { IgnoreAutoTag: &ignoreAutoTag, } - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) @@ -208,7 +207,7 @@ func TestStudioQueryIgnoreAutoTag(t *testing.T) { func TestStudioQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { - tqb := sqlite.StudioReaderWriter + tqb := db.Studio name := studioNames[studioIdxWithMovie] // find a studio by name @@ -239,7 +238,7 @@ func TestStudioQueryForAutoTag(t *testing.T) { func TestStudioQueryParent(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studioCriterion := models.MultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithChildStudio]), @@ -289,18 +288,18 @@ func TestStudioDestroyParent(t *testing.T) { // create parent and child studios if err := withTxn(func(ctx context.Context) error { - createdParent, err := createStudio(ctx, sqlite.StudioReaderWriter, parentName, nil) + createdParent, err := createStudio(ctx, db.Studio, parentName, nil) if err != nil { return fmt.Errorf("Error creating parent studio: %s", err.Error()) } parentID := int64(createdParent.ID) - createdChild, err := createStudio(ctx, sqlite.StudioReaderWriter, childName, &parentID) + createdChild, err := createStudio(ctx, db.Studio, childName, &parentID) if err != nil { return fmt.Errorf("Error creating child studio: %s", err.Error()) } - sqb := sqlite.StudioReaderWriter + sqb := db.Studio // destroy the parent err = sqb.Destroy(ctx, createdParent.ID) @@ -322,7 +321,7 @@ func TestStudioDestroyParent(t *testing.T) { func TestStudioFindChildren(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studios, err := sqb.FindChildren(ctx, studioIDs[studioIdxWithChildStudio]) @@ -351,18 +350,18 @@ func TestStudioUpdateClearParent(t *testing.T) { // create parent and child studios if err := withTxn(func(ctx context.Context) error { - createdParent, err := createStudio(ctx, sqlite.StudioReaderWriter, parentName, nil) + createdParent, err := createStudio(ctx, db.Studio, parentName, nil) if err != nil { return fmt.Errorf("Error creating parent studio: %s", err.Error()) } parentID := int64(createdParent.ID) - createdChild, err := createStudio(ctx, sqlite.StudioReaderWriter, childName, &parentID) + createdChild, err := createStudio(ctx, db.Studio, childName, &parentID) if err != nil { return fmt.Errorf("Error creating child studio: %s", err.Error()) } - sqb := sqlite.StudioReaderWriter + sqb := db.Studio // clear the parent id from the child updatePartial := models.StudioPartial{ @@ -388,70 +387,16 @@ func TestStudioUpdateClearParent(t *testing.T) { func TestStudioUpdateStudioImage(t *testing.T) { if err := withTxn(func(ctx context.Context) error { - qb := sqlite.StudioReaderWriter + qb := db.Studio - // create performer to test against + // create studio to test against const name = "TestStudioUpdateStudioImage" - created, err := createStudio(ctx, sqlite.StudioReaderWriter, name, nil) + created, err := createStudio(ctx, db.Studio, name, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } - image := []byte("image") - err = qb.UpdateImage(ctx, created.ID, image) - if err != nil { - return fmt.Errorf("Error updating studio image: %s", err.Error()) - } - - // ensure image set - storedImage, err := qb.GetImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Equal(t, storedImage, image) - - // set nil image - err = qb.UpdateImage(ctx, created.ID, nil) - if err == nil { - return fmt.Errorf("Expected error setting nil image") - } - - return nil - }); err != nil { - t.Error(err.Error()) - } -} - -func TestStudioDestroyStudioImage(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - qb := sqlite.StudioReaderWriter - - // create performer to test against - const name = "TestStudioDestroyStudioImage" - created, err := createStudio(ctx, sqlite.StudioReaderWriter, name, nil) - if err != nil { - return fmt.Errorf("Error creating studio: %s", err.Error()) - } - - image := []byte("image") - err = qb.UpdateImage(ctx, created.ID, image) - if err != nil { - return fmt.Errorf("Error updating studio image: %s", err.Error()) - } - - err = qb.DestroyImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error destroying studio image: %s", err.Error()) - } - - // image should be nil - storedImage, err := qb.GetImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Nil(t, storedImage) - - return nil + return testUpdateImage(t, ctx, created.ID, qb.UpdateImage, qb.GetImage) }); err != nil { t.Error(err.Error()) } @@ -478,7 +423,7 @@ func TestStudioQuerySceneCount(t *testing.T) { func verifyStudiosSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studioFilter := models.StudioFilterType{ SceneCount: &sceneCountCriterion, } @@ -519,7 +464,7 @@ func TestStudioQueryImageCount(t *testing.T) { func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studioFilter := models.StudioFilterType{ ImageCount: &imageCountCriterion, } @@ -575,7 +520,7 @@ func TestStudioQueryGalleryCount(t *testing.T) { func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studioFilter := models.StudioFilterType{ GalleryCount: &galleryCountCriterion, } @@ -606,11 +551,11 @@ func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCri func TestStudioStashIDs(t *testing.T) { if err := withTxn(func(ctx context.Context) error { - qb := sqlite.StudioReaderWriter + qb := db.Studio // create studio to test against const name = "TestStudioStashIDs" - created, err := createStudio(ctx, sqlite.StudioReaderWriter, name, nil) + created, err := createStudio(ctx, db.Studio, name, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } @@ -688,7 +633,7 @@ func TestStudioQueryRating(t *testing.T) { func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) { withTxn(func(ctx context.Context) error { t.Helper() - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studios := queryStudio(ctx, t, sqb, &filter, nil) @@ -705,7 +650,7 @@ func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn fu func verifyStudiosRating(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio studioFilter := models.StudioFilterType{ Rating: &ratingCriterion, } @@ -726,7 +671,7 @@ func verifyStudiosRating(t *testing.T, ratingCriterion models.IntCriterionInput) func TestStudioQueryIsMissingRating(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio isMissing := "rating" studioFilter := models.StudioFilterType{ IsMissing: &isMissing, @@ -802,7 +747,7 @@ func TestStudioQueryAlias(t *testing.T) { verifyFn := func(ctx context.Context, studio *models.Studio) { t.Helper() - aliases, err := sqlite.StudioReaderWriter.GetAliases(ctx, studio.ID) + aliases, err := db.Studio.GetAliases(ctx, studio.ID) if err != nil { t.Errorf("Error querying studios: %s", err.Error()) } @@ -837,7 +782,7 @@ func TestStudioQueryAlias(t *testing.T) { func TestStudioUpdateAlias(t *testing.T) { if err := withTxn(func(ctx context.Context) error { - qb := sqlite.StudioReaderWriter + qb := db.Studio // create studio to test against const name = "TestStudioUpdateAlias" @@ -934,7 +879,7 @@ func TestStudioQueryFast(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.StudioReaderWriter + sqb := db.Studio for _, f := range filters { for _, ff := range findFilters { _, _, err := sqb.Query(ctx, &f, &ff) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 3cf0f5bce..2bf1bfd16 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -234,3 +234,10 @@ var ( idColumn: goqu.T(movieTable).Col(idColumn), } ) + +var ( + blobTableMgr = &table{ + table: goqu.T(blobTable), + idColumn: goqu.T(blobTable).Col(blobChecksumColumn), + } +) diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index aa6232cb2..9ad4abcaf 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -13,20 +13,31 @@ import ( "github.com/stashapp/stash/pkg/sliceutil/intslice" ) -const tagTable = "tags" -const tagIDColumn = "tag_id" -const tagAliasesTable = "tag_aliases" -const tagAliasColumn = "alias" +const ( + tagTable = "tags" + tagIDColumn = "tag_id" + tagAliasesTable = "tag_aliases" + tagAliasColumn = "alias" + + tagImageBlobColumn = "image_blob" +) type tagQueryBuilder struct { repository + blobJoinQueryBuilder } -var TagReaderWriter = &tagQueryBuilder{ - repository{ - tableName: tagTable, - idColumn: idColumn, - }, +func NewTagReaderWriter(blobStore *BlobStore) *tagQueryBuilder { + return &tagQueryBuilder{ + repository{ + tableName: tagTable, + idColumn: idColumn, + }, + blobJoinQueryBuilder{ + blobStore: blobStore, + joinTable: tagTable, + }, + } } func (qb *tagQueryBuilder) Create(ctx context.Context, newObject models.Tag) (*models.Tag, error) { @@ -57,16 +68,8 @@ func (qb *tagQueryBuilder) UpdateFull(ctx context.Context, updatedObject models. } func (qb *tagQueryBuilder) Destroy(ctx context.Context, id int) error { - // TODO - add delete cascade to foreign key - // delete tag from scenes and markers first - _, err := qb.tx.Exec(ctx, "DELETE FROM scenes_tags WHERE tag_id = ?", id) - if err != nil { - return err - } - - // TODO - add delete cascade to foreign key - _, err = qb.tx.Exec(ctx, "DELETE FROM scene_markers_tags WHERE tag_id = ?", id) - if err != nil { + // must handle image checksums manually + if err := qb.destroyImage(ctx, id); err != nil { return err } @@ -407,8 +410,7 @@ func tagIsMissingCriterionHandler(qb *tagQueryBuilder, isMissing *string) criter if isMissing != nil && *isMissing != "" { switch *isMissing { case "image": - qb.imageRepository().join(f, "", "tags.id") - f.addWhere("tags_image.tag_id IS NULL") + f.addWhere("tags.image_blob IS NULL") default: f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") } @@ -642,31 +644,16 @@ func (qb *tagQueryBuilder) queryTags(ctx context.Context, query string, args []i return []*models.Tag(ret), nil } -func (qb *tagQueryBuilder) imageRepository() *imageRepository { - return &imageRepository{ - repository: repository{ - tx: qb.tx, - tableName: "tags_image", - idColumn: tagIDColumn, - }, - imageColumn: "image", - } -} - func (qb *tagQueryBuilder) GetImage(ctx context.Context, tagID int) ([]byte, error) { - return qb.imageRepository().get(ctx, tagID) -} - -func (qb *tagQueryBuilder) HasImage(ctx context.Context, tagID int) (bool, error) { - return qb.imageRepository().exists(ctx, tagID) + return qb.blobJoinQueryBuilder.GetImage(ctx, tagID, tagImageBlobColumn) } func (qb *tagQueryBuilder) UpdateImage(ctx context.Context, tagID int, image []byte) error { - return qb.imageRepository().replace(ctx, tagID, image) + return qb.blobJoinQueryBuilder.UpdateImage(ctx, tagID, tagImageBlobColumn, image) } -func (qb *tagQueryBuilder) DestroyImage(ctx context.Context, tagID int) error { - return qb.imageRepository().destroy(ctx, []int{tagID}) +func (qb *tagQueryBuilder) destroyImage(ctx context.Context, tagID int) error { + return qb.blobJoinQueryBuilder.DestroyImage(ctx, tagID, tagImageBlobColumn) } func (qb *tagQueryBuilder) aliasRepository() *stringRepository { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index a351f28f4..d3ff5459f 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -19,7 +19,7 @@ import ( func TestMarkerFindBySceneMarkerID(t *testing.T) { withTxn(func(ctx context.Context) error { - tqb := sqlite.TagReaderWriter + tqb := db.Tag markerID := markerIDs[markerIdxWithTag] @@ -46,7 +46,7 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) { func TestTagFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { - tqb := sqlite.TagReaderWriter + tqb := db.Tag name := tagNames[tagIdxWithScene] // find a tag by name @@ -82,7 +82,7 @@ func TestTagQueryIgnoreAutoTag(t *testing.T) { IgnoreAutoTag: &ignoreAutoTag, } - sqb := sqlite.TagReaderWriter + sqb := db.Tag tags := queryTags(ctx, t, sqb, &tagFilter, nil) @@ -97,7 +97,7 @@ func TestTagQueryIgnoreAutoTag(t *testing.T) { func TestTagQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { - tqb := sqlite.TagReaderWriter + tqb := db.Tag name := tagNames[tagIdx1WithScene] // find a tag by name @@ -131,7 +131,7 @@ func TestTagFindByNames(t *testing.T) { var names []string withTxn(func(ctx context.Context) error { - tqb := sqlite.TagReaderWriter + tqb := db.Tag names = append(names, tagNames[tagIdxWithScene]) // find tags by names @@ -176,7 +176,7 @@ func TestTagFindByNames(t *testing.T) { func TestTagQuerySort(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.TagReaderWriter + sqb := db.Tag sortBy := "scenes_count" dir := models.SortDirectionEnumDesc @@ -253,7 +253,7 @@ func TestTagQueryAlias(t *testing.T) { } verifyFn := func(ctx context.Context, tag *models.Tag) { - aliases, err := sqlite.TagReaderWriter.GetAliases(ctx, tag.ID) + aliases, err := db.Tag.GetAliases(ctx, tag.ID) if err != nil { t.Errorf("Error querying tags: %s", err.Error()) } @@ -288,7 +288,7 @@ func TestTagQueryAlias(t *testing.T) { func verifyTagQuery(t *testing.T, tagFilter *models.TagFilterType, findFilter *models.FindFilterType, verifyFn func(ctx context.Context, t *models.Tag)) { withTxn(func(ctx context.Context) error { - sqb := sqlite.TagReaderWriter + sqb := db.Tag tags := queryTags(ctx, t, sqb, tagFilter, findFilter) @@ -312,7 +312,7 @@ func queryTags(ctx context.Context, t *testing.T, qb models.TagReader, tagFilter func TestTagQueryIsMissingImage(t *testing.T) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag isMissing := "image" tagFilter := models.TagFilterType{ IsMissing: &isMissing, @@ -366,7 +366,7 @@ func TestTagQuerySceneCount(t *testing.T) { func verifyTagSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ SceneCount: &sceneCountCriterion, } @@ -408,7 +408,7 @@ func TestTagQueryMarkerCount(t *testing.T) { func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ MarkerCount: &markerCountCriterion, } @@ -450,7 +450,7 @@ func TestTagQueryImageCount(t *testing.T) { func verifyTagImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ ImageCount: &imageCountCriterion, } @@ -492,7 +492,7 @@ func TestTagQueryGalleryCount(t *testing.T) { func verifyTagGalleryCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ GalleryCount: &imageCountCriterion, } @@ -534,7 +534,7 @@ func TestTagQueryPerformerCount(t *testing.T) { func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ PerformerCount: &imageCountCriterion, } @@ -576,7 +576,7 @@ func TestTagQueryParentCount(t *testing.T) { func verifyTagParentCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ ParentCount: &sceneCountCriterion, } @@ -619,7 +619,7 @@ func TestTagQueryChildCount(t *testing.T) { func verifyTagChildCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag tagFilter := models.TagFilterType{ ChildCount: &sceneCountCriterion, } @@ -644,7 +644,7 @@ func verifyTagChildCount(t *testing.T, sceneCountCriterion models.IntCriterionIn func TestTagQueryParent(t *testing.T) { withTxn(func(ctx context.Context) error { const nameField = "Name" - sqb := sqlite.TagReaderWriter + sqb := db.Tag tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithChildTag]), @@ -722,7 +722,7 @@ func TestTagQueryChild(t *testing.T) { withTxn(func(ctx context.Context) error { const nameField = "Name" - sqb := sqlite.TagReaderWriter + sqb := db.Tag tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentTag]), @@ -798,7 +798,7 @@ func TestTagQueryChild(t *testing.T) { func TestTagUpdateTagImage(t *testing.T) { if err := withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag // create tag to test against const name = "TestTagUpdateTagImage" @@ -810,64 +810,7 @@ func TestTagUpdateTagImage(t *testing.T) { return fmt.Errorf("Error creating tag: %s", err.Error()) } - image := []byte("image") - err = qb.UpdateImage(ctx, created.ID, image) - if err != nil { - return fmt.Errorf("Error updating studio image: %s", err.Error()) - } - - // ensure image set - storedImage, err := qb.GetImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Equal(t, storedImage, image) - - // set nil image - err = qb.UpdateImage(ctx, created.ID, nil) - if err == nil { - return fmt.Errorf("Expected error setting nil image") - } - - return nil - }); err != nil { - t.Error(err.Error()) - } -} - -func TestTagDestroyTagImage(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter - - // create performer to test against - const name = "TestTagDestroyTagImage" - tag := models.Tag{ - Name: name, - } - created, err := qb.Create(ctx, tag) - if err != nil { - return fmt.Errorf("Error creating tag: %s", err.Error()) - } - - image := []byte("image") - err = qb.UpdateImage(ctx, created.ID, image) - if err != nil { - return fmt.Errorf("Error updating studio image: %s", err.Error()) - } - - err = qb.DestroyImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error destroying studio image: %s", err.Error()) - } - - // image should be nil - storedImage, err := qb.GetImage(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting image: %s", err.Error()) - } - assert.Nil(t, storedImage) - - return nil + return testUpdateImage(t, ctx, created.ID, qb.UpdateImage, qb.GetImage) }); err != nil { t.Error(err.Error()) } @@ -875,7 +818,7 @@ func TestTagDestroyTagImage(t *testing.T) { func TestTagUpdateAlias(t *testing.T) { if err := withTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag // create tag to test against const name = "TestTagUpdateAlias" @@ -911,7 +854,7 @@ func TestTagMerge(t *testing.T) { // merge tests - perform these in a transaction that we'll rollback if err := withRollbackTxn(func(ctx context.Context) error { - qb := sqlite.TagReaderWriter + qb := db.Tag // try merging into same tag err := qb.Merge(ctx, []int{tagIDs[tagIdx1WithScene]}, tagIDs[tagIdx1WithScene]) diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index f67a2d6e7..743ccce04 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -131,13 +131,13 @@ func (db *Database) TxnRepository() models.Repository { Gallery: db.Gallery, GalleryChapter: GalleryChapterReaderWriter, Image: db.Image, - Movie: MovieReaderWriter, + Movie: db.Movie, Performer: db.Performer, Scene: db.Scene, SceneMarker: SceneMarkerReaderWriter, ScrapedItem: ScrapedItemReaderWriter, - Studio: StudioReaderWriter, - Tag: TagReaderWriter, + Studio: db.Studio, + Tag: db.Tag, SavedFilter: SavedFilterReaderWriter, } } diff --git a/pkg/txn/hooks.go b/pkg/txn/hooks.go index 5f36d7def..2327349cb 100644 --- a/pkg/txn/hooks.go +++ b/pkg/txn/hooks.go @@ -11,9 +11,10 @@ const ( ) type hookManager struct { - postCommitHooks []TxnFunc - postRollbackHooks []TxnFunc - postCompleteHooks []TxnFunc + preCommitHooks []TxnFunc + postCommitHooks []MustFunc + postRollbackHooks []MustFunc + postCompleteHooks []MustFunc } func (m *hookManager) register(ctx context.Context) context.Context { @@ -28,39 +29,55 @@ func hookManagerCtx(ctx context.Context) *hookManager { return m } -func executeHooks(ctx context.Context, hooks []TxnFunc) { +func executeHooks(ctx context.Context, hooks []TxnFunc) error { + // we need to return the first error for _, h := range hooks { - // ignore errors - _ = h(ctx) + if err := h(ctx); err != nil { + return err + } + } + + return nil +} + +func executeMustHooks(ctx context.Context, hooks []MustFunc) { + for _, h := range hooks { + h(ctx) } } -func executePostCommitHooks(ctx context.Context, outerCtx context.Context) { - m := hookManagerCtx(ctx) - executeHooks(outerCtx, m.postCommitHooks) +func (m *hookManager) executePostCommitHooks(ctx context.Context) { + executeMustHooks(ctx, m.postCommitHooks) } -func executePostRollbackHooks(ctx context.Context, outerCtx context.Context) { - m := hookManagerCtx(ctx) - executeHooks(outerCtx, m.postRollbackHooks) +func (m *hookManager) executePostRollbackHooks(ctx context.Context) { + executeMustHooks(ctx, m.postRollbackHooks) } -func executePostCompleteHooks(ctx context.Context, outerCtx context.Context) { - m := hookManagerCtx(ctx) - executeHooks(outerCtx, m.postCompleteHooks) +func (m *hookManager) executePreCommitHooks(ctx context.Context) error { + return executeHooks(ctx, m.preCommitHooks) } -func AddPostCommitHook(ctx context.Context, hook TxnFunc) { +func (m *hookManager) executePostCompleteHooks(ctx context.Context) { + executeMustHooks(ctx, m.postCompleteHooks) +} + +func AddPreCommitHook(ctx context.Context, hook TxnFunc) { + m := hookManagerCtx(ctx) + m.preCommitHooks = append(m.preCommitHooks, hook) +} + +func AddPostCommitHook(ctx context.Context, hook MustFunc) { m := hookManagerCtx(ctx) m.postCommitHooks = append(m.postCommitHooks, hook) } -func AddPostRollbackHook(ctx context.Context, hook TxnFunc) { +func AddPostRollbackHook(ctx context.Context, hook MustFunc) { m := hookManagerCtx(ctx) m.postRollbackHooks = append(m.postRollbackHooks, hook) } -func AddPostCompleteHook(ctx context.Context, hook TxnFunc) { +func AddPostCompleteHook(ctx context.Context, hook MustFunc) { m := hookManagerCtx(ctx) m.postCompleteHooks = append(m.postCompleteHooks, hook) } diff --git a/pkg/txn/transaction.go b/pkg/txn/transaction.go index 0989e438e..751588eff 100644 --- a/pkg/txn/transaction.go +++ b/pkg/txn/transaction.go @@ -17,13 +17,14 @@ type DatabaseProvider interface { WithDatabase(ctx context.Context) (context.Context, error) } -type DatabaseProviderManager interface { - DatabaseProvider - Manager -} - +// TxnFunc is a function that is used in transaction hooks. +// It should return an error if something went wrong. type TxnFunc func(ctx context.Context) error +// MustFunc is a function that is used in transaction hooks. +// It does not return an error. +type MustFunc func(ctx context.Context) + // WithTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. // Transaction is exclusive. Only one thread may run a transaction @@ -51,35 +52,44 @@ func WithReadTxn(ctx context.Context, m Manager, fn TxnFunc) error { return withTxn(ctx, m, fn, exclusive, execComplete) } -func withTxn(outerCtx context.Context, m Manager, fn TxnFunc, exclusive bool, execCompleteOnLocked bool) error { - ctx, err := begin(outerCtx, m, exclusive) +func withTxn(ctx context.Context, m Manager, fn TxnFunc, exclusive bool, execCompleteOnLocked bool) error { + // post-hooks should be executed with the outside context + txnCtx, err := begin(ctx, m, exclusive) if err != nil { return err } + hookMgr := hookManagerCtx(txnCtx) + defer func() { if p := recover(); p != nil { // a panic occurred, rollback and repanic - rollback(ctx, outerCtx, m) + rollback(txnCtx, m) panic(p) } if err != nil { // something went wrong, rollback - rollback(ctx, outerCtx, m) + rollback(txnCtx, m) + + // execute post-hooks with outside context + hookMgr.executePostRollbackHooks(ctx) if execCompleteOnLocked || !m.IsLocked(err) { - executePostCompleteHooks(ctx, outerCtx) + hookMgr.executePostCompleteHooks(ctx) } } else { // all good, commit - err = commit(ctx, outerCtx, m) - executePostCompleteHooks(ctx, outerCtx) + err = commit(txnCtx, m) + + // execute post-hooks with outside context + hookMgr.executePostCommitHooks(ctx) + hookMgr.executePostCompleteHooks(ctx) } }() - err = fn(ctx) + err = fn(txnCtx) return err } @@ -96,21 +106,23 @@ func begin(ctx context.Context, m Manager, exclusive bool) (context.Context, err return ctx, nil } -func commit(ctx context.Context, outerCtx context.Context, m Manager) error { +func commit(ctx context.Context, m Manager) error { + hookMgr := hookManagerCtx(ctx) + if err := hookMgr.executePreCommitHooks(ctx); err != nil { + return err + } + if err := m.Commit(ctx); err != nil { return err } - executePostCommitHooks(ctx, outerCtx) return nil } -func rollback(ctx context.Context, outerCtx context.Context, m Manager) { +func rollback(ctx context.Context, m Manager) { if err := m.Rollback(ctx); err != nil { return } - - executePostRollbackHooks(ctx, outerCtx) } // WithDatabase executes fn with the context provided by p.WithDatabase. diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index bb6d5131f..c5b0f36c1 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -15,8 +15,11 @@ import { VideoPreviewInput, VideoPreviewSettingsInput, } from "./GeneratePreviewOptions"; +import { useIntl } from "react-intl"; export const SettingsConfigurationPanel: React.FC = () => { + const intl = useIntl(); + const { general, loading, error, saveGeneral } = React.useContext(SettingStateContext); @@ -94,20 +97,23 @@ export const SettingsConfigurationPanel: React.FC = () => { return GQL.HashAlgorithm.Md5; } + function blobStorageTypeToID(value: GQL.BlobsStorageType | undefined) { + switch (value) { + case GQL.BlobsStorageType.Database: + return "blobs_storage_type.database"; + case GQL.BlobsStorageType.Filesystem: + return "blobs_storage_type.filesystem"; + } + + return "blobs_storage_type.database"; + } + if (error) return
+ {chunks},
+ }}
+ />
+
+ {chunks},
+ strong: (chunks: string) => {chunks},
+ }}
+ />
+
+ {blobsLocation !== ""
+ ? blobsLocation
+ : intl.formatMessage({
+ id: "setup.confirm.default_blobs_location",
+ })}
+
+