mirror of
https://github.com/stashapp/stash.git
synced 2026-06-21 00:31:49 -05:00
Compare commits
3 Commits
normalize-
...
docs-plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb3b4ee44 | ||
|
|
b7c6e370dc | ||
|
|
19d6f5543d |
75
README.md
75
README.md
@@ -10,8 +10,7 @@
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
|
||||
### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.**
|
||||
|
||||

|
||||

|
||||
|
||||
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
|
||||
* Stash supports a wide variety of both video and image formats.
|
||||
@@ -20,88 +19,80 @@
|
||||
|
||||
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
|
||||
|
||||
For further information you can consult the [documentation](https://docs.stashapp.cc) or access the in-app manual from within the application (also available at [docs.stashapp.cc/in-app-manual](https://docs.stashapp.cc/in-app-manual)).
|
||||
For further information you can consult the [documentation](https://docs.stashapp.cc) or [read the in-app manual](ui/v2.5/src/docs/en).
|
||||
|
||||
# Installing Stash
|
||||
|
||||
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
|
||||
|
||||
#### Windows Users:
|
||||
|
||||
As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
|
||||
At least Windows 10 or Server 2016 is required.
|
||||
As of version 0.27.0, Stash doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._
|
||||
Windows 10 or Server 2016 are at least required.
|
||||
|
||||
#### Mac Users:
|
||||
|
||||
As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
|
||||
Stash can still be run through docker on older versions of macOS.
|
||||
As of version 0.29.0, Stash requires at least _macOS 11 Big Sur._
|
||||
Stash can still be ran through docker on older versions of macOS
|
||||
|
||||
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
|
||||
:---:|:---:|:---:|:---:
|
||||
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
|
||||
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
|
||||
Download links for other platforms and architectures are available on the [Releases page](https://github.com/stashapp/stash/releases).
|
||||
|
||||
## First Run
|
||||
|
||||
#### Windows/macOS Users: Security Prompt
|
||||
|
||||
On Windows or macOS, running the app might present a security prompt since the application binary isn't yet signed.
|
||||
On Windows or macOS, running the app might present a security prompt since the binary isn't yet signed.
|
||||
|
||||
- On Windows, bypass this by clicking "more info" and then the "run anyway" button.
|
||||
- On macOS, Control+Click the app, click "Open", and then "Open" again.
|
||||
On Windows, bypass this by clicking "more info" and then the "run anyway" button. On macOS, Control+Click the app, click "Open", and then "Open" again.
|
||||
|
||||
#### ffmpeg
|
||||
|
||||
Stash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
|
||||
#### FFmpeg
|
||||
Stash requires FFmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
|
||||
|
||||
# Usage
|
||||
|
||||
## Quickstart Guide
|
||||
|
||||
Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.
|
||||
Stash is a web-based application. Once the application is running, the interface is available (by default) from http://localhost:9999.
|
||||
|
||||
On first run, Stash will prompt you for some configuration options and media directories to index, called "Scanning" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging.
|
||||
|
||||
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources:
|
||||
- The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
|
||||
- The project maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
|
||||
- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/).
|
||||
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings → Metadata Providers → Available Scrapers → Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
|
||||
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to Settings -> Metadata Providers -> Available Scrapers -> Community (stable). These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
|
||||
- All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/).
|
||||
|
||||
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
|
||||
|
||||
# Translation
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
|
||||
|
||||
The badge below shows the current translation status of Stash across all supported languages:
|
||||
Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks!
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
# Support & Resources
|
||||
## Join Our Community
|
||||
|
||||
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
|
||||
We are excited to announce that we have a new home for support, feature requests, and discussions related to Stash and its associated projects. Join our community on the [Discourse forum](https://discourse.stashapp.cc) to connect with other users, share your ideas, and get help from fellow enthusiasts.
|
||||
|
||||
- Documentation
|
||||
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.
|
||||
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual.
|
||||
- FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers.
|
||||
- Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips.
|
||||
|
||||
- Community & discussion
|
||||
- Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions.
|
||||
- Discord: https://discord.gg/2TsNFKt - real-time chat and community support.
|
||||
- GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions.
|
||||
- Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space.
|
||||
# Support (FAQ)
|
||||
|
||||
- Community scrapers & plugins
|
||||
- Metadata sources: https://docs.stashapp.cc/metadata-sources/
|
||||
- Plugins: https://docs.stashapp.cc/plugins/
|
||||
- Themes: https://docs.stashapp.cc/themes/
|
||||
- Other projects: https://docs.stashapp.cc/other-projects/
|
||||
Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more.
|
||||
|
||||
For more help you can:
|
||||
* Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual))
|
||||
* Join our [community forum](https://discourse.stashapp.cc)
|
||||
* Join the [Discord server](https://discord.gg/2TsNFKt)
|
||||
* Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions)
|
||||
|
||||
# Customization
|
||||
|
||||
## Themes and CSS Customization
|
||||
|
||||
There is a [directory of community-created themes](https://docs.stashapp.cc/themes/list) on Stash-Docs.
|
||||
|
||||
You can also change the Stash interface to fit your desired style with various snippets from [Custom CSS snippets](https://docs.stashapp.cc/themes/custom-css-snippets).
|
||||
|
||||
# For Developers
|
||||
|
||||
|
||||
@@ -5,39 +5,20 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/hash/imagephash"
|
||||
"github.com/stashapp/stash/pkg/hash/videophash"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func customUsage() {
|
||||
fmt.Fprintf(os.Stderr, "Usage:\n")
|
||||
fmt.Fprintf(os.Stderr, "%s [OPTIONS] FILE...\n\nOptions:\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "%s [OPTIONS] VIDEOFILE...\n\nOptions:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
|
||||
// Determine if this is a video or image file based on extension
|
||||
ext := filepath.Ext(inputfile)
|
||||
ext = ext[1:] // remove the leading dot
|
||||
|
||||
// Common image extensions
|
||||
imageExts := map[string]bool{
|
||||
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true,
|
||||
}
|
||||
|
||||
if imageExts[ext] {
|
||||
return printImagePhash(inputfile, quiet)
|
||||
}
|
||||
|
||||
return printVideoPhash(ff, ffp, inputfile, quiet)
|
||||
}
|
||||
|
||||
func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
|
||||
ffvideoFile, err := ffp.NewVideoFile(inputfile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -65,24 +46,6 @@ func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, q
|
||||
return nil
|
||||
}
|
||||
|
||||
func printImagePhash(inputfile string, quiet *bool) error {
|
||||
imgFile := &models.ImageFile{
|
||||
BaseFile: &models.BaseFile{Path: inputfile},
|
||||
}
|
||||
|
||||
phash, err := imagephash.Generate(imgFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *quiet {
|
||||
fmt.Printf("%x\n", *phash)
|
||||
} else {
|
||||
fmt.Printf("%x %v\n", *phash, imgFile.Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPaths() (string, string) {
|
||||
ffmpegPath, _ := exec.LookPath("ffmpeg")
|
||||
ffprobePath, _ := exec.LookPath("ffprobe")
|
||||
@@ -104,7 +67,7 @@ func main() {
|
||||
args := flag.Args()
|
||||
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "Missing FILE argument.\n")
|
||||
fmt.Fprintf(os.Stderr, "Missing VIDEOFILE argument.\n")
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -124,5 +87,4 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -76,10 +76,6 @@ func main() {
|
||||
defer pprof.StopCPUProfile()
|
||||
}
|
||||
|
||||
// initialise desktop.IsDesktop here so that it doesn't get affected by
|
||||
// ffmpeg hardware checks later on
|
||||
desktop.InitIsDesktop()
|
||||
|
||||
mgr, err := manager.Initialize(cfg, l)
|
||||
if err != nil {
|
||||
exitError(fmt.Errorf("manager initialization error: %w", err))
|
||||
|
||||
31
go.mod
31
go.mod
@@ -7,10 +7,10 @@ require (
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
|
||||
github.com/Yamashou/gqlgenc v0.32.1
|
||||
github.com/anacrolix/dms v1.2.2
|
||||
github.com/antchfx/htmlquery v1.3.5
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
github.com/asticode/go-astisub v0.25.1
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617
|
||||
github.com/chromedp/chromedp v0.9.2
|
||||
github.com/corona10/goimagehash v1.1.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
|
||||
@@ -55,12 +55,12 @@ require (
|
||||
github.com/vektra/mockery/v2 v2.10.0
|
||||
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
|
||||
github.com/zencoder/go-dash/v3 v3.0.2
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -69,21 +69,20 @@ require (
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/antchfx/xpath v1.3.5 // indirect
|
||||
github.com/antchfx/xpath v1.2.3 // indirect
|
||||
github.com/asticode/go-astikit v0.20.0 // indirect
|
||||
github.com/asticode/go-astits v1.8.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
@@ -91,8 +90,10 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
@@ -120,9 +121,9 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.3 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
110
go.sum
110
go.sum
@@ -85,10 +85,10 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
|
||||
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
|
||||
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
|
||||
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
@@ -116,12 +116,13 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w=
|
||||
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
|
||||
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
@@ -205,8 +206,6 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
@@ -225,8 +224,9 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -286,7 +286,6 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -380,6 +379,8 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@@ -431,6 +432,8 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
@@ -661,12 +664,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -708,12 +707,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -762,14 +757,9 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -799,13 +789,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -884,27 +869,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -915,14 +889,9 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -987,11 +956,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -165,12 +165,6 @@ type Query {
|
||||
input: ScrapeSingleStudioInput!
|
||||
): [ScrapedStudio!]!
|
||||
|
||||
"Scrape for a single tag"
|
||||
scrapeSingleTag(
|
||||
source: ScraperSourceInput!
|
||||
input: ScrapeSingleTagInput!
|
||||
): [ScrapedTag!]!
|
||||
|
||||
"Scrape for a single performer"
|
||||
scrapeSinglePerformer(
|
||||
source: ScraperSourceInput!
|
||||
@@ -373,7 +367,6 @@ type Mutation {
|
||||
performerDestroy(input: PerformerDestroyInput!): Boolean!
|
||||
performersDestroy(ids: [ID!]!): Boolean!
|
||||
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
|
||||
performerMerge(input: PerformerMergeInput!): Performer!
|
||||
|
||||
studioCreate(input: StudioCreateInput!): Studio
|
||||
studioUpdate(input: StudioUpdateInput!): Studio
|
||||
@@ -422,8 +415,6 @@ type Mutation {
|
||||
"""
|
||||
moveFiles(input: MoveFilesInput!): Boolean!
|
||||
deleteFiles(ids: [ID!]!): Boolean!
|
||||
"Deletes file entries from the database without deleting the files from the filesystem"
|
||||
destroyFiles(ids: [ID!]!): Boolean!
|
||||
|
||||
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!
|
||||
|
||||
|
||||
@@ -69,8 +69,6 @@ input ConfigGeneralInput {
|
||||
databasePath: String
|
||||
"Path to backup directory"
|
||||
backupDirectoryPath: String
|
||||
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
|
||||
deleteTrashPath: String
|
||||
"Path to generated files"
|
||||
generatedPath: String
|
||||
"Path to import/export files"
|
||||
@@ -193,8 +191,6 @@ type ConfigGeneralResult {
|
||||
databasePath: String!
|
||||
"Path to backup directory"
|
||||
backupDirectoryPath: String!
|
||||
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
|
||||
deleteTrashPath: String!
|
||||
"Path to generated files"
|
||||
generatedPath: String!
|
||||
"Path to import/export files"
|
||||
@@ -319,7 +315,6 @@ input ConfigDisableDropdownCreateInput {
|
||||
tag: Boolean
|
||||
studio: Boolean
|
||||
movie: Boolean
|
||||
gallery: Boolean
|
||||
}
|
||||
|
||||
enum ImageLightboxDisplayMode {
|
||||
@@ -340,7 +335,6 @@ input ConfigImageLightboxInput {
|
||||
resetZoomOnNav: Boolean
|
||||
scrollMode: ImageLightboxScrollMode
|
||||
scrollAttemptsBeforeChange: Int
|
||||
disableAnimation: Boolean
|
||||
}
|
||||
|
||||
type ConfigImageLightboxResult {
|
||||
@@ -350,7 +344,6 @@ type ConfigImageLightboxResult {
|
||||
resetZoomOnNav: Boolean
|
||||
scrollMode: ImageLightboxScrollMode
|
||||
scrollAttemptsBeforeChange: Int!
|
||||
disableAnimation: Boolean
|
||||
}
|
||||
|
||||
input ConfigInterfaceInput {
|
||||
@@ -395,9 +388,6 @@ input ConfigInterfaceInput {
|
||||
customLocales: String
|
||||
customLocalesEnabled: Boolean
|
||||
|
||||
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
|
||||
disableCustomizations: Boolean
|
||||
|
||||
"Interface language"
|
||||
language: String
|
||||
|
||||
@@ -423,7 +413,6 @@ type ConfigDisableDropdownCreate {
|
||||
tag: Boolean!
|
||||
studio: Boolean!
|
||||
movie: Boolean!
|
||||
gallery: Boolean!
|
||||
}
|
||||
|
||||
type ConfigInterfaceResult {
|
||||
@@ -472,9 +461,6 @@ type ConfigInterfaceResult {
|
||||
customLocales: String
|
||||
customLocalesEnabled: Boolean
|
||||
|
||||
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
|
||||
disableCustomizations: Boolean
|
||||
|
||||
"Interface language"
|
||||
language: String
|
||||
|
||||
|
||||
@@ -82,22 +82,12 @@ input PHashDuplicationCriterionInput {
|
||||
}
|
||||
|
||||
input StashIDCriterionInput {
|
||||
"""
|
||||
If present, this value is treated as a predicate.
|
||||
That is, it will filter based on stash_id with the matching endpoint
|
||||
"""
|
||||
endpoint: String
|
||||
stash_id: String
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
input StashIDsCriterionInput {
|
||||
"""
|
||||
If present, this value is treated as a predicate.
|
||||
That is, it will filter based on stash_ids with the matching endpoint
|
||||
"""
|
||||
endpoint: String
|
||||
stash_ids: [String]
|
||||
stash_id: String
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
@@ -166,9 +156,6 @@ input PerformerFilterType {
|
||||
o_counter: IntCriterionInput
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
"Filter by StashIDs"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
# rating expressed as 1-100
|
||||
rating100: IntCriterionInput
|
||||
"Filter by url"
|
||||
@@ -305,11 +292,6 @@ input SceneFilterType {
|
||||
performer_count: IntCriterionInput
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
"Filter by StashIDs"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
"Filter by StashID count"
|
||||
stash_id_count: IntCriterionInput
|
||||
"Filter by url"
|
||||
url: StringCriterionInput
|
||||
"Filter by interactive"
|
||||
@@ -450,9 +432,6 @@ input StudioFilterType {
|
||||
parents: MultiCriterionInput
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
"Filter by StashIDs"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
"Filter to only include studios with these tags"
|
||||
tags: HierarchicalMultiCriterionInput
|
||||
"Filter to only include studios missing this property"
|
||||
@@ -487,8 +466,6 @@ input StudioFilterType {
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
|
||||
custom_fields: [CustomFieldCriterionInput!]
|
||||
}
|
||||
|
||||
input GalleryFilterType {
|
||||
@@ -629,13 +606,6 @@ input TagFilterType {
|
||||
"Filter by autotag ignore value"
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
|
||||
"Filter by StashID"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
|
||||
"Filter by related scenes that meet this criteria"
|
||||
scenes_filter: SceneFilterType
|
||||
"Filter by related images that meet this criteria"
|
||||
@@ -662,8 +632,6 @@ input ImageFilterType {
|
||||
id: IntCriterionInput
|
||||
"Filter by file checksum"
|
||||
checksum: StringCriterionInput
|
||||
"Filter by file phash distance"
|
||||
phash_distance: PhashDistanceCriterionInput
|
||||
"Filter by path"
|
||||
path: StringCriterionInput
|
||||
"Filter by file count"
|
||||
|
||||
@@ -100,8 +100,6 @@ input GalleryDestroyInput {
|
||||
"""
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
type FindGalleriesResultType {
|
||||
|
||||
@@ -82,16 +82,12 @@ input ImageDestroyInput {
|
||||
id: ID!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
input ImagesDestroyInput {
|
||||
ids: [ID!]!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
type FindImagesResultType {
|
||||
|
||||
@@ -10,11 +10,8 @@ input GenerateMetadataInput {
|
||||
transcodes: Boolean
|
||||
"Generate transcodes even if not required"
|
||||
forceTranscodes: Boolean
|
||||
"Generate video phashes during scan"
|
||||
phashes: Boolean
|
||||
interactiveHeatmapsSpeeds: Boolean
|
||||
"Generate image phashes during scan"
|
||||
imagePhashes: Boolean
|
||||
imageThumbnails: Boolean
|
||||
clipPreviews: Boolean
|
||||
|
||||
@@ -22,10 +19,6 @@ input GenerateMetadataInput {
|
||||
sceneIDs: [ID!]
|
||||
"marker ids to generate for"
|
||||
markerIDs: [ID!]
|
||||
"image ids to generate for"
|
||||
imageIDs: [ID!]
|
||||
"gallery ids to generate for"
|
||||
galleryIDs: [ID!]
|
||||
|
||||
"overwrite existing media"
|
||||
overwrite: Boolean
|
||||
@@ -92,10 +85,8 @@ input ScanMetadataInput {
|
||||
scanGenerateImagePreviews: Boolean
|
||||
"Generate sprites during scan"
|
||||
scanGenerateSprites: Boolean
|
||||
"Generate video phashes during scan"
|
||||
"Generate phashes during scan"
|
||||
scanGeneratePhashes: Boolean
|
||||
"Generate image phashes during scan"
|
||||
scanGenerateImagePhashes: Boolean
|
||||
"Generate image thumbnails during scan"
|
||||
scanGenerateThumbnails: Boolean
|
||||
"Generate image clip previews during scan"
|
||||
@@ -116,10 +107,8 @@ type ScanMetadataOptions {
|
||||
scanGenerateImagePreviews: Boolean!
|
||||
"Generate sprites during scan"
|
||||
scanGenerateSprites: Boolean!
|
||||
"Generate video phashes during scan"
|
||||
"Generate phashes during scan"
|
||||
scanGeneratePhashes: Boolean!
|
||||
"Generate image phashes during scan"
|
||||
scanGenerateImagePhashes: Boolean
|
||||
"Generate image thumbnails during scan"
|
||||
scanGenerateThumbnails: Boolean!
|
||||
"Generate image clip previews during scan"
|
||||
@@ -355,6 +344,4 @@ input CustomFieldsInput {
|
||||
full: Map
|
||||
"If populated, only the keys in this map will be updated"
|
||||
partial: Map
|
||||
"Remove any keys in this list"
|
||||
remove: [String!]
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@ input PerformerCreateInput {
|
||||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
alias_list: [String!]
|
||||
twitter: String @deprecated(reason: "Use urls")
|
||||
instagram: String @deprecated(reason: "Use urls")
|
||||
@@ -119,7 +118,6 @@ input PerformerUpdateInput {
|
||||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
alias_list: [String!]
|
||||
twitter: String @deprecated(reason: "Use urls")
|
||||
instagram: String @deprecated(reason: "Use urls")
|
||||
@@ -163,7 +161,6 @@ input BulkPerformerUpdateInput {
|
||||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
|
||||
alias_list: BulkUpdateStrings
|
||||
twitter: String @deprecated(reason: "Use urls")
|
||||
instagram: String @deprecated(reason: "Use urls")
|
||||
@@ -188,10 +185,3 @@ type FindPerformersResultType {
|
||||
count: Int!
|
||||
performers: [Performer!]!
|
||||
}
|
||||
|
||||
input PerformerMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: PerformerUpdateInput
|
||||
}
|
||||
|
||||
@@ -196,16 +196,12 @@ input SceneDestroyInput {
|
||||
id: ID!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
input ScenesDestroyInput {
|
||||
ids: [ID!]!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
type FindScenesResultType {
|
||||
|
||||
@@ -198,13 +198,6 @@ input ScrapeSingleStudioInput {
|
||||
query: String
|
||||
}
|
||||
|
||||
input ScrapeSingleTagInput {
|
||||
"""
|
||||
Query can be either a name or a Stash ID
|
||||
"""
|
||||
query: String
|
||||
}
|
||||
|
||||
input ScrapeSinglePerformerInput {
|
||||
"Instructs to query by string"
|
||||
query: String
|
||||
@@ -288,10 +281,7 @@ type StashBoxFingerprint {
|
||||
duration: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Accepts either ids, or a combination of names and stash_ids.
|
||||
If none are set, then all existing items will be tagged.
|
||||
"""
|
||||
"If neither ids nor names are set, tag all items"
|
||||
input StashBoxBatchTagInput {
|
||||
"Stash endpoint to use for the tagging"
|
||||
endpoint: Int @deprecated(reason: "use stash_box_endpoint")
|
||||
@@ -303,17 +293,12 @@ input StashBoxBatchTagInput {
|
||||
refresh: Boolean!
|
||||
"If batch adding studios, should their parent studios also be created?"
|
||||
createParent: Boolean!
|
||||
"""
|
||||
IDs in stash of the items to update.
|
||||
If set, names and stash_ids fields will be ignored.
|
||||
"""
|
||||
"If set, only tag these ids"
|
||||
ids: [ID!]
|
||||
"Names of the items in the stash-box instance to search for and create"
|
||||
"If set, only tag these names"
|
||||
names: [String!]
|
||||
"Stash IDs of the items in the stash-box instance to search for and create"
|
||||
stash_ids: [String!]
|
||||
"IDs in stash of the performers to update"
|
||||
"If set, only tag these performer ids"
|
||||
performer_ids: [ID!] @deprecated(reason: "use ids")
|
||||
"Names of the performers in the stash-box instance to search for and create"
|
||||
"If set, only tag these performer names"
|
||||
performer_names: [String!] @deprecated(reason: "use names")
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ type Studio {
|
||||
updated_at: Time!
|
||||
groups: [Group!]!
|
||||
movies: [Movie!]! @deprecated(reason: "use groups instead")
|
||||
o_counter: Int
|
||||
|
||||
custom_fields: Map!
|
||||
}
|
||||
|
||||
input StudioCreateInput {
|
||||
@@ -42,12 +39,9 @@ input StudioCreateInput {
|
||||
rating100: Int
|
||||
favorite: Boolean
|
||||
details: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
tag_ids: [ID!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input StudioUpdateInput {
|
||||
@@ -63,12 +57,9 @@ input StudioUpdateInput {
|
||||
rating100: Int
|
||||
favorite: Boolean
|
||||
details: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
tag_ids: [ID!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input BulkStudioUpdateInput {
|
||||
|
||||
@@ -31,7 +31,6 @@ input TagCreateInput {
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
favorite: Boolean
|
||||
@@ -49,7 +48,6 @@ input TagUpdateInput {
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
favorite: Boolean
|
||||
@@ -78,7 +76,6 @@ input TagsMergeInput {
|
||||
input BulkTagUpdateInput {
|
||||
ids: [ID!]
|
||||
description: String
|
||||
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
|
||||
aliases: BulkUpdateStrings
|
||||
ignore_auto_tag: Boolean
|
||||
favorite: Boolean
|
||||
|
||||
@@ -170,21 +170,6 @@ query FindStudio($id: ID, $name: String) {
|
||||
}
|
||||
}
|
||||
|
||||
query FindTag($id: ID, $name: String) {
|
||||
findTag(id: $id, name: $name) {
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
|
||||
query QueryTags($input: TagQueryInput!) {
|
||||
queryTags(input: $input) {
|
||||
count
|
||||
tags {
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation SubmitFingerprint($input: FingerprintSubmission!) {
|
||||
submitFingerprint(input: $input)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (t changesetTranslator) string(value *string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(*value)
|
||||
return *value
|
||||
}
|
||||
|
||||
func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
|
||||
@@ -106,12 +106,7 @@ func (t changesetTranslator) optionalString(value *string, field string) models.
|
||||
return models.OptionalString{}
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
return models.NewOptionalStringPtr(nil)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
return models.NewOptionalString(trimmed)
|
||||
return models.NewOptionalStringPtr(value)
|
||||
}
|
||||
|
||||
func (t changesetTranslator) optionalDate(value *string, field string) (models.OptionalDate, error) {
|
||||
@@ -323,14 +318,8 @@ func (t changesetTranslator) updateStrings(value []string, field string) *models
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trim whitespace from each string
|
||||
trimmedValues := make([]string, len(value))
|
||||
for i, v := range value {
|
||||
trimmedValues[i] = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
return &models.UpdateStrings{
|
||||
Values: trimmedValues,
|
||||
Values: value,
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
@@ -340,14 +329,8 @@ func (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field s
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trim whitespace from each string
|
||||
trimmedValues := make([]string, len(value.Values))
|
||||
for i, v := range value.Values {
|
||||
trimmedValues[i] = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
return &models.UpdateStrings{
|
||||
Values: trimmedValues,
|
||||
Values: value.Values,
|
||||
Mode: value.Mode,
|
||||
}
|
||||
}
|
||||
@@ -465,7 +448,7 @@ func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.
|
||||
GroupID: gID,
|
||||
}
|
||||
if v.Description != nil {
|
||||
ret[i].Description = strings.TrimSpace(*v.Description)
|
||||
ret[i].Description = *v.Description
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package api
|
||||
|
||||
import "github.com/stashapp/stash/pkg/models"
|
||||
|
||||
func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput {
|
||||
ret := input
|
||||
// convert json.Numbers to int/float
|
||||
ret.Full = convertMapJSONNumbers(ret.Full)
|
||||
ret.Partial = convertMapJSONNumbers(ret.Partial)
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -26,7 +26,6 @@ var imageBoxExts = []string{
|
||||
".gif",
|
||||
".svg",
|
||||
".webp",
|
||||
".avif",
|
||||
}
|
||||
|
||||
func newImageBox(box fs.FS) (*imageBox, error) {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
// TODO - apply handleIDs to other resolvers that accept ID lists
|
||||
|
||||
// handleIDList validates and converts a list of string IDs to integers
|
||||
func handleIDList(idList []string, field string) ([]int, error) {
|
||||
if err := validateIDList(idList); err != nil {
|
||||
return nil, fmt.Errorf("validating %s: %w", field, err)
|
||||
}
|
||||
|
||||
ids, err := stringslice.StringSliceToIntSlice(idList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting %s: %w", field, err)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// validateIDList returns an error if there are any duplicate ids in the list
|
||||
func validateIDList(ids []string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, id := range ids {
|
||||
if _, exists := seen[id]; exists {
|
||||
return fmt.Errorf("duplicate id found: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -59,9 +59,7 @@ type Loaders struct {
|
||||
PerformerByID *PerformerLoader
|
||||
PerformerCustomFields *CustomFieldsLoader
|
||||
|
||||
StudioByID *StudioLoader
|
||||
StudioCustomFields *CustomFieldsLoader
|
||||
|
||||
StudioByID *StudioLoader
|
||||
TagByID *TagLoader
|
||||
GroupByID *GroupLoader
|
||||
FileByID *FileLoader
|
||||
@@ -101,11 +99,6 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchPerformerCustomFields(ctx),
|
||||
},
|
||||
StudioCustomFields: &CustomFieldsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchStudioCustomFields(ctx),
|
||||
},
|
||||
StudioByID: &StudioLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -260,18 +253,6 @@ func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*model
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
|
||||
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys)
|
||||
return err
|
||||
})
|
||||
|
||||
return ret, toErrorSlice(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) {
|
||||
return func(keys []int) (ret []*models.Tag, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
|
||||
@@ -143,24 +143,6 @@ func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, dep
|
||||
return r.GroupCount(ctx, obj, depth)
|
||||
}
|
||||
|
||||
func (r *studioResolver) OCounter(ctx context.Context, obj *models.Studio) (ret *int, err error) {
|
||||
var res_scene int
|
||||
var res_image int
|
||||
var res int
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
res_scene, err = r.repository.Scene.OCountByStudioID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res_image, err = r.repository.Image.OCountByStudioID(ctx, obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = res_scene + res_image
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {
|
||||
if obj.ParentID == nil {
|
||||
return nil, nil
|
||||
@@ -207,19 +189,6 @@ func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) {
|
||||
m, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deprecated
|
||||
func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {
|
||||
return r.Groups(ctx, obj)
|
||||
|
||||
@@ -150,15 +150,6 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
||||
c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)
|
||||
}
|
||||
|
||||
existingDeleteTrashPath := c.GetDeleteTrashPath()
|
||||
if input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath {
|
||||
if err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil {
|
||||
return makeConfigGeneralResult(), err
|
||||
}
|
||||
|
||||
c.SetString(config.DeleteTrashPath, *input.DeleteTrashPath)
|
||||
}
|
||||
|
||||
existingGeneratedPath := c.GetGeneratedPath()
|
||||
if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath {
|
||||
if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil {
|
||||
@@ -493,8 +484,6 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
|
||||
r.setConfigString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))
|
||||
|
||||
r.setConfigInt(config.ImageLightboxScrollAttemptsBeforeChange, options.ScrollAttemptsBeforeChange)
|
||||
|
||||
r.setConfigBool(config.ImageLightboxDisableAnimation, options.DisableAnimation)
|
||||
}
|
||||
|
||||
if input.CSS != nil {
|
||||
@@ -515,15 +504,12 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
|
||||
|
||||
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
|
||||
|
||||
r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)
|
||||
|
||||
if input.DisableDropdownCreate != nil {
|
||||
ddc := input.DisableDropdownCreate
|
||||
r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)
|
||||
r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio)
|
||||
r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag)
|
||||
r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie)
|
||||
r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery)
|
||||
}
|
||||
|
||||
r.setConfigString(config.HandyKey, input.HandyKey)
|
||||
|
||||
@@ -149,9 +149,7 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
|
||||
return false, fmt.Errorf("converting ids: %w", err)
|
||||
}
|
||||
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
fileDeleter := file.NewDeleterWithTrash(trashPath)
|
||||
fileDeleter := file.NewDeleter()
|
||||
destroyer := &file.ZipDestroyer{
|
||||
FileDestroyer: r.repository.File,
|
||||
FolderDestroyer: r.repository.Folder,
|
||||
@@ -210,58 +208,6 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) {
|
||||
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("converting ids: %w", err)
|
||||
}
|
||||
|
||||
destroyer := &file.ZipDestroyer{
|
||||
FileDestroyer: r.repository.File,
|
||||
FolderDestroyer: r.repository.Folder,
|
||||
}
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.File
|
||||
|
||||
for _, fileIDInt := range fileIDs {
|
||||
fileID := models.FileID(fileIDInt)
|
||||
f, err := qb.Find(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(f) == 0 {
|
||||
return fmt.Errorf("file with id %d not found", fileID)
|
||||
}
|
||||
|
||||
path := f[0].Base().Path
|
||||
|
||||
// ensure not a primary file
|
||||
isPrimary, err := qb.IsPrimary(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking if file %s is primary: %w", path, err)
|
||||
}
|
||||
|
||||
if isPrimary {
|
||||
return fmt.Errorf("cannot destroy primary file entry %s", path)
|
||||
}
|
||||
|
||||
// destroy DB entries only (no filesystem deletion)
|
||||
const deleteFile = false
|
||||
if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil {
|
||||
return fmt.Errorf("destroying file entry %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) {
|
||||
fileIDInt, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
@@ -44,12 +43,11 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
|
||||
// Populate a new gallery from the input
|
||||
newGallery := models.NewGallery()
|
||||
|
||||
newGallery.Title = strings.TrimSpace(input.Title)
|
||||
newGallery.Title = input.Title
|
||||
newGallery.Code = translator.string(input.Code)
|
||||
newGallery.Details = translator.string(input.Details)
|
||||
newGallery.Photographer = translator.string(input.Photographer)
|
||||
newGallery.Rating = input.Rating100
|
||||
newGallery.Organized = translator.bool(input.Organized)
|
||||
|
||||
var err error
|
||||
|
||||
@@ -76,9 +74,9 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newGallery.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
newGallery.URLs = models.NewRelatedStrings(input.Urls)
|
||||
} else if input.URL != nil {
|
||||
newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
|
||||
newGallery.URLs = models.NewRelatedStrings([]string{*input.URL})
|
||||
}
|
||||
|
||||
// Start the transaction and save the gallery
|
||||
@@ -335,18 +333,15 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
|
||||
return false, fmt.Errorf("converting ids: %w", err)
|
||||
}
|
||||
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
var galleries []*models.Gallery
|
||||
var imgsDestroyed []*models.Image
|
||||
fileDeleter := &image.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
|
||||
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
|
||||
deleteFile := utils.IsTrue(input.DeleteFile)
|
||||
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Gallery
|
||||
@@ -367,7 +362,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
|
||||
|
||||
galleries = append(galleries, gallery)
|
||||
|
||||
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/group"
|
||||
@@ -22,7 +21,7 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
|
||||
// Populate a new group from the input
|
||||
newGroup := models.NewGroup()
|
||||
|
||||
newGroup.Name = strings.TrimSpace(input.Name)
|
||||
newGroup.Name = input.Name
|
||||
newGroup.Aliases = translator.string(input.Aliases)
|
||||
newGroup.Duration = input.Duration
|
||||
newGroup.Rating = input.Rating100
|
||||
@@ -56,7 +55,7 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
newGroup.URLs = models.NewRelatedStrings(input.Urls)
|
||||
}
|
||||
|
||||
return &newGroup, nil
|
||||
|
||||
@@ -308,11 +308,9 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
|
||||
return false, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
var i *models.Image
|
||||
fileDeleter := &image.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
@@ -325,7 +323,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
|
||||
return fmt.Errorf("image with id %d not found", imageID)
|
||||
}
|
||||
|
||||
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry))
|
||||
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile))
|
||||
}); err != nil {
|
||||
fileDeleter.Rollback()
|
||||
return false, err
|
||||
@@ -350,11 +348,9 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
|
||||
return false, fmt.Errorf("converting ids: %w", err)
|
||||
}
|
||||
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
var images []*models.Image
|
||||
fileDeleter := &image.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
@@ -372,7 +368,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
|
||||
|
||||
images = append(images, i)
|
||||
|
||||
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil {
|
||||
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -33,7 +32,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
|
||||
// Populate a new group from the input
|
||||
newGroup := models.NewGroup()
|
||||
|
||||
newGroup.Name = strings.TrimSpace(input.Name)
|
||||
newGroup.Name = input.Name
|
||||
newGroup.Aliases = translator.string(input.Aliases)
|
||||
newGroup.Duration = input.Duration
|
||||
newGroup.Rating = input.Rating100
|
||||
@@ -57,9 +56,9 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
newGroup.URLs = models.NewRelatedStrings(input.Urls)
|
||||
} else if input.URL != nil {
|
||||
newGroup.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
|
||||
newGroup.URLs = models.NewRelatedStrings([]string{*input.URL})
|
||||
}
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
|
||||
@@ -2,16 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
@@ -41,9 +37,9 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
// Populate a new performer from the input
|
||||
newPerformer := models.NewPerformer()
|
||||
|
||||
newPerformer.Name = strings.TrimSpace(input.Name)
|
||||
newPerformer.Name = input.Name
|
||||
newPerformer.Disambiguation = translator.string(input.Disambiguation)
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
|
||||
newPerformer.Aliases = models.NewRelatedStrings(input.AliasList)
|
||||
newPerformer.Gender = input.Gender
|
||||
newPerformer.Ethnicity = translator.string(input.Ethnicity)
|
||||
newPerformer.Country = translator.string(input.Country)
|
||||
@@ -66,17 +62,17 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
|
||||
newPerformer.URLs = models.NewRelatedStrings([]string{})
|
||||
if input.URL != nil {
|
||||
newPerformer.URLs.Add(strings.TrimSpace(*input.URL))
|
||||
newPerformer.URLs.Add(*input.URL)
|
||||
}
|
||||
if input.Twitter != nil {
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Twitter), twitterURL))
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(*input.Twitter, twitterURL))
|
||||
}
|
||||
if input.Instagram != nil {
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Instagram), instagramURL))
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(*input.Instagram, instagramURL))
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newPerformer.URLs.Add(stringslice.TrimSpace(input.Urls)...)
|
||||
newPerformer.URLs.Add(input.Urls...)
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -139,7 +135,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
return r.getPerformer(ctx, newPerformer.ID)
|
||||
}
|
||||
|
||||
func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if translator.hasField("url") {
|
||||
return fmt.Errorf("url field must not be included if urls is included")
|
||||
@@ -154,7 +150,7 @@ func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
// we need to be careful with URL/Twitter/Instagram
|
||||
@@ -173,23 +169,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs := p.URLs.List()
|
||||
|
||||
// performer partial URLs should be empty
|
||||
if legacyURLs.URL.Set {
|
||||
if legacyURL.Set {
|
||||
replaced := false
|
||||
for i, url := range existingURLs {
|
||||
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
|
||||
existingURLs[i] = legacyURLs.URL.Value
|
||||
existingURLs[i] = legacyURL.Value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
existingURLs = append(existingURLs, legacyURLs.URL.Value)
|
||||
existingURLs = append(existingURLs, legacyURL.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if legacyURLs.Twitter.Set {
|
||||
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
|
||||
if legacyTwitter.Set {
|
||||
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
|
||||
found := false
|
||||
// find and replace the first twitter URL
|
||||
for i, url := range existingURLs {
|
||||
@@ -204,9 +200,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs = append(existingURLs, value)
|
||||
}
|
||||
}
|
||||
if legacyURLs.Instagram.Set {
|
||||
if legacyInstagram.Set {
|
||||
found := false
|
||||
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
|
||||
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
|
||||
// find and replace the first instagram URL
|
||||
for i, url := range existingURLs {
|
||||
if performer.IsInstagramURL(url) {
|
||||
@@ -229,25 +225,16 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
return nil
|
||||
}
|
||||
|
||||
type legacyPerformerURLs struct {
|
||||
URL models.OptionalString
|
||||
Twitter models.OptionalString
|
||||
Instagram models.OptionalString
|
||||
}
|
||||
|
||||
func (u *legacyPerformerURLs) AnySet() bool {
|
||||
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
|
||||
}
|
||||
|
||||
func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {
|
||||
return legacyPerformerURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
|
||||
// Populate performer from the input
|
||||
updatedPerformer := models.NewPerformerPartial()
|
||||
|
||||
@@ -272,17 +259,19 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
|
||||
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
||||
|
||||
var err error
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting birthdate: %w", err)
|
||||
@@ -307,27 +296,10 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
|
||||
|
||||
return &updatedPerformer, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
updatedPerformer, err := performerPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
legacyURLs := legacyPerformerURLsFromInput(input, translator)
|
||||
updatedPerformer.CustomFields = input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
|
||||
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
@@ -342,38 +314,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if updatedPerformer.Aliases != nil {
|
||||
p, err := qb.Find(ctx, performerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p != nil {
|
||||
if err := p.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())
|
||||
name := p.Name
|
||||
if updatedPerformer.Name.Set {
|
||||
name = updatedPerformer.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedPerformer.Aliases.Values = sanitized
|
||||
updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
|
||||
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)
|
||||
_, err = qb.UpdatePartial(ctx, performerID, updatedPerformer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -430,18 +381,16 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURLs := legacyPerformerURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
@@ -467,10 +416,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
if input.CustomFields != nil {
|
||||
updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields)
|
||||
}
|
||||
|
||||
ret := []*models.Performer{}
|
||||
|
||||
// Start the transaction and save the performers
|
||||
@@ -478,8 +423,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
qb := r.repository.Performer
|
||||
|
||||
for _, performerID := range performerIDs {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -559,87 +504,3 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
|
||||
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting source ids: %w", err)
|
||||
}
|
||||
|
||||
// ensure source ids are unique
|
||||
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
|
||||
|
||||
destID, err := strconv.Atoi(input.Destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting destination id: %w", err)
|
||||
}
|
||||
|
||||
// ensure destination is not in source list
|
||||
if slices.Contains(srcIDs, destID) {
|
||||
return nil, errors.New("destination performer cannot be in source list")
|
||||
}
|
||||
|
||||
var values *models.PerformerPartial
|
||||
var imageData []byte
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = performerPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
|
||||
if legacyURLs.AnySet() {
|
||||
return nil, errors.New("Merging legacy performer URLs is not supported")
|
||||
}
|
||||
|
||||
if input.Values.Image != nil {
|
||||
var err error
|
||||
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := models.NewPerformerPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var dest *models.Performer
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
dest, err = qb.Find(ctx, destID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding destination performer ID %d: %w", destID, err)
|
||||
}
|
||||
|
||||
// ensure source performers exist
|
||||
if _, err := qb.FindMany(ctx, srcIDs); err != nil {
|
||||
return fmt.Errorf("finding source performers: %w", err)
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {
|
||||
return fmt.Errorf("updating performer: %w", err)
|
||||
}
|
||||
|
||||
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
|
||||
return fmt.Errorf("merging performers: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput
|
||||
|
||||
f := models.SavedFilter{
|
||||
Mode: input.Mode,
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Name: input.Name,
|
||||
FindFilter: input.FindFilter,
|
||||
ObjectFilter: input.ObjectFilter,
|
||||
UIOptions: input.UIOptions,
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
@@ -63,9 +62,9 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newScene.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
newScene.URLs = models.NewRelatedStrings(input.Urls)
|
||||
} else if input.URL != nil {
|
||||
newScene.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
|
||||
newScene.URLs = models.NewRelatedStrings([]string{*input.URL})
|
||||
}
|
||||
|
||||
newScene.PerformerIDs, err = translator.relatedIds(input.PerformerIds)
|
||||
@@ -297,7 +296,6 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
coverImageIncluded := translator.hasField("cover_image")
|
||||
if input.CoverImage != nil {
|
||||
var err error
|
||||
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
|
||||
@@ -311,21 +309,21 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if coverImageIncluded {
|
||||
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
|
||||
qb := r.repository.Scene
|
||||
if len(coverImageData) > 0 {
|
||||
qb := r.repository.Scene
|
||||
|
||||
// update cover table - empty data will clear the cover
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
// update cover table
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -430,18 +428,16 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||
}
|
||||
|
||||
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
var s *models.Scene
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
FileNamingAlgo: fileNamingAlgo,
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
|
||||
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
|
||||
deleteFile := utils.IsTrue(input.DeleteFile)
|
||||
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Scene
|
||||
@@ -458,7 +454,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||
// kill any running encoders
|
||||
manager.KillRunningStreams(s, fileNamingAlgo)
|
||||
|
||||
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile)
|
||||
}); err != nil {
|
||||
fileDeleter.Rollback()
|
||||
return false, err
|
||||
@@ -486,17 +482,15 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||
|
||||
var scenes []*models.Scene
|
||||
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
FileNamingAlgo: fileNamingAlgo,
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
|
||||
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
|
||||
deleteFile := utils.IsTrue(input.DeleteFile)
|
||||
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Scene
|
||||
@@ -515,7 +509,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||
// kill any running encoders
|
||||
manager.KillRunningStreams(scene, fileNamingAlgo)
|
||||
|
||||
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -599,9 +593,8 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
}
|
||||
|
||||
mgr := manager.GetInstance()
|
||||
trashPath := mgr.Config.GetDeleteTrashPath()
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
||||
Paths: mgr.Paths,
|
||||
}
|
||||
@@ -624,12 +617,7 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
return fmt.Errorf("scene with id %d not found", destID)
|
||||
}
|
||||
|
||||
// only update cover image if one was provided
|
||||
if len(coverImageData) > 0 {
|
||||
return r.sceneUpdateCoverImage(ctx, ret, coverImageData)
|
||||
}
|
||||
|
||||
return nil
|
||||
return r.sceneUpdateCoverImage(ctx, ret, coverImageData)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -662,7 +650,7 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
|
||||
// Populate a new scene marker from the input
|
||||
newMarker := models.NewSceneMarker()
|
||||
|
||||
newMarker.Title = strings.TrimSpace(input.Title)
|
||||
newMarker.Title = input.Title
|
||||
newMarker.Seconds = input.Seconds
|
||||
newMarker.PrimaryTagID = primaryTagID
|
||||
newMarker.SceneID = sceneID
|
||||
@@ -748,10 +736,9 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
|
||||
}
|
||||
|
||||
mgr := manager.GetInstance()
|
||||
trashPath := mgr.Config.GetDeleteTrashPath()
|
||||
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
||||
Paths: mgr.Paths,
|
||||
}
|
||||
@@ -962,10 +949,9 @@ func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []
|
||||
|
||||
var markers []*models.SceneMarker
|
||||
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Deleter: file.NewDeleter(),
|
||||
FileNamingAlgo: fileNamingAlgo,
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
|
||||
}
|
||||
|
||||
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input
|
||||
}
|
||||
|
||||
func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
@@ -31,25 +30,25 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
}
|
||||
|
||||
// Populate a new studio from the input
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio := models.NewStudio()
|
||||
|
||||
newStudio.Name = strings.TrimSpace(input.Name)
|
||||
newStudio.Name = input.Name
|
||||
newStudio.Rating = input.Rating100
|
||||
newStudio.Favorite = translator.bool(input.Favorite)
|
||||
newStudio.Details = translator.string(input.Details)
|
||||
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))
|
||||
newStudio.Aliases = models.NewRelatedStrings(input.Aliases)
|
||||
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
|
||||
|
||||
var err error
|
||||
|
||||
newStudio.URLs = models.NewRelatedStrings([]string{})
|
||||
if input.URL != nil {
|
||||
newStudio.URLs.Add(strings.TrimSpace(*input.URL))
|
||||
newStudio.URLs.Add(*input.URL)
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newStudio.URLs.Add(stringslice.TrimSpace(input.Urls)...)
|
||||
newStudio.URLs.Add(input.Urls...)
|
||||
}
|
||||
|
||||
newStudio.ParentID, err = translator.intPtrFromString(input.ParentID)
|
||||
@@ -61,7 +60,6 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
@@ -135,7 +133,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -153,11 +151,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
updatedStudio.CustomFields = input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full)
|
||||
updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
@@ -173,28 +166,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Studio
|
||||
|
||||
if updatedStudio.Aliases != nil {
|
||||
s, err := qb.Find(ctx, studioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
if err := s.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())
|
||||
name := s.Name
|
||||
if updatedStudio.Name.Set {
|
||||
name = updatedStudio.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedStudio.Aliases.Values = sanitized
|
||||
updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,7 +210,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -33,9 +32,9 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
// Populate a new tag from the input
|
||||
newTag := models.NewTag()
|
||||
|
||||
newTag.Name = strings.TrimSpace(input.Name)
|
||||
newTag.Name = input.Name
|
||||
newTag.SortName = translator.string(input.SortName)
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name))
|
||||
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
|
||||
newTag.Favorite = translator.bool(input.Favorite)
|
||||
newTag.Description = translator.string(input.Description)
|
||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
@@ -151,28 +150,6 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
||||
if updatedTag.Aliases != nil {
|
||||
t, err := qb.Find(ctx, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t != nil {
|
||||
if err := t.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newAliases := updatedTag.Aliases.Apply(t.Aliases.List())
|
||||
name := t.Name
|
||||
if updatedTag.Name.Set {
|
||||
name = updatedTag.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(newAliases, name)
|
||||
updatedTag.Aliases.Values = sanitized
|
||||
updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
||||
Stashes: config.GetStashPaths(),
|
||||
DatabasePath: config.GetDatabasePath(),
|
||||
BackupDirectoryPath: config.GetBackupDirectoryPath(),
|
||||
DeleteTrashPath: config.GetDeleteTrashPath(),
|
||||
GeneratedPath: config.GetGeneratedPath(),
|
||||
MetadataPath: config.GetMetadataPath(),
|
||||
ConfigFilePath: config.GetConfigFile(),
|
||||
@@ -156,7 +155,6 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
||||
javascriptEnabled := config.GetJavascriptEnabled()
|
||||
customLocales := config.GetCustomLocales()
|
||||
customLocalesEnabled := config.GetCustomLocalesEnabled()
|
||||
disableCustomizations := config.GetDisableCustomizations()
|
||||
language := config.GetLanguage()
|
||||
handyKey := config.GetHandyKey()
|
||||
scriptOffset := config.GetFunscriptOffset()
|
||||
@@ -184,7 +182,6 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
||||
JavascriptEnabled: &javascriptEnabled,
|
||||
CustomLocales: &customLocales,
|
||||
CustomLocalesEnabled: &customLocalesEnabled,
|
||||
DisableCustomizations: &disableCustomizations,
|
||||
Language: &language,
|
||||
|
||||
ImageLightbox: &imageLightboxOptions,
|
||||
|
||||
@@ -29,7 +29,7 @@ func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string)
|
||||
ret = files[0]
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path, true)
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("file not found")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string
|
||||
return err
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path, true)
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("folder not found")
|
||||
}
|
||||
@@ -48,7 +49,7 @@ func (r *queryResolver) FindFolders(
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := handleIDList(ids, "ids")
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.G
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {
|
||||
@@ -54,7 +55,7 @@ func (r *queryResolver) FindImages(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindImagesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
imageIds, err = handleIDList(ids, "ids")
|
||||
imageIds, err = stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.G
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) {
|
||||
@@ -25,7 +26,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
|
||||
|
||||
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
performerIDs, err = handleIDList(ids, "ids")
|
||||
performerIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
|
||||
@@ -82,7 +83,7 @@ func (r *queryResolver) FindScenes(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindScenesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
sceneIDs, err = handleIDList(ids, "ids")
|
||||
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {
|
||||
@@ -25,7 +26,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -350,46 +350,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_endpoint must be set")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) {
|
||||
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
|
||||
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := r.newStashBoxClient(*b)
|
||||
|
||||
var ret []*models.ScrapedTag
|
||||
out, err := client.QueryTag(ctx, *input.Query)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if out != nil {
|
||||
ret = append(ret, out...)
|
||||
}
|
||||
|
||||
if len(ret) > 0 {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
for _, tag := range ret {
|
||||
if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_endpoint must be set")
|
||||
return nil, errors.New("stash_box_index must be set")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/file/video"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
@@ -244,12 +243,6 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
||||
// if default flag is set, return the default image
|
||||
if r.URL.Query().Get("default") == "true" {
|
||||
utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))
|
||||
return
|
||||
}
|
||||
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
ss := manager.SceneServer{
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -256,9 +255,6 @@ func Initialize() (*Server, error) {
|
||||
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
|
||||
}
|
||||
|
||||
// handle favicon override
|
||||
r.HandleFunc("/favicon.ico", handleFavicon(staticUI))
|
||||
|
||||
// Serve the web app
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
ext := path.Ext(r.URL.Path)
|
||||
@@ -299,31 +295,6 @@ func Initialize() (*Server, error) {
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {
|
||||
mgr := manager.GetInstance()
|
||||
cfg := mgr.Config
|
||||
|
||||
// check if favicon.ico exists in the config directory
|
||||
// if so, use that
|
||||
// otherwise, use the embedded one
|
||||
iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico")
|
||||
exists, _ := fsutil.FileExists(iconPath)
|
||||
|
||||
if exists {
|
||||
logger.Debugf("Using custom favicon at %s", iconPath)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if exists {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
} else {
|
||||
staticUI.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the server. It listens on the configured address and port.
|
||||
// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.
|
||||
// Calls to Start are blocked until the server is shutdown.
|
||||
@@ -450,7 +421,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var paths []string
|
||||
|
||||
if c.GetCSSEnabled() && !c.GetDisableCustomizations() {
|
||||
if c.GetCSSEnabled() {
|
||||
// search for custom.css in current directory, then $HOME/.stash
|
||||
fn := c.GetCSSPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
@@ -468,7 +439,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var paths []string
|
||||
|
||||
if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {
|
||||
if c.GetJavascriptEnabled() {
|
||||
// search for custom.js in current directory, then $HOME/.stash
|
||||
fn := c.GetJavascriptPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
@@ -486,7 +457,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
buffer := bytes.Buffer{}
|
||||
|
||||
if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {
|
||||
if c.GetCustomLocalesEnabled() {
|
||||
// search for custom-locales.json in current directory, then $HOME/.stash
|
||||
path := c.GetCustomLocalesPath()
|
||||
exists, _ := fsutil.FileExists(path)
|
||||
|
||||
@@ -101,15 +101,16 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
|
||||
|
||||
func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) {
|
||||
// create the studio
|
||||
studio := models.NewCreateStudioInput()
|
||||
studio.Name = name
|
||||
studio := models.Studio{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err := qb.Create(ctx, &studio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return studio.Studio, nil
|
||||
return &studio, nil
|
||||
}
|
||||
|
||||
func createTag(ctx context.Context, qb models.TagWriter) error {
|
||||
@@ -224,7 +225,7 @@ func createSceneFile(ctx context.Context, name string, folderStore models.Folder
|
||||
}
|
||||
|
||||
func getOrCreateFolder(ctx context.Context, folderStore models.FolderFinderCreator, folderPath string) (*models.Folder, error) {
|
||||
f, err := folderStore.FindByPath(ctx, folderPath, true)
|
||||
f, err := folderStore.FindByPath(ctx, folderPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting folder by path: %w", err)
|
||||
}
|
||||
|
||||
@@ -17,16 +17,6 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var isDesktop bool
|
||||
|
||||
// InitIsDesktop sets the value of isDesktop.
|
||||
// Changed IsDesktop to be evaluated once at startup because if it is
|
||||
// checked while there are open terminal sessions (such as the ffmpeg hardware
|
||||
// encoding checks), it may return false.
|
||||
func InitIsDesktop() {
|
||||
isDesktop = isDesktopCheck()
|
||||
}
|
||||
|
||||
type FaviconProvider interface {
|
||||
GetFavicon() []byte
|
||||
GetFaviconPng() []byte
|
||||
@@ -69,33 +59,22 @@ func SendNotification(title string, text string) {
|
||||
}
|
||||
|
||||
func IsDesktop() bool {
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
// isDesktop tries to determine if the application is running in a desktop environment
|
||||
// where desktop features like system tray and notifications should be enabled.
|
||||
func isDesktopCheck() bool {
|
||||
if isDoubleClickLaunched() {
|
||||
logger.Debug("Detected double-click launch")
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if running under root
|
||||
if os.Getuid() == 0 {
|
||||
logger.Debug("Running as root, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
// Check if stdin is a terminal
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
logger.Debug("Running in terminal, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
if isService() {
|
||||
logger.Debug("Running as a service, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
if IsServerDockerized() {
|
||||
logger.Debug("Running in docker, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/kermieisinthehouse/systray"
|
||||
@@ -22,12 +20,7 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
|
||||
// system is started from a non-terminal method, e.g. double-clicking an icon.
|
||||
c := config.GetInstance()
|
||||
if c.GetShowOneTimeMovedNotification() {
|
||||
// Use platform-appropriate terminology
|
||||
location := "tray"
|
||||
if runtime.GOOS == "darwin" {
|
||||
location = "menu bar"
|
||||
}
|
||||
SendNotification("Stash has moved!", "Stash now runs in your "+location+", instead of a terminal window.")
|
||||
SendNotification("Stash has moved!", "Stash now runs in your tray, instead of a terminal window.")
|
||||
c.SetBool(config.ShowOneTimeMovedNotification, false)
|
||||
if err := c.Write(); err != nil {
|
||||
logger.Errorf("Error while writing configuration file: %v", err)
|
||||
@@ -59,12 +52,12 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
|
||||
func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) {
|
||||
favicon := faviconProvider.GetFavicon()
|
||||
systray.SetTemplateIcon(favicon, favicon)
|
||||
c := config.GetInstance()
|
||||
systray.SetTooltip(fmt.Sprintf("🟢 Stash is Running on port %d.", c.GetPort()))
|
||||
systray.SetTooltip("🟢 Stash is Running.")
|
||||
|
||||
openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash")
|
||||
var menuItems []string
|
||||
systray.AddSeparator()
|
||||
c := config.GetInstance()
|
||||
if !c.IsNewSystem() {
|
||||
menuItems = c.GetMenuItems()
|
||||
for _, item := range menuItems {
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultSessionTimeout is the time after which a session is considered complete
|
||||
// if no new requests are received.
|
||||
// This is set high (5 minutes) because DLNA clients buffer aggressively and may not
|
||||
// send any HTTP requests for extended periods while the user is still watching.
|
||||
DefaultSessionTimeout = 5 * time.Minute
|
||||
|
||||
// monitorInterval is how often we check for expired sessions.
|
||||
monitorInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
// ActivityConfig provides configuration options for DLNA activity tracking.
|
||||
type ActivityConfig interface {
|
||||
// GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled.
|
||||
// If not implemented, defaults to true.
|
||||
GetDLNAActivityTrackingEnabled() bool
|
||||
|
||||
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
||||
// watched before incrementing the play count. Uses UI setting if available.
|
||||
GetMinimumPlayPercent() int
|
||||
}
|
||||
|
||||
// SceneActivityWriter provides methods for saving scene activity.
|
||||
type SceneActivityWriter interface {
|
||||
SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)
|
||||
AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)
|
||||
}
|
||||
|
||||
// streamSession represents an active DLNA streaming session.
|
||||
type streamSession struct {
|
||||
SceneID int
|
||||
ClientIP string
|
||||
StartTime time.Time
|
||||
LastActivity time.Time
|
||||
VideoDuration float64
|
||||
PlayCountAdded bool
|
||||
}
|
||||
|
||||
// sessionKey generates a unique key for a session based on client IP and scene ID.
|
||||
func sessionKey(clientIP string, sceneID int) string {
|
||||
return fmt.Sprintf("%s:%d", clientIP, sceneID)
|
||||
}
|
||||
|
||||
// percentWatched calculates the estimated percentage of video watched.
|
||||
// Uses a time-based approach since DLNA clients buffer aggressively and byte
|
||||
// positions don't correlate with actual playback position.
|
||||
//
|
||||
// The key insight: you cannot have watched more of the video than time has elapsed.
|
||||
// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%.
|
||||
func (s *streamSession) percentWatched() float64 {
|
||||
if s.VideoDuration <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate elapsed time from session start to last activity
|
||||
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Maximum possible percent is based on elapsed time
|
||||
// You can't watch more of the video than time has passed
|
||||
timeBasedPercent := (elapsed / s.VideoDuration) * 100
|
||||
|
||||
// Cap at 100%
|
||||
if timeBasedPercent > 100 {
|
||||
return 100
|
||||
}
|
||||
|
||||
return timeBasedPercent
|
||||
}
|
||||
|
||||
// estimatedResumeTime calculates the estimated resume time based on elapsed time.
|
||||
// Since DLNA clients buffer aggressively, byte positions don't correlate with playback.
|
||||
// Instead, we estimate based on how long the session has been active.
|
||||
// Returns the time in seconds, or 0 if the video is nearly complete (>=98%).
|
||||
func (s *streamSession) estimatedResumeTime() float64 {
|
||||
if s.VideoDuration <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate elapsed time from session start
|
||||
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior)
|
||||
if elapsed >= s.VideoDuration*0.98 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resume time is approximately where the user was watching
|
||||
// Capped by video duration
|
||||
if elapsed > s.VideoDuration {
|
||||
elapsed = s.VideoDuration
|
||||
}
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// ActivityTracker tracks DLNA streaming activity and saves it to the database.
|
||||
type ActivityTracker struct {
|
||||
txnManager txn.Manager
|
||||
sceneWriter SceneActivityWriter
|
||||
config ActivityConfig
|
||||
sessionTimeout time.Duration
|
||||
|
||||
sessions map[string]*streamSession
|
||||
mutex sync.RWMutex
|
||||
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewActivityTracker creates a new ActivityTracker.
|
||||
func NewActivityTracker(
|
||||
txnManager txn.Manager,
|
||||
sceneWriter SceneActivityWriter,
|
||||
config ActivityConfig,
|
||||
) *ActivityTracker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: txnManager,
|
||||
sceneWriter: sceneWriter,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
ctx: ctx,
|
||||
cancelFunc: cancel,
|
||||
}
|
||||
|
||||
// Start the session monitor goroutine
|
||||
tracker.wg.Add(1)
|
||||
go tracker.monitorSessions()
|
||||
|
||||
return tracker
|
||||
}
|
||||
|
||||
// Stop stops the activity tracker and processes any remaining sessions.
|
||||
func (t *ActivityTracker) Stop() {
|
||||
t.cancelFunc()
|
||||
t.wg.Wait()
|
||||
|
||||
// Process any remaining sessions
|
||||
t.mutex.Lock()
|
||||
sessions := make([]*streamSession, 0, len(t.sessions))
|
||||
for _, session := range t.sessions {
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
t.sessions = make(map[string]*streamSession)
|
||||
t.mutex.Unlock()
|
||||
|
||||
for _, session := range sessions {
|
||||
t.processCompletedSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest records a streaming request for activity tracking.
|
||||
// Each request updates the session's LastActivity time, which is used for
|
||||
// time-based tracking of watch progress.
|
||||
func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) {
|
||||
if !t.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
key := sessionKey(clientIP, sceneID)
|
||||
now := time.Now()
|
||||
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
session, exists := t.sessions[key]
|
||||
if !exists {
|
||||
session = &streamSession{
|
||||
SceneID: sceneID,
|
||||
ClientIP: clientIP,
|
||||
StartTime: now,
|
||||
VideoDuration: videoDuration,
|
||||
}
|
||||
t.sessions[key] = session
|
||||
logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP)
|
||||
}
|
||||
|
||||
session.LastActivity = now
|
||||
}
|
||||
|
||||
// monitorSessions periodically checks for expired sessions and processes them.
|
||||
func (t *ActivityTracker) monitorSessions() {
|
||||
defer t.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(monitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
t.processExpiredSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processExpiredSessions finds and processes sessions that have timed out.
|
||||
func (t *ActivityTracker) processExpiredSessions() {
|
||||
now := time.Now()
|
||||
var expiredSessions []*streamSession
|
||||
|
||||
t.mutex.Lock()
|
||||
for key, session := range t.sessions {
|
||||
timeSinceStart := now.Sub(session.StartTime)
|
||||
timeSinceActivity := now.Sub(session.LastActivity)
|
||||
|
||||
// Must have no HTTP activity for the full timeout period
|
||||
if timeSinceActivity <= t.sessionTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
// DLNA clients buffer aggressively - they fetch most/all of the video quickly,
|
||||
// then play from cache with NO further HTTP requests.
|
||||
//
|
||||
// Two scenarios:
|
||||
// 1. User watched the whole video: timeSinceStart >= videoDuration
|
||||
// -> Set LastActivity to when timeout began (they finished watching)
|
||||
// 2. User stopped early: timeSinceStart < videoDuration
|
||||
// -> Keep LastActivity as-is (best estimate of when they stopped)
|
||||
|
||||
videoDuration := time.Duration(session.VideoDuration) * time.Second
|
||||
if timeSinceStart >= videoDuration && videoDuration > 0 {
|
||||
// User likely watched the whole video, then it timed out
|
||||
// Estimate they watched until the timeout period started
|
||||
session.LastActivity = now.Add(-t.sessionTimeout)
|
||||
}
|
||||
// else: User stopped early - LastActivity is already our best estimate
|
||||
|
||||
expiredSessions = append(expiredSessions, session)
|
||||
delete(t.sessions, key)
|
||||
}
|
||||
t.mutex.Unlock()
|
||||
|
||||
for _, session := range expiredSessions {
|
||||
t.processCompletedSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// processCompletedSession saves activity data for a completed streaming session.
|
||||
func (t *ActivityTracker) processCompletedSession(session *streamSession) {
|
||||
percentWatched := session.percentWatched()
|
||||
resumeTime := session.estimatedResumeTime()
|
||||
|
||||
logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs",
|
||||
session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime)
|
||||
|
||||
// Only save if there was meaningful activity (at least 1% watched)
|
||||
if percentWatched < 1 {
|
||||
logger.Debugf("[DLNA Activity] Session too short, skipping save")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip DB operations if txnManager is nil (for testing)
|
||||
if t.txnManager == nil {
|
||||
logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine what needs to be saved
|
||||
shouldSaveResume := resumeTime > 0
|
||||
shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())
|
||||
|
||||
// Nothing to save
|
||||
if !shouldSaveResume && !shouldAddView {
|
||||
return
|
||||
}
|
||||
|
||||
// Save everything in a single transaction
|
||||
ctx := context.Background()
|
||||
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
|
||||
// Save resume time only. DLNA clients buffer aggressively and don't report
|
||||
// playback position, so we can't accurately track play duration - saving
|
||||
// guesses would corrupt analytics. Resume time is still useful as a
|
||||
// "continue watching" hint even if imprecise.
|
||||
if shouldSaveResume {
|
||||
if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil {
|
||||
return fmt.Errorf("save resume time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Increment play count (also updates last_played_at via view date)
|
||||
if shouldAddView {
|
||||
if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil {
|
||||
return fmt.Errorf("add view: %w", err)
|
||||
}
|
||||
session.PlayCountAdded = true
|
||||
logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)",
|
||||
session.SceneID, percentWatched)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// isEnabled returns true if activity tracking is enabled.
|
||||
func (t *ActivityTracker) isEnabled() bool {
|
||||
if t.config == nil {
|
||||
return true // Default to enabled
|
||||
}
|
||||
return t.config.GetDLNAActivityTrackingEnabled()
|
||||
}
|
||||
|
||||
// getMinimumPlayPercent returns the minimum play percentage for incrementing play count.
|
||||
func (t *ActivityTracker) getMinimumPlayPercent() int {
|
||||
if t.config == nil {
|
||||
return 0 // Default: any play increments count (matches frontend default)
|
||||
}
|
||||
return t.config.GetMinimumPlayPercent()
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockSceneWriter is a mock implementation of SceneActivityWriter
|
||||
type mockSceneWriter struct {
|
||||
mu sync.Mutex
|
||||
saveActivityCalls []saveActivityCall
|
||||
addViewsCalls []addViewsCall
|
||||
}
|
||||
|
||||
type saveActivityCall struct {
|
||||
sceneID int
|
||||
resumeTime *float64
|
||||
playDuration *float64
|
||||
}
|
||||
|
||||
type addViewsCall struct {
|
||||
sceneID int
|
||||
dates []time.Time
|
||||
}
|
||||
|
||||
func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {
|
||||
m.mu.Lock()
|
||||
m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{
|
||||
sceneID: sceneID,
|
||||
resumeTime: resumeTime,
|
||||
playDuration: playDuration,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {
|
||||
m.mu.Lock()
|
||||
m.addViewsCalls = append(m.addViewsCalls, addViewsCall{
|
||||
sceneID: sceneID,
|
||||
dates: dates,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
// mockConfig is a mock implementation of ActivityConfig
|
||||
type mockConfig struct {
|
||||
enabled bool
|
||||
minPlayPercent int
|
||||
}
|
||||
|
||||
func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool {
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
func (c *mockConfig) GetMinimumPlayPercent() int {
|
||||
return c.minPlayPercent
|
||||
}
|
||||
|
||||
func TestStreamSession_PercentWatched(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
lastActivity time.Time
|
||||
videoDuration float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "no video duration",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "half watched",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50%
|
||||
expected: 50.0,
|
||||
},
|
||||
{
|
||||
name: "fully watched",
|
||||
startTime: now.Add(-120 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100%
|
||||
expected: 100.0,
|
||||
},
|
||||
{
|
||||
name: "quarter watched",
|
||||
startTime: now.Add(-30 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25%
|
||||
expected: 25.0,
|
||||
},
|
||||
{
|
||||
name: "elapsed exceeds duration - capped at 100%",
|
||||
startTime: now.Add(-180 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100%
|
||||
expected: 100.0,
|
||||
},
|
||||
{
|
||||
name: "no elapsed time",
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
session := &streamSession{
|
||||
StartTime: tt.startTime,
|
||||
LastActivity: tt.lastActivity,
|
||||
VideoDuration: tt.videoDuration,
|
||||
}
|
||||
result := session.percentWatched()
|
||||
assert.InDelta(t, tt.expected, result, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSession_EstimatedResumeTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
lastActivity time.Time
|
||||
videoDuration float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "no elapsed time",
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "half way through",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s
|
||||
expected: 60.0,
|
||||
},
|
||||
{
|
||||
name: "quarter way through",
|
||||
startTime: now.Add(-30 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s
|
||||
expected: 30.0,
|
||||
},
|
||||
{
|
||||
name: "98% complete - should reset to 0",
|
||||
startTime: now.Add(-118 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 98.3% elapsed, should reset
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "100% complete - should reset to 0",
|
||||
startTime: now.Add(-120 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "elapsed exceeds duration - capped and reset to 0",
|
||||
startTime: now.Add(-180 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "no video duration",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
session := &streamSession{
|
||||
StartTime: tt.startTime,
|
||||
LastActivity: tt.lastActivity,
|
||||
VideoDuration: tt.videoDuration,
|
||||
}
|
||||
result := session.estimatedResumeTime()
|
||||
assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKey(t *testing.T) {
|
||||
key := sessionKey("192.168.1.100", 42)
|
||||
assert.Equal(t, "192.168.1.100:42", key)
|
||||
}
|
||||
|
||||
func TestActivityTracker_RecordRequest(t *testing.T) {
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil, // Don't need DB for this test
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Record first request - should create new session
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
session := tracker.sessions["192.168.1.100:42"]
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.NotNil(t, session)
|
||||
assert.Equal(t, 42, session.SceneID)
|
||||
assert.Equal(t, "192.168.1.100", session.ClientIP)
|
||||
assert.Equal(t, 120.0, session.VideoDuration)
|
||||
assert.False(t, session.StartTime.IsZero())
|
||||
assert.False(t, session.LastActivity.IsZero())
|
||||
|
||||
// Record second request - should update LastActivity
|
||||
firstActivity := session.LastActivity
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
session = tracker.sessions["192.168.1.100:42"]
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.True(t, session.LastActivity.After(firstActivity))
|
||||
}
|
||||
|
||||
func TestActivityTracker_DisabledTracking(t *testing.T) {
|
||||
config := &mockConfig{enabled: false, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Record request - should be ignored when tracking is disabled
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
sessionCount := len(tracker.sessions)
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 0, sessionCount)
|
||||
}
|
||||
|
||||
func TestActivityTracker_SessionExpiration(t *testing.T) {
|
||||
// For this test, we'll test the session expiration logic directly
|
||||
// without the full transaction manager integration
|
||||
|
||||
sceneWriter := &mockSceneWriter{}
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
||||
|
||||
// Create a tracker with nil txnManager - we'll test processCompletedSession separately
|
||||
// Here we just verify the session management logic
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil, // Skip DB calls for this test
|
||||
sceneWriter: sceneWriter,
|
||||
config: config,
|
||||
sessionTimeout: 100 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Manually add a session
|
||||
// Use a short video duration (1 second) so the test can verify expiration quickly.
|
||||
now := time.Now()
|
||||
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
||||
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
||||
VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
assert.Len(t, tracker.sessions, 1)
|
||||
|
||||
// Process expired sessions - this will try to save activity but txnManager is nil
|
||||
// so it will skip the DB calls but still remove the session
|
||||
tracker.processExpiredSessions()
|
||||
|
||||
// Verify session was removed (even though DB calls were skipped)
|
||||
assert.Len(t, tracker.sessions, 0)
|
||||
}
|
||||
|
||||
func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) {
|
||||
// Test that sessions expire when user stops watching early (before video ends)
|
||||
// This was a bug where sessions wouldn't expire until video duration passed
|
||||
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: 100 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// User started watching a 30-minute video but stopped after 5 seconds
|
||||
now := time.Now()
|
||||
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
||||
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
||||
VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time
|
||||
}
|
||||
|
||||
assert.Len(t, tracker.sessions, 1)
|
||||
|
||||
// Session should expire because timeSinceActivity > timeout
|
||||
// Even though the video is 30 minutes and only 5 seconds have passed
|
||||
tracker.processExpiredSessions()
|
||||
|
||||
// Verify session was expired
|
||||
assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration")
|
||||
}
|
||||
|
||||
func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) {
|
||||
// Test the threshold logic without full transaction integration
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold
|
||||
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: 50 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Test that getMinimumPlayPercent returns the configured value
|
||||
assert.Equal(t, 75, tracker.getMinimumPlayPercent())
|
||||
|
||||
// Create a session with 30% watched (36 seconds of a 120 second video)
|
||||
now := time.Now()
|
||||
session := &streamSession{
|
||||
SceneID: 42,
|
||||
StartTime: now.Add(-36 * time.Second),
|
||||
LastActivity: now,
|
||||
VideoDuration: 120.0,
|
||||
}
|
||||
|
||||
// 30% is below 75% threshold
|
||||
percentWatched := session.percentWatched()
|
||||
assert.InDelta(t, 30.0, percentWatched, 0.1)
|
||||
assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent()))
|
||||
}
|
||||
|
||||
func TestActivityTracker_MultipleSessions(t *testing.T) {
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Different clients watching same scene
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
tracker.RecordRequest(42, "192.168.1.101", 120.0)
|
||||
|
||||
// Same client watching different scenes
|
||||
tracker.RecordRequest(43, "192.168.1.100", 180.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
assert.Len(t, tracker.sessions, 3)
|
||||
tracker.mutex.RUnlock()
|
||||
}
|
||||
|
||||
func TestActivityTracker_ShortSessionIgnored(t *testing.T) {
|
||||
// Test that short sessions are ignored
|
||||
// Create a session with only ~0.8% watched (1 second of a 120 second video)
|
||||
now := time.Now()
|
||||
session := &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-1 * time.Second), // Only 1 second
|
||||
LastActivity: now,
|
||||
VideoDuration: 120.0, // 2 minutes
|
||||
}
|
||||
|
||||
// Verify percent watched is below threshold (1s / 120s = 0.83%)
|
||||
assert.InDelta(t, 0.83, session.percentWatched(), 0.1)
|
||||
|
||||
// Verify elapsed time is short
|
||||
elapsed := session.LastActivity.Sub(session.StartTime).Seconds()
|
||||
assert.InDelta(t, 1.0, elapsed, 0.5)
|
||||
|
||||
// Both are below the minimum thresholds (1% and 5 seconds)
|
||||
percentWatched := session.percentWatched()
|
||||
shouldSkip := percentWatched < 1 && elapsed < 5
|
||||
assert.True(t, shouldSkip, "Short session should be skipped")
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*,http-get:*:image/avif:*"
|
||||
const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*"
|
||||
|
||||
type connectionManagerService struct {
|
||||
*Server
|
||||
|
||||
@@ -278,7 +278,6 @@ type Server struct {
|
||||
repository Repository
|
||||
sceneServer sceneServer
|
||||
ipWhitelistManager *ipWhitelistManager
|
||||
activityTracker *ActivityTracker
|
||||
VideoSortOrder string
|
||||
|
||||
subscribeLock sync.Mutex
|
||||
@@ -597,7 +596,6 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
sceneId := r.URL.Query().Get("scene")
|
||||
var scene *models.Scene
|
||||
var videoDuration float64
|
||||
repo := me.repository
|
||||
err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error {
|
||||
sceneIdInt, err := strconv.Atoi(sceneId)
|
||||
@@ -605,15 +603,6 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
return nil
|
||||
}
|
||||
scene, _ = repo.SceneFinder.Find(ctx, sceneIdInt)
|
||||
if scene != nil {
|
||||
// Load primary file to get duration for activity tracking
|
||||
if err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil {
|
||||
logger.Debugf("failed to load primary file for scene %d: %v", sceneIdInt, err)
|
||||
}
|
||||
if f := scene.Files.Primary(); f != nil {
|
||||
videoDuration = f.Duration
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -626,14 +615,6 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
|
||||
w.Header().Set("transferMode.dlna.org", "Streaming")
|
||||
w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000")
|
||||
|
||||
// Track activity - uses time-based tracking, updated on each request
|
||||
if me.activityTracker != nil {
|
||||
sceneIdInt, _ := strconv.Atoi(sceneId)
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
me.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration)
|
||||
}
|
||||
|
||||
me.sceneServer.StreamSceneDirect(scene, w, r)
|
||||
})
|
||||
mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -77,29 +77,13 @@ type Config interface {
|
||||
GetDLNADefaultIPWhitelist() []string
|
||||
GetVideoSortOrder() string
|
||||
GetDLNAPortAsString() string
|
||||
GetDLNAActivityTrackingEnabled() bool
|
||||
}
|
||||
|
||||
// activityConfig wraps Config to implement ActivityConfig.
|
||||
type activityConfig struct {
|
||||
config Config
|
||||
minPlayPercent int // cached from UI config
|
||||
}
|
||||
|
||||
func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool {
|
||||
return c.config.GetDLNAActivityTrackingEnabled()
|
||||
}
|
||||
|
||||
func (c *activityConfig) GetMinimumPlayPercent() int {
|
||||
return c.minPlayPercent
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
config Config
|
||||
sceneServer sceneServer
|
||||
ipWhitelistMgr *ipWhitelistManager
|
||||
activityTracker *ActivityTracker
|
||||
repository Repository
|
||||
config Config
|
||||
sceneServer sceneServer
|
||||
ipWhitelistMgr *ipWhitelistManager
|
||||
|
||||
server *Server
|
||||
running bool
|
||||
@@ -171,7 +155,6 @@ func (s *Service) init() error {
|
||||
repository: s.repository,
|
||||
sceneServer: s.sceneServer,
|
||||
ipWhitelistManager: s.ipWhitelistMgr,
|
||||
activityTracker: s.activityTracker,
|
||||
Interfaces: interfaces,
|
||||
HTTPConn: func() net.Listener {
|
||||
conn, err := net.Listen("tcp", dmsConfig.Http)
|
||||
@@ -232,14 +215,7 @@ func (s *Service) init() error {
|
||||
// }
|
||||
|
||||
// NewService initialises and returns a new DLNA service.
|
||||
// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter).
|
||||
// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count.
|
||||
func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service {
|
||||
activityCfg := &activityConfig{
|
||||
config: cfg,
|
||||
minPlayPercent: minPlayPercent,
|
||||
}
|
||||
|
||||
func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
|
||||
ret := &Service{
|
||||
repository: repo,
|
||||
sceneServer: sceneServer,
|
||||
@@ -247,8 +223,7 @@ func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWrite
|
||||
ipWhitelistMgr: &ipWhitelistManager{
|
||||
config: cfg,
|
||||
},
|
||||
activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg),
|
||||
mutex: sync.Mutex{},
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
|
||||
return ret
|
||||
@@ -308,12 +283,6 @@ func (s *Service) Stop(duration *time.Duration) {
|
||||
|
||||
if s.running {
|
||||
logger.Info("Stopping DLNA")
|
||||
|
||||
// Stop activity tracker first to process any pending sessions
|
||||
if s.activityTracker != nil {
|
||||
s.activityTracker.Stop()
|
||||
}
|
||||
|
||||
err := s.server.Close()
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
|
||||
@@ -27,7 +27,7 @@ func Test_sceneRelationships_studio(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
db.Studio.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = validStoredIDInt
|
||||
}).Return(nil)
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ func Test_createMissingStudio(t *testing.T) {
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool {
|
||||
return p.Name == validName
|
||||
})).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = createdID
|
||||
}).Return(nil)
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool {
|
||||
return p.Name == invalidName
|
||||
})).Return(errors.New("error creating studio"))
|
||||
|
||||
|
||||
@@ -194,7 +194,6 @@ const (
|
||||
CSSEnabled = "cssenabled"
|
||||
JavascriptEnabled = "javascriptenabled"
|
||||
CustomLocalesEnabled = "customlocalesenabled"
|
||||
DisableCustomizations = "disable_customizations"
|
||||
|
||||
ShowScrubber = "show_scrubber"
|
||||
showScrubberDefault = true
|
||||
@@ -210,7 +209,6 @@ const (
|
||||
ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav"
|
||||
ImageLightboxScrollModeKey = "image_lightbox.scroll_mode"
|
||||
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
|
||||
ImageLightboxDisableAnimation = "image_lightbox.disable_animation"
|
||||
|
||||
UI = "ui"
|
||||
|
||||
@@ -220,7 +218,6 @@ const (
|
||||
DisableDropdownCreateStudio = "disable_dropdown_create.studio"
|
||||
DisableDropdownCreateTag = "disable_dropdown_create.tag"
|
||||
DisableDropdownCreateMovie = "disable_dropdown_create.movie"
|
||||
DisableDropdownCreateGallery = "disable_dropdown_create.gallery"
|
||||
|
||||
HandyKey = "handy_key"
|
||||
FunscriptOffset = "funscript_offset"
|
||||
@@ -275,9 +272,6 @@ const (
|
||||
DeleteGeneratedDefault = "defaults.delete_generated"
|
||||
deleteGeneratedDefaultDefault = true
|
||||
|
||||
// Trash/Recycle Bin options
|
||||
DeleteTrashPath = "delete_trash_path"
|
||||
|
||||
// Desktop Integration Options
|
||||
NoBrowser = "nobrowser"
|
||||
NoBrowserDefault = false
|
||||
@@ -296,7 +290,7 @@ const (
|
||||
// slice default values
|
||||
var (
|
||||
defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"}
|
||||
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif"}
|
||||
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp"}
|
||||
defaultGalleryExtensions = []string{"zip", "cbz"}
|
||||
defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"}
|
||||
)
|
||||
@@ -1299,10 +1293,6 @@ func (i *Config) GetImageLightboxOptions() ConfigImageLightboxResult {
|
||||
if v := i.with(ImageLightboxScrollAttemptsBeforeChange); v != nil {
|
||||
ret.ScrollAttemptsBeforeChange = v.Int(ImageLightboxScrollAttemptsBeforeChange)
|
||||
}
|
||||
if v := i.with(ImageLightboxDisableAnimation); v != nil {
|
||||
value := v.Bool(ImageLightboxDisableAnimation)
|
||||
ret.DisableAnimation = &value
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -1313,7 +1303,6 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate {
|
||||
Studio: i.getBool(DisableDropdownCreateStudio),
|
||||
Tag: i.getBool(DisableDropdownCreateTag),
|
||||
Movie: i.getBool(DisableDropdownCreateMovie),
|
||||
Gallery: i.getBool(DisableDropdownCreateGallery),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,26 +1313,6 @@ func (i *Config) GetUIConfiguration() map[string]interface{} {
|
||||
return i.forKey(UI).Cut(UI).Raw()
|
||||
}
|
||||
|
||||
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
||||
// watched before incrementing the play count. Returns 0 if not configured.
|
||||
func (i *Config) GetMinimumPlayPercent() int {
|
||||
uiConfig := i.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
return 0
|
||||
}
|
||||
if val, ok := uiConfig["minimumPlayPercent"]; ok {
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (i *Config) SetUIConfiguration(v map[string]interface{}) {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
@@ -1480,13 +1449,6 @@ func (i *Config) GetCustomLocalesEnabled() bool {
|
||||
return i.getBool(CustomLocalesEnabled)
|
||||
}
|
||||
|
||||
// GetDisableCustomizations returns true if all customizations (plugins, custom CSS,
|
||||
// custom JavaScript, and custom locales) should be disabled. This is useful for
|
||||
// troubleshooting issues without permanently disabling individual customizations.
|
||||
func (i *Config) GetDisableCustomizations() bool {
|
||||
return i.getBool(DisableCustomizations)
|
||||
}
|
||||
|
||||
func (i *Config) GetHandyKey() string {
|
||||
return i.getString(HandyKey)
|
||||
}
|
||||
@@ -1507,14 +1469,6 @@ func (i *Config) GetDeleteGeneratedDefault() bool {
|
||||
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
|
||||
}
|
||||
|
||||
func (i *Config) GetDeleteTrashPath() string {
|
||||
return i.getString(DeleteTrashPath)
|
||||
}
|
||||
|
||||
func (i *Config) SetDeleteTrashPath(value string) {
|
||||
i.SetString(DeleteTrashPath, value)
|
||||
}
|
||||
|
||||
// GetDefaultIdentifySettings returns the default Identify task settings.
|
||||
// Returns nil if the settings could not be unmarshalled, or if it
|
||||
// has not been set.
|
||||
@@ -1643,22 +1597,6 @@ func (i *Config) GetDLNAPortAsString() string {
|
||||
return ":" + strconv.Itoa(i.GetDLNAPort())
|
||||
}
|
||||
|
||||
// GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled.
|
||||
// This uses the same "trackActivity" UI setting that controls frontend play history tracking.
|
||||
// When enabled, scenes played via DLNA will have their play count and duration tracked.
|
||||
func (i *Config) GetDLNAActivityTrackingEnabled() bool {
|
||||
uiConfig := i.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
return true // Default to enabled
|
||||
}
|
||||
if val, ok := uiConfig["trackActivity"]; ok {
|
||||
if v, ok := val.(bool); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return true // Default to enabled
|
||||
}
|
||||
|
||||
// GetVideoSortOrder returns the sort order to display videos. If
|
||||
// empty, videos will be sorted by titles.
|
||||
func (i *Config) GetVideoSortOrder() string {
|
||||
|
||||
@@ -11,10 +11,8 @@ type ScanMetadataOptions struct {
|
||||
ScanGenerateImagePreviews bool `json:"scanGenerateImagePreviews"`
|
||||
// Generate sprites during scan
|
||||
ScanGenerateSprites bool `json:"scanGenerateSprites"`
|
||||
// Generate video phashes during scan
|
||||
// Generate phashes during scan
|
||||
ScanGeneratePhashes bool `json:"scanGeneratePhashes"`
|
||||
// Generate image phashes during scan
|
||||
ScanGenerateImagePhashes bool `json:"scanGenerateImagePhashes"`
|
||||
// Generate image thumbnails during scan
|
||||
ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"`
|
||||
// Generate image thumbnails during scan
|
||||
|
||||
@@ -13,7 +13,6 @@ type ConfigImageLightboxResult struct {
|
||||
ResetZoomOnNav *bool `json:"resetZoomOnNav"`
|
||||
ScrollMode *ImageLightboxScrollMode `json:"scrollMode"`
|
||||
ScrollAttemptsBeforeChange int `json:"scrollAttemptsBeforeChange"`
|
||||
DisableAnimation *bool `json:"disableAnimation"`
|
||||
}
|
||||
|
||||
type ImageLightboxDisplayMode string
|
||||
@@ -105,5 +104,4 @@ type ConfigDisableDropdownCreate struct {
|
||||
Tag bool `json:"tag"`
|
||||
Studio bool `json:"studio"`
|
||||
Movie bool `json:"movie"`
|
||||
Gallery bool `json:"gallery"`
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
|
||||
}
|
||||
|
||||
dlnaRepository := dlna.NewRepository(repo)
|
||||
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer, repo.Scene, cfg.GetMinimumPlayPercent())
|
||||
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer)
|
||||
|
||||
mgr := &Manager{
|
||||
Config: cfg,
|
||||
@@ -313,7 +313,6 @@ func (s *Manager) RefreshFFMpeg(ctx context.Context) {
|
||||
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
|
||||
s.FFProbe = ffmpeg.NewFFProbe(ffprobePath)
|
||||
|
||||
// initialise hardware support with background context
|
||||
s.FFMpeg.InitHWSupport(context.Background())
|
||||
s.FFMpeg.InitHWSupport(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,11 +219,8 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
|
||||
// paths since they must not be relative. The config file property is
|
||||
// resolved to an absolute path when stash is run normally, so convert
|
||||
// relative paths to absolute paths during setup.
|
||||
// #6287 - this should no longer be necessary since the ffmpeg code
|
||||
// converts to absolute paths. Converting the config location to
|
||||
// absolute means that scraper and plugin paths default to absolute
|
||||
// which we don't want.
|
||||
configFile := input.ConfigLocation
|
||||
configFile, _ := filepath.Abs(input.ConfigLocation)
|
||||
|
||||
configDir := filepath.Dir(configFile)
|
||||
|
||||
if exists, _ := fsutil.DirExists(configDir); !exists {
|
||||
|
||||
@@ -100,8 +100,6 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cfg := config.GetInstance()
|
||||
|
||||
scanner := &file.Scanner{
|
||||
Repository: file.NewRepository(s.Repository),
|
||||
FileDecorators: []file.Decorator{
|
||||
@@ -120,10 +118,6 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
|
||||
},
|
||||
FingerprintCalculator: &fingerprintCalculator{s.Config},
|
||||
FS: &file.OsFS{},
|
||||
ZipFileExtensions: cfg.GetGalleryExtensions(),
|
||||
// ScanFilters is set in ScanJob.Execute
|
||||
// HandlerRequiredFilters is set in ScanJob.Execute
|
||||
Rescan: input.Rescan,
|
||||
}
|
||||
|
||||
scanJob := ScanJob{
|
||||
@@ -300,7 +294,6 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
|
||||
Handlers: []file.CleanHandler{
|
||||
&cleanHandler{},
|
||||
},
|
||||
TrashPath: s.Config.GetDeleteTrashPath(),
|
||||
}
|
||||
|
||||
j := cleanJob{
|
||||
@@ -371,37 +364,9 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
|
||||
return s.JobManager.Add(ctx, "Migrating scene hashes...", j)
|
||||
}
|
||||
|
||||
// batchTagType indicates which batch tagging mode to use
|
||||
type batchTagType int
|
||||
|
||||
const (
|
||||
batchTagByIds batchTagType = iota
|
||||
batchTagByNamesOrStashIds
|
||||
batchTagAll
|
||||
)
|
||||
|
||||
// getBatchTagType determines the batch tag mode based on the input
|
||||
func (input StashBoxBatchTagInput) getBatchTagType(hasPerformerFields bool) batchTagType {
|
||||
switch {
|
||||
case len(input.Ids) > 0:
|
||||
return batchTagByIds
|
||||
case hasPerformerFields && len(input.PerformerIds) > 0:
|
||||
return batchTagByIds
|
||||
case len(input.StashIDs) > 0 || len(input.Names) > 0:
|
||||
return batchTagByNamesOrStashIds
|
||||
case hasPerformerFields && len(input.PerformerNames) > 0:
|
||||
return batchTagByNamesOrStashIds
|
||||
default:
|
||||
return batchTagAll
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts either ids, or a combination of names and stash_ids.
|
||||
// If none are set, then all existing items will be tagged.
|
||||
// If neither ids nor names are set, tag all items
|
||||
type StashBoxBatchTagInput struct {
|
||||
// Stash endpoint to use for the tagging
|
||||
//
|
||||
// Deprecated: use StashBoxEndpoint
|
||||
// Stash endpoint to use for the tagging - deprecated - use StashBoxEndpoint
|
||||
Endpoint *int `json:"endpoint"`
|
||||
StashBoxEndpoint *string `json:"stash_box_endpoint"`
|
||||
// Fields to exclude when executing the tagging
|
||||
@@ -410,143 +375,128 @@ type StashBoxBatchTagInput struct {
|
||||
Refresh bool `json:"refresh"`
|
||||
// If batch adding studios, should their parent studios also be created?
|
||||
CreateParent bool `json:"createParent"`
|
||||
// IDs in stash of the items to update.
|
||||
// If set, names and stash_ids fields will be ignored.
|
||||
// If set, only tag these ids
|
||||
Ids []string `json:"ids"`
|
||||
// Names of the items in the stash-box instance to search for and create
|
||||
// If set, only tag these names
|
||||
Names []string `json:"names"`
|
||||
// Stash IDs of the items in the stash-box instance to search for and create
|
||||
StashIDs []string `json:"stash_ids"`
|
||||
// IDs in stash of the performers to update
|
||||
// If set, only tag these performer ids
|
||||
//
|
||||
// Deprecated: use Ids
|
||||
// Deprecated: please use Ids
|
||||
PerformerIds []string `json:"performer_ids"`
|
||||
// Names of the performers in the stash-box instance to search for and create
|
||||
// If set, only tag these performer names
|
||||
//
|
||||
// Deprecated: use Names
|
||||
// Deprecated: please use Names
|
||||
PerformerNames []string `json:"performer_names"`
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
performerQuery := s.Repository.Performer
|
||||
|
||||
ids := input.Ids
|
||||
if len(ids) == 0 {
|
||||
ids = input.PerformerIds //nolint:staticcheck
|
||||
}
|
||||
|
||||
for _, performerID := range ids {
|
||||
if id, err := strconv.Atoi(performerID); err == nil {
|
||||
performer, err := performerQuery.Find(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
|
||||
return fmt.Errorf("loading performer stash ids: %w", err)
|
||||
}
|
||||
|
||||
hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil
|
||||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
|
||||
performer: performer,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
|
||||
var tasks []Task
|
||||
|
||||
for i := range input.StashIDs {
|
||||
stashID := input.StashIDs[i]
|
||||
if len(stashID) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
|
||||
stashID: &stashID,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
names := input.Names
|
||||
if len(names) == 0 {
|
||||
names = input.PerformerNames //nolint:staticcheck
|
||||
}
|
||||
|
||||
for i := range names {
|
||||
name := names[i]
|
||||
if len(name) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
|
||||
name: &name,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
performerQuery := s.Repository.Performer
|
||||
var performers []*models.Performer
|
||||
var err error
|
||||
|
||||
performers, err = performerQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying performers: %v", err)
|
||||
}
|
||||
|
||||
for _, performer := range performers {
|
||||
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
|
||||
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
|
||||
}
|
||||
|
||||
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
|
||||
performer: performer,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
|
||||
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
|
||||
logger.Infof("Initiating stash-box batch performer tag")
|
||||
|
||||
var tasks []Task
|
||||
var err error
|
||||
var tasks []StashBoxBatchTagTask
|
||||
|
||||
switch input.getBatchTagType(true) {
|
||||
case batchTagByIds:
|
||||
tasks, err = s.batchTagPerformersByIds(ctx, input, box)
|
||||
case batchTagByNamesOrStashIds:
|
||||
tasks = s.batchTagPerformersByNamesOrStashIds(input, box)
|
||||
case batchTagAll:
|
||||
tasks, err = s.batchTagAllPerformers(ctx, input, box)
|
||||
}
|
||||
// The gocritic linter wants to turn this ifElseChain into a switch.
|
||||
// however, such a switch would contain quite large blocks for each section
|
||||
// and would arguably be hard to read.
|
||||
//
|
||||
// This is why we mark this section nolint. In principle, we should look to
|
||||
// rewrite the section at some point, to avoid the linter warning.
|
||||
if len(input.Ids) > 0 || len(input.PerformerIds) > 0 { //nolint:gocritic
|
||||
// The user has chosen only to tag the items on the current page
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
performerQuery := s.Repository.Performer
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
idsToUse := input.PerformerIds
|
||||
if len(input.Ids) > 0 {
|
||||
idsToUse = input.Ids
|
||||
}
|
||||
|
||||
for _, performerID := range idsToUse {
|
||||
if id, err := strconv.Atoi(performerID); err == nil {
|
||||
performer, err := performerQuery.Find(ctx, id)
|
||||
if err == nil {
|
||||
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
|
||||
return fmt.Errorf("loading performer stash ids: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user wants to refresh existing or new items
|
||||
hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil
|
||||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, StashBoxBatchTagTask{
|
||||
performer: performer,
|
||||
refresh: input.Refresh,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
taskType: Performer,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(input.Names) > 0 || len(input.PerformerNames) > 0 {
|
||||
// The user is batch adding performers
|
||||
namesToUse := input.PerformerNames
|
||||
if len(input.Names) > 0 {
|
||||
namesToUse = input.Names
|
||||
}
|
||||
|
||||
for i := range namesToUse {
|
||||
name := namesToUse[i]
|
||||
if len(name) > 0 {
|
||||
tasks = append(tasks, StashBoxBatchTagTask{
|
||||
name: &name,
|
||||
refresh: false,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
taskType: Performer,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else { //nolint:gocritic
|
||||
// The gocritic linter wants to fold this if-block into the else on the line above.
|
||||
// However, this doesn't really help with readability of the current section. Mark it
|
||||
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
|
||||
// this into separate functions.
|
||||
|
||||
// The user has chosen to tag every item in their database
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
performerQuery := s.Repository.Performer
|
||||
var performers []*models.Performer
|
||||
var err error
|
||||
|
||||
if input.Refresh {
|
||||
performers, err = performerQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
|
||||
} else {
|
||||
performers, err = performerQuery.FindByStashIDStatus(ctx, false, box.Endpoint)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying performers: %v", err)
|
||||
}
|
||||
|
||||
for _, performer := range performers {
|
||||
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
|
||||
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
|
||||
}
|
||||
|
||||
tasks = append(tasks, StashBoxBatchTagTask{
|
||||
performer: performer,
|
||||
refresh: input.Refresh,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
taskType: Performer,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
@@ -558,7 +508,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.Sta
|
||||
logger.Infof("Starting stash-box batch operation for %d performers", len(tasks))
|
||||
|
||||
for _, task := range tasks {
|
||||
progress.ExecuteTask(task.GetDescription(), func() {
|
||||
progress.ExecuteTask(task.Description(), func() {
|
||||
task.Start(ctx)
|
||||
})
|
||||
|
||||
@@ -571,116 +521,103 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.Sta
|
||||
return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j)
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagStudiosByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
studioQuery := s.Repository.Studio
|
||||
|
||||
for _, studioID := range input.Ids {
|
||||
if id, err := strconv.Atoi(studioID); err == nil {
|
||||
studio, err := studioQuery.Find(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := studio.LoadStashIDs(ctx, studioQuery); err != nil {
|
||||
return fmt.Errorf("loading studio stash ids: %w", err)
|
||||
}
|
||||
|
||||
hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil
|
||||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, &stashBoxBatchStudioTagTask{
|
||||
studio: studio,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagStudiosByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
|
||||
var tasks []Task
|
||||
|
||||
for i := range input.StashIDs {
|
||||
stashID := input.StashIDs[i]
|
||||
if len(stashID) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchStudioTagTask{
|
||||
stashID: &stashID,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for i := range input.Names {
|
||||
name := input.Names[i]
|
||||
if len(name) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchStudioTagTask{
|
||||
name: &name,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagAllStudios(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
studioQuery := s.Repository.Studio
|
||||
var studios []*models.Studio
|
||||
var err error
|
||||
|
||||
studios, err = studioQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying studios: %v", err)
|
||||
}
|
||||
|
||||
for _, studio := range studios {
|
||||
tasks = append(tasks, &stashBoxBatchStudioTagTask{
|
||||
studio: studio,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
|
||||
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
|
||||
logger.Infof("Initiating stash-box batch studio tag")
|
||||
|
||||
var tasks []Task
|
||||
var err error
|
||||
var tasks []StashBoxBatchTagTask
|
||||
|
||||
switch input.getBatchTagType(false) {
|
||||
case batchTagByIds:
|
||||
tasks, err = s.batchTagStudiosByIds(ctx, input, box)
|
||||
case batchTagByNamesOrStashIds:
|
||||
tasks = s.batchTagStudiosByNamesOrStashIds(input, box)
|
||||
case batchTagAll:
|
||||
tasks, err = s.batchTagAllStudios(ctx, input, box)
|
||||
}
|
||||
// The gocritic linter wants to turn this ifElseChain into a switch.
|
||||
// however, such a switch would contain quite large blocks for each section
|
||||
// and would arguably be hard to read.
|
||||
//
|
||||
// This is why we mark this section nolint. In principle, we should look to
|
||||
// rewrite the section at some point, to avoid the linter warning.
|
||||
if len(input.Ids) > 0 { //nolint:gocritic
|
||||
// The user has chosen only to tag the items on the current page
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
studioQuery := s.Repository.Studio
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
for _, studioID := range input.Ids {
|
||||
if id, err := strconv.Atoi(studioID); err == nil {
|
||||
studio, err := studioQuery.Find(ctx, id)
|
||||
if err == nil {
|
||||
if err := studio.LoadStashIDs(ctx, studioQuery); err != nil {
|
||||
return fmt.Errorf("loading studio stash ids: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user wants to refresh existing or new items
|
||||
hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil
|
||||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, StashBoxBatchTagTask{
|
||||
studio: studio,
|
||||
refresh: input.Refresh,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
taskType: Studio,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
} else if len(input.Names) > 0 {
|
||||
// The user is batch adding studios
|
||||
for i := range input.Names {
|
||||
name := input.Names[i]
|
||||
if len(name) > 0 {
|
||||
tasks = append(tasks, StashBoxBatchTagTask{
|
||||
name: &name,
|
||||
refresh: false,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
taskType: Studio,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else { //nolint:gocritic
|
||||
// The gocritic linter wants to fold this if-block into the else on the line above.
|
||||
// However, this doesn't really help with readability of the current section. Mark it
|
||||
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
|
||||
// this into separate functions.
|
||||
|
||||
// The user has chosen to tag every item in their database
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
studioQuery := s.Repository.Studio
|
||||
var studios []*models.Studio
|
||||
var err error
|
||||
|
||||
if input.Refresh {
|
||||
studios, err = studioQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
|
||||
} else {
|
||||
studios, err = studioQuery.FindByStashIDStatus(ctx, false, box.Endpoint)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying studios: %v", err)
|
||||
}
|
||||
|
||||
for _, studio := range studios {
|
||||
tasks = append(tasks, StashBoxBatchTagTask{
|
||||
studio: studio,
|
||||
refresh: input.Refresh,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
taskType: Studio,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
@@ -692,7 +629,7 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB
|
||||
logger.Infof("Starting stash-box batch operation for %d studios", len(tasks))
|
||||
|
||||
for _, task := range tasks {
|
||||
progress.ExecuteTask(task.GetDescription(), func() {
|
||||
progress.ExecuteTask(task.Description(), func() {
|
||||
task.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ type SceneService interface {
|
||||
Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error)
|
||||
AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error
|
||||
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
|
||||
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
|
||||
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
|
||||
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
sceneFingerprintGetter
|
||||
}
|
||||
|
||||
type ImageService interface {
|
||||
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
|
||||
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type GalleryService interface {
|
||||
SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error
|
||||
ResetCover(ctx context.Context, g *models.Gallery) error
|
||||
|
||||
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error)
|
||||
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
|
||||
|
||||
ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error
|
||||
|
||||
|
||||
@@ -300,10 +300,7 @@ func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *fil
|
||||
// only delete if the scene has no other files
|
||||
if len(scene.Files.List()) <= 1 {
|
||||
logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName())
|
||||
const deleteGenerated = true
|
||||
const deleteFile = false
|
||||
const destroyFileEntry = false
|
||||
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -424,10 +421,7 @@ func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *fil
|
||||
|
||||
if len(i.Files.List()) <= 1 {
|
||||
logger.Infof("Deleting image %q since it has no other related files", i.DisplayName())
|
||||
const deleteGenerated = true
|
||||
const deleteFile = false
|
||||
const destroyFileEntry = false
|
||||
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ type GenerateMetadataInput struct {
|
||||
// Generate transcodes even if not required
|
||||
ForceTranscodes bool `json:"forceTranscodes"`
|
||||
Phashes bool `json:"phashes"`
|
||||
ImagePhashes bool `json:"imagePhashes"`
|
||||
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
|
||||
ClipPreviews bool `json:"clipPreviews"`
|
||||
ImageThumbnails bool `json:"imageThumbnails"`
|
||||
@@ -37,10 +36,6 @@ type GenerateMetadataInput struct {
|
||||
SceneIDs []string `json:"sceneIDs"`
|
||||
// marker ids to generate for
|
||||
MarkerIDs []string `json:"markerIDs"`
|
||||
// image ids to generate for
|
||||
ImageIDs []string `json:"imageIDs"`
|
||||
// gallery ids to generate for
|
||||
GalleryIDs []string `json:"galleryIDs"`
|
||||
// overwrite existing media
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
@@ -78,7 +73,6 @@ type totalsGenerate struct {
|
||||
markers int64
|
||||
transcodes int64
|
||||
phashes int64
|
||||
imagePhashes int64
|
||||
interactiveHeatmapSpeeds int64
|
||||
clipPreviews int64
|
||||
imageThumbnails int64
|
||||
@@ -88,9 +82,8 @@ type totalsGenerate struct {
|
||||
|
||||
func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
var scenes []*models.Scene
|
||||
var markers []*models.SceneMarker
|
||||
var images []*models.Image
|
||||
var err error
|
||||
var markers []*models.SceneMarker
|
||||
|
||||
j.overwrite = j.input.Overwrite
|
||||
j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||
@@ -112,14 +105,6 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
imageIDs, err := stringslice.StringSliceToIntSlice(j.input.ImageIDs)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
galleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
|
||||
g := &generate.Generator{
|
||||
Encoder: instance.FFMpeg,
|
||||
@@ -133,7 +118,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
r := j.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Scene
|
||||
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 {
|
||||
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 {
|
||||
j.queueTasks(ctx, g, queue)
|
||||
} else {
|
||||
if len(j.input.SceneIDs) > 0 {
|
||||
@@ -156,33 +141,6 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
j.queueMarkerJob(g, m, queue)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.input.ImageIDs) > 0 {
|
||||
images, err = r.Image.FindMany(ctx, imageIDs)
|
||||
for _, i := range images {
|
||||
if err := i.LoadFiles(ctx, r.Image); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
j.queueImageJob(g, i, queue)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.input.GalleryIDs) > 0 {
|
||||
for _, galleryID := range galleryIDs {
|
||||
imgs, err := r.Image.FindByGalleryID(ctx, galleryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, img := range imgs {
|
||||
if err := img.LoadFiles(ctx, r.Image); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
j.queueImageJob(g, img, queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -214,17 +172,14 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
if j.input.Phashes {
|
||||
logMsg += fmt.Sprintf(" %d phashes", totals.phashes)
|
||||
}
|
||||
if j.input.ImagePhashes {
|
||||
logMsg += fmt.Sprintf(" %d image phashes", totals.imagePhashes)
|
||||
}
|
||||
if j.input.InteractiveHeatmapsSpeeds {
|
||||
logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds)
|
||||
}
|
||||
if j.input.ClipPreviews {
|
||||
logMsg += fmt.Sprintf(" %d image clip previews", totals.clipPreviews)
|
||||
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
|
||||
}
|
||||
if j.input.ImageThumbnails {
|
||||
logMsg += fmt.Sprintf(" %d image thumbnails", totals.imageThumbnails)
|
||||
logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails)
|
||||
}
|
||||
if logMsg == "Generating" {
|
||||
logMsg = "Nothing selected to generate"
|
||||
@@ -329,7 +284,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato
|
||||
|
||||
r := j.repository
|
||||
|
||||
for more := j.input.ClipPreviews || j.input.ImageThumbnails || j.input.ImagePhashes; more; {
|
||||
for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; {
|
||||
if job.IsCancelled(ctx) {
|
||||
return
|
||||
}
|
||||
@@ -456,13 +411,12 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||
}
|
||||
}
|
||||
|
||||
if j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots {
|
||||
if j.input.Markers {
|
||||
task := &GenerateMarkersTask{
|
||||
repository: r,
|
||||
Scene: scene,
|
||||
Overwrite: j.overwrite,
|
||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||
VideoPreview: j.input.Markers,
|
||||
ImagePreview: j.input.MarkerImagePreviews,
|
||||
Screenshot: j.input.MarkerScreenshots,
|
||||
|
||||
@@ -534,9 +488,6 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
|
||||
Marker: marker,
|
||||
Overwrite: j.overwrite,
|
||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||
VideoPreview: j.input.Markers,
|
||||
ImagePreview: j.input.MarkerImagePreviews,
|
||||
Screenshot: j.input.MarkerScreenshots,
|
||||
generator: g,
|
||||
}
|
||||
j.totals.markers++
|
||||
@@ -570,23 +521,4 @@ func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image,
|
||||
queue <- task
|
||||
}
|
||||
}
|
||||
|
||||
if j.input.ImagePhashes {
|
||||
// generate for all files in image
|
||||
for _, f := range image.Files.List() {
|
||||
if imageFile, ok := f.(*models.ImageFile); ok {
|
||||
task := &GenerateImagePhashTask{
|
||||
repository: j.repository,
|
||||
File: imageFile,
|
||||
Overwrite: j.overwrite,
|
||||
}
|
||||
|
||||
if task.required() {
|
||||
j.totals.imagePhashes++
|
||||
j.totals.tasks++
|
||||
queue <- task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/hash/imagephash"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type GenerateImagePhashTask struct {
|
||||
repository models.Repository
|
||||
File *models.ImageFile
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) GetDescription() string {
|
||||
return fmt.Sprintf("Generating phash for %s", t.File.Path)
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) Start(ctx context.Context) {
|
||||
if !t.required() {
|
||||
return
|
||||
}
|
||||
|
||||
var hash int64
|
||||
set := false
|
||||
|
||||
// #4393 - if there is a file with the same md5, we can use the same phash
|
||||
// only use this if we're not overwriting
|
||||
if !t.Overwrite {
|
||||
existing, err := t.findExistingPhash(ctx)
|
||||
if err != nil {
|
||||
logger.Warnf("Error finding existing phash: %v", err)
|
||||
} else if existing != nil {
|
||||
logger.Infof("Using existing phash for %s", t.File.Path)
|
||||
hash = existing.(int64)
|
||||
set = true
|
||||
}
|
||||
}
|
||||
|
||||
if !set {
|
||||
generated, err := imagephash.Generate(t.File)
|
||||
if err != nil {
|
||||
logger.Errorf("Error generating phash for %q: %v", t.File.Path, err)
|
||||
logErrorOutput(err)
|
||||
return
|
||||
}
|
||||
|
||||
hash = int64(*generated)
|
||||
}
|
||||
|
||||
r := t.repository
|
||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{
|
||||
Type: models.FingerprintTypePhash,
|
||||
Fingerprint: hash,
|
||||
})
|
||||
|
||||
return r.File.Update(ctx, t.File)
|
||||
}); err != nil && ctx.Err() == nil {
|
||||
logger.Errorf("Error setting phash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) {
|
||||
r := t.repository
|
||||
var ret interface{}
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
md5 := t.File.Fingerprints.Get(models.FingerprintTypeMD5)
|
||||
|
||||
// find other files with the same md5
|
||||
files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Fingerprint: md5,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding files by md5: %w", err)
|
||||
}
|
||||
|
||||
// find the first file with a phash
|
||||
for _, file := range files {
|
||||
if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil {
|
||||
ret = phash
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) required() bool {
|
||||
if t.Overwrite {
|
||||
return true
|
||||
}
|
||||
|
||||
return t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
@@ -21,13 +20,6 @@ func (t *GenerateImageThumbnailTask) GetDescription() string {
|
||||
return fmt.Sprintf("Generating Thumbnail for image %s", t.Image.Path)
|
||||
}
|
||||
|
||||
func (t *GenerateImageThumbnailTask) logStderr(err error) {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
logger.Debugf("[generator] error output: %s", exitErr.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
|
||||
if !t.required() {
|
||||
return
|
||||
@@ -54,15 +46,14 @@ func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
|
||||
if err != nil {
|
||||
// don't log for animated images
|
||||
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
|
||||
logger.Errorf("[generator] getting thumbnail for image %s: %s", path, err.Error())
|
||||
t.logStderr(err)
|
||||
logger.Errorf("[generator] getting thumbnail for image %s: %w", path, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = fsutil.WriteFile(thumbPath, data)
|
||||
if err != nil {
|
||||
logger.Errorf("[generator] writing thumbnail for image %s: %s", path, err.Error())
|
||||
logger.Errorf("[generator] writing thumbnail for image %s: %w", path, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ type GenerateMarkersTask struct {
|
||||
Overwrite bool
|
||||
fileNamingAlgorithm models.HashAlgorithm
|
||||
|
||||
VideoPreview bool
|
||||
ImagePreview bool
|
||||
Screenshot bool
|
||||
|
||||
@@ -116,11 +115,9 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene
|
||||
|
||||
g := t.generator
|
||||
|
||||
if t.VideoPreview {
|
||||
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
|
||||
logger.Errorf("[generator] failed to generate marker video: %v", err)
|
||||
logErrorOutput(err)
|
||||
}
|
||||
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
|
||||
logger.Errorf("[generator] failed to generate marker video: %v", err)
|
||||
logErrorOutput(err)
|
||||
}
|
||||
|
||||
if t.ImagePreview {
|
||||
@@ -167,7 +164,7 @@ func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bo
|
||||
return false
|
||||
}
|
||||
|
||||
videoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds)
|
||||
videoExists := t.videoExists(sceneChecksum, seconds)
|
||||
imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds)
|
||||
screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func (t *GeneratePhashTask) Start(ctx context.Context) {
|
||||
if !set {
|
||||
generated, err := videophash.Generate(instance.FFMpeg, t.File)
|
||||
if err != nil {
|
||||
logger.Errorf("Error generating phash for %q: %v", t.File.Path, err)
|
||||
logger.Errorf("Error generating phash: %v", err)
|
||||
logErrorOutput(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler/lru"
|
||||
"github.com/remeh/sizedwaitgroup"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/file/video"
|
||||
@@ -28,13 +24,14 @@ import (
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
)
|
||||
|
||||
type scanner interface {
|
||||
Scan(ctx context.Context, handlers []file.Handler, options file.ScanOptions, progressReporter file.ProgressReporter)
|
||||
}
|
||||
|
||||
type ScanJob struct {
|
||||
scanner *file.Scanner
|
||||
scanner scanner
|
||||
input ScanMetadataInput
|
||||
subscriptions *subscriptionManager
|
||||
|
||||
fileQueue chan file.ScannedFile
|
||||
count int
|
||||
}
|
||||
|
||||
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
@@ -58,22 +55,22 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
nTasks := cfg.GetParallelTasksWithAutoDetection()
|
||||
|
||||
const taskQueueSize = 200000
|
||||
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, nTasks)
|
||||
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, cfg.GetParallelTasksWithAutoDetection())
|
||||
|
||||
var minModTime time.Time
|
||||
if j.input.Filter != nil && j.input.Filter.MinModTime != nil {
|
||||
minModTime = *j.input.Filter.MinModTime
|
||||
}
|
||||
|
||||
// HACK - these should really be set in the scanner initialization
|
||||
j.scanner.FileHandlers = getScanHandlers(j.input, taskQueue, progress)
|
||||
j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)}
|
||||
j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)}
|
||||
|
||||
j.runJob(ctx, paths, nTasks, progress)
|
||||
j.scanner.Scan(ctx, getScanHandlers(j.input, taskQueue, progress), file.ScanOptions{
|
||||
Paths: paths,
|
||||
ScanFilters: []file.PathFilter{newScanFilter(c, repo, minModTime)},
|
||||
ZipFileExtensions: cfg.GetGalleryExtensions(),
|
||||
ParallelTasks: cfg.GetParallelTasksWithAutoDetection(),
|
||||
HandlerRequiredFilters: []file.Filter{newHandlerRequiredFilter(cfg, repo)},
|
||||
Rescan: j.input.Rescan,
|
||||
}, progress)
|
||||
|
||||
taskQueue.Close()
|
||||
|
||||
@@ -89,264 +86,6 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScanJob) runJob(ctx context.Context, paths []string, nTasks int, progress *job.Progress) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
j.fileQueue = make(chan file.ScannedFile, scanQueueSize)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
|
||||
// handle panics in goroutine
|
||||
if p := recover(); p != nil {
|
||||
logger.Errorf("panic while queuing files for scan: %v", p)
|
||||
logger.Errorf(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := j.queueFiles(ctx, paths, progress); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Errorf("error queuing files for scan: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Finished adding files to queue. %d files queued", j.count)
|
||||
}()
|
||||
|
||||
defer wg.Wait()
|
||||
|
||||
j.processQueue(ctx, nTasks, progress)
|
||||
}
|
||||
|
||||
const scanQueueSize = 200000
|
||||
|
||||
func (j *ScanJob) queueFiles(ctx context.Context, paths []string, progress *job.Progress) error {
|
||||
fs := &file.OsFS{}
|
||||
|
||||
defer func() {
|
||||
close(j.fileQueue)
|
||||
|
||||
progress.AddTotal(j.count)
|
||||
progress.Definite()
|
||||
}()
|
||||
|
||||
var err error
|
||||
progress.ExecuteTask("Walking directory tree", func() {
|
||||
for _, p := range paths {
|
||||
err = file.SymWalk(fs, p, j.queueFileFunc(ctx, fs, nil, progress))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.ScannedFile, progress *job.Progress) fs.WalkDirFunc {
|
||||
return func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
logger.Errorf("error scanning %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
logger.Errorf("reading info for %q: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !j.scanner.AcceptEntry(ctx, path, info) {
|
||||
if info.IsDir() {
|
||||
logger.Debugf("Skipping directory %s", path)
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
logger.Debugf("Skipping file %s", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := file.GetFileSize(f, path, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ff := file.ScannedFile{
|
||||
BaseFile: &models.BaseFile{
|
||||
DirEntry: models.DirEntry{
|
||||
ModTime: file.ModTime(info),
|
||||
},
|
||||
Path: path,
|
||||
Basename: filepath.Base(path),
|
||||
Size: size,
|
||||
},
|
||||
FS: f,
|
||||
Info: info,
|
||||
}
|
||||
|
||||
if zipFile != nil {
|
||||
ff.ZipFileID = &zipFile.ID
|
||||
ff.ZipFile = zipFile
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// handle folders immediately
|
||||
if err := j.handleFolder(ctx, ff, progress); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
|
||||
// skip the directory since we won't be able to process the files anyway
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// if zip file is present, we handle immediately
|
||||
if zipFile != nil {
|
||||
progress.ExecuteTask("Scanning "+path, func() {
|
||||
// don't increment progress in zip files
|
||||
if err := j.handleFile(ctx, ff, nil); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
// don't return an error, just skip the file
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Tracef("Queueing file %s for scanning", path)
|
||||
j.fileQueue <- ff
|
||||
|
||||
j.count++
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress *job.Progress) {
|
||||
if parallelTasks < 1 {
|
||||
parallelTasks = 1
|
||||
}
|
||||
|
||||
wg := sizedwaitgroup.New(parallelTasks)
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
wg.Wait()
|
||||
|
||||
// handle panics in goroutine
|
||||
if p := recover(); p != nil {
|
||||
logger.Errorf("panic while scanning files: %v", p)
|
||||
logger.Errorf(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
for f := range j.fileQueue {
|
||||
logger.Tracef("Processing queued file %s", f.Path)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
ff := f
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
j.processQueueItem(ctx, ff, progress)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (j *ScanJob) processQueueItem(ctx context.Context, f file.ScannedFile, progress *job.Progress) {
|
||||
progress.ExecuteTask("Scanning "+f.Path, func() {
|
||||
var err error
|
||||
if f.Info.IsDir() {
|
||||
err = j.handleFolder(ctx, f, progress)
|
||||
} else {
|
||||
err = j.handleFile(ctx, f, progress)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", f.Path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (j *ScanJob) handleFolder(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
|
||||
if progress != nil {
|
||||
defer progress.Increment()
|
||||
}
|
||||
|
||||
_, err := j.scanner.ScanFolder(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
|
||||
if progress != nil {
|
||||
defer progress.Increment()
|
||||
}
|
||||
|
||||
r, err := j.scanner.ScanFile(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// handle rename should have already handled the contents of the zip file
|
||||
// so shouldn't need to scan it again
|
||||
|
||||
if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) {
|
||||
ff := r.File
|
||||
f.BaseFile = ff.Base()
|
||||
|
||||
// scan zip files with a different context that is not cancellable
|
||||
// cancelling while scanning zip file contents results in the scan
|
||||
// contents being partially completed
|
||||
zipCtx := context.WithoutCancel(ctx)
|
||||
|
||||
if err := j.scanZipFile(zipCtx, f, progress); err != nil {
|
||||
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
|
||||
zipFS, err := f.FS.OpenZip(f.Path, f.Size)
|
||||
if err != nil {
|
||||
if errors.Is(err, file.ErrNotReaderAt) {
|
||||
// can't walk the zip file
|
||||
// just return
|
||||
logger.Debugf("Skipping zip file %q as it cannot be opened for walking", f.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
defer zipFS.Close()
|
||||
|
||||
return file.SymWalk(zipFS, f.Path, j.queueFileFunc(ctx, zipFS, &f, progress))
|
||||
}
|
||||
|
||||
type extensionConfig struct {
|
||||
vidExt []string
|
||||
imgExt []string
|
||||
@@ -724,29 +463,6 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
|
||||
}
|
||||
}
|
||||
|
||||
if t.ScanGenerateImagePhashes {
|
||||
progress.AddTotal(1)
|
||||
phashFn := func(ctx context.Context) {
|
||||
mgr := GetInstance()
|
||||
// Only generate phash for image files, not video files
|
||||
if imageFile, ok := f.(*models.ImageFile); ok {
|
||||
taskPhash := GenerateImagePhashTask{
|
||||
repository: mgr.Repository,
|
||||
File: imageFile,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
taskPhash.Start(ctx)
|
||||
}
|
||||
progress.Increment()
|
||||
}
|
||||
|
||||
if g.sequentialScanning {
|
||||
phashFn(ctx)
|
||||
} else {
|
||||
g.taskQueue.Add(fmt.Sprintf("Generating phash for %s", path), phashFn)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,33 +14,57 @@ import (
|
||||
"github.com/stashapp/stash/pkg/studio"
|
||||
)
|
||||
|
||||
// stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.
|
||||
//
|
||||
// Two modes of operation:
|
||||
// - Update existing performer: set performer to update from stash-box data
|
||||
// - Create new performer: set name or stashID to search stash-box and create locally
|
||||
type stashBoxBatchPerformerTagTask struct {
|
||||
type StashBoxTagTaskType int
|
||||
|
||||
const (
|
||||
Performer StashBoxTagTaskType = iota
|
||||
Studio
|
||||
)
|
||||
|
||||
type StashBoxBatchTagTask struct {
|
||||
box *models.StashBox
|
||||
name *string
|
||||
stashID *string
|
||||
performer *models.Performer
|
||||
studio *models.Studio
|
||||
refresh bool
|
||||
createParent bool
|
||||
excludedFields []string
|
||||
taskType StashBoxTagTaskType
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) getName() string {
|
||||
switch {
|
||||
case t.name != nil:
|
||||
return *t.name
|
||||
case t.stashID != nil:
|
||||
return *t.stashID
|
||||
case t.performer != nil:
|
||||
return t.performer.Name
|
||||
func (t *StashBoxBatchTagTask) Start(ctx context.Context) {
|
||||
switch t.taskType {
|
||||
case Performer:
|
||||
t.stashBoxPerformerTag(ctx)
|
||||
case Studio:
|
||||
t.stashBoxStudioTag(ctx)
|
||||
default:
|
||||
return ""
|
||||
logger.Errorf("Error starting batch task, unknown task_type %d", t.taskType)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
|
||||
func (t *StashBoxBatchTagTask) Description() string {
|
||||
if t.taskType == Performer {
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else {
|
||||
name = t.performer.Name
|
||||
}
|
||||
return fmt.Sprintf("Tagging performer %s from stash-box", name)
|
||||
} else if t.taskType == Studio {
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else {
|
||||
name = t.studio.Name
|
||||
}
|
||||
return fmt.Sprintf("Tagging studio %s from stash-box", name)
|
||||
}
|
||||
return fmt.Sprintf("Unknown tagging task type %d from stash-box", t.taskType)
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
|
||||
performer, err := t.findStashBoxPerformer(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Error fetching performer data from stash-box: %v", err)
|
||||
@@ -52,18 +76,21 @@ func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
|
||||
excluded[field] = true
|
||||
}
|
||||
|
||||
// performer will have a value if pulling from Stash-box by Stash ID or name was successful
|
||||
if performer != nil {
|
||||
t.processMatchedPerformer(ctx, performer, excluded)
|
||||
} else {
|
||||
logger.Infof("No match found for %s", t.getName())
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else if t.performer != nil {
|
||||
name = t.performer.Name
|
||||
}
|
||||
logger.Infof("No match found for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) GetDescription() string {
|
||||
return fmt.Sprintf("Tagging performer %s from stash-box", t.getName())
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
|
||||
func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
|
||||
var performer *models.ScrapedPerformer
|
||||
var err error
|
||||
|
||||
@@ -71,24 +98,7 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex
|
||||
|
||||
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
|
||||
|
||||
switch {
|
||||
case t.name != nil:
|
||||
performer, err = client.FindPerformerByName(ctx, *t.name)
|
||||
case t.stashID != nil:
|
||||
performer, err = client.FindPerformerByID(ctx, *t.stashID)
|
||||
|
||||
if performer != nil && performer.RemoteMergedIntoId != nil {
|
||||
mergedPerformer, err := t.handleMergedPerformer(ctx, performer, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mergedPerformer != nil {
|
||||
logger.Infof("Performer id %s merged into %s, updating local performer", *t.stashID, *performer.RemoteMergedIntoId)
|
||||
performer = mergedPerformer
|
||||
}
|
||||
}
|
||||
case t.performer != nil: // tagging or updating existing performer
|
||||
if t.refresh {
|
||||
var remoteID string
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Performer
|
||||
@@ -108,7 +118,6 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if remoteID != "" {
|
||||
performer, err = client.FindPerformerByID(ctx, remoteID)
|
||||
|
||||
@@ -123,10 +132,15 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex
|
||||
performer = mergedPerformer
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// find by performer name instead
|
||||
performer, err = client.FindPerformerByName(ctx, t.performer.Name)
|
||||
}
|
||||
} else {
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else {
|
||||
name = t.performer.Name
|
||||
}
|
||||
performer, err = client.FindPerformerByName(ctx, name)
|
||||
}
|
||||
|
||||
if performer != nil {
|
||||
@@ -140,7 +154,7 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex
|
||||
return performer, err
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
|
||||
func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
|
||||
mergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading merged performer %s from stashbox", *performer.RemoteMergedIntoId)
|
||||
@@ -155,7 +169,8 @@ func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Contex
|
||||
return mergedPerformer, nil
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
|
||||
func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
|
||||
// Refreshing an existing performer
|
||||
if t.performer != nil {
|
||||
storedID, _ := strconv.Atoi(*p.StoredID)
|
||||
|
||||
@@ -165,6 +180,7 @@ func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Cont
|
||||
return
|
||||
}
|
||||
|
||||
// Start the transaction and update the performer
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Performer
|
||||
@@ -210,8 +226,8 @@ func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Cont
|
||||
} else {
|
||||
logger.Infof("Updated performer %s", *p.Name)
|
||||
}
|
||||
} else {
|
||||
// no existing performer, create a new one
|
||||
} else if t.name != nil && p.Name != nil {
|
||||
// Creating a new performer
|
||||
newPerformer := p.ToPerformer(t.box.Endpoint, excluded)
|
||||
image, err := p.GetImage(ctx, excluded)
|
||||
if err != nil {
|
||||
@@ -247,34 +263,7 @@ func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Cont
|
||||
}
|
||||
}
|
||||
|
||||
// stashBoxBatchStudioTagTask is used to tag or create studios from stash-box.
|
||||
//
|
||||
// Two modes of operation:
|
||||
// - Update existing studio: set studio to update from stash-box data
|
||||
// - Create new studio: set name or stashID to search stash-box and create locally
|
||||
type stashBoxBatchStudioTagTask struct {
|
||||
box *models.StashBox
|
||||
name *string
|
||||
stashID *string
|
||||
studio *models.Studio
|
||||
createParent bool
|
||||
excludedFields []string
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchStudioTagTask) getName() string {
|
||||
switch {
|
||||
case t.name != nil:
|
||||
return *t.name
|
||||
case t.stashID != nil:
|
||||
return *t.stashID
|
||||
case t.studio != nil:
|
||||
return t.studio.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) {
|
||||
func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) {
|
||||
studio, err := t.findStashBoxStudio(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Error fetching studio data from stash-box: %v", err)
|
||||
@@ -286,18 +275,21 @@ func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) {
|
||||
excluded[field] = true
|
||||
}
|
||||
|
||||
// studio will have a value if pulling from Stash-box by Stash ID or name was successful
|
||||
if studio != nil {
|
||||
t.processMatchedStudio(ctx, studio, excluded)
|
||||
} else {
|
||||
logger.Infof("No match found for %s", t.getName())
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else if t.studio != nil {
|
||||
name = t.studio.Name
|
||||
}
|
||||
logger.Infof("No match found for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchStudioTagTask) GetDescription() string {
|
||||
return fmt.Sprintf("Tagging studio %s from stash-box", t.getName())
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {
|
||||
func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {
|
||||
var studio *models.ScrapedStudio
|
||||
var err error
|
||||
|
||||
@@ -305,12 +297,7 @@ func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*m
|
||||
|
||||
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
|
||||
|
||||
switch {
|
||||
case t.name != nil:
|
||||
studio, err = client.FindStudio(ctx, *t.name)
|
||||
case t.stashID != nil:
|
||||
studio, err = client.FindStudio(ctx, *t.stashID)
|
||||
case t.studio != nil:
|
||||
if t.refresh {
|
||||
var remoteID string
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
if !t.studio.StashIDs.Loaded() {
|
||||
@@ -328,13 +315,17 @@ func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*m
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if remoteID != "" {
|
||||
studio, err = client.FindStudio(ctx, remoteID)
|
||||
} else {
|
||||
// find by studio name instead
|
||||
studio, err = client.FindStudio(ctx, t.studio.Name)
|
||||
}
|
||||
} else {
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else {
|
||||
name = t.studio.Name
|
||||
}
|
||||
studio, err = client.FindStudio(ctx, name)
|
||||
}
|
||||
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
@@ -352,7 +343,8 @@ func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*m
|
||||
return studio, err
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
|
||||
func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
|
||||
// Refreshing an existing studio
|
||||
if t.studio != nil {
|
||||
storedID, _ := strconv.Atoi(*s.StoredID)
|
||||
|
||||
@@ -369,6 +361,7 @@ func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s
|
||||
return
|
||||
}
|
||||
|
||||
// Start the transaction and update the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
@@ -401,8 +394,8 @@ func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s
|
||||
} else {
|
||||
logger.Infof("Updated studio %s", s.Name)
|
||||
}
|
||||
} else if s.Name != "" {
|
||||
// no existing studio, create a new one
|
||||
} else if t.name != nil && s.Name != "" {
|
||||
// Creating a new studio
|
||||
if s.Parent != nil && t.createParent {
|
||||
err := t.processParentStudio(ctx, s.Parent, excluded)
|
||||
if err != nil {
|
||||
@@ -417,6 +410,7 @@ func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s
|
||||
return
|
||||
}
|
||||
|
||||
// Start the transaction and save the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
@@ -445,8 +439,9 @@ func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {
|
||||
func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {
|
||||
if parent.StoredID == nil {
|
||||
// The parent needs to be created
|
||||
newParentStudio := parent.ToStudio(t.box.Endpoint, excluded)
|
||||
|
||||
image, err := parent.GetImage(ctx, excluded)
|
||||
@@ -455,6 +450,7 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the transaction and save the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
@@ -480,6 +476,7 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
// The parent studio matched an existing one and the user has chosen in the UI to link and/or update it
|
||||
storedID, _ := strconv.Atoi(*parent.StoredID)
|
||||
|
||||
image, err := parent.GetImage(ctx, excluded)
|
||||
@@ -488,6 +485,7 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the transaction and update the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
|
||||
@@ -36,32 +36,6 @@ const minHeight int = 480
|
||||
|
||||
// Tests all (given) hardware codec's
|
||||
func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
// do the hardware codec tests in a separate goroutine to avoid blocking
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
f.initHWSupport(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// log if the initialization takes too long
|
||||
const hwInitLogTimeoutSecondsDefault = 5
|
||||
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second
|
||||
timer := time.NewTimer(hwInitLogTimeoutSeconds)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds)
|
||||
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
|
||||
case <-done:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (f *FFMpeg) initHWSupport(ctx context.Context) {
|
||||
var hwCodecSupport []VideoCodec
|
||||
|
||||
// Note that the first compatible codec is returned, so order is important
|
||||
@@ -95,7 +69,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
|
||||
args = args.Output("-")
|
||||
|
||||
// #6064 - add timeout to context to prevent hangs
|
||||
const hwTestTimeoutSecondsDefault = 10
|
||||
const hwTestTimeoutSecondsDefault = 1
|
||||
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second
|
||||
|
||||
// allow timeout to be overridden with environment variable
|
||||
@@ -109,7 +83,6 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
|
||||
defer cancel()
|
||||
|
||||
cmd := f.Command(testCtx, args)
|
||||
cmd.WaitDelay = time.Second
|
||||
logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
@@ -117,7 +90,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if testCtx.Err() != nil {
|
||||
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds)
|
||||
logger.Debugf("[InitHWSupport] Codec %s test timed out after %d seconds", codec, hwTestTimeoutSeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -139,8 +112,6 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
|
||||
}
|
||||
logger.Info(outstr)
|
||||
|
||||
f.hwCodecSupportMutex.Lock()
|
||||
defer f.hwCodecSupportMutex.Unlock()
|
||||
f.hwCodecSupport = hwCodecSupport
|
||||
}
|
||||
|
||||
@@ -363,11 +334,8 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw
|
||||
args = args.Append("scale_qsv=format=nv12")
|
||||
}
|
||||
case VideoCodecRK264:
|
||||
// Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15.
|
||||
// h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12
|
||||
if fullhw {
|
||||
args = args.Append("scale_rkrga=w=iw:h=ih:format=nv12")
|
||||
}
|
||||
// For Rockchip, no extra mapping here. If there is no scale filter,
|
||||
// leave frames in DRM_PRIME for the encoder.
|
||||
}
|
||||
|
||||
return args
|
||||
@@ -402,7 +370,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in
|
||||
// by downloading the scaled frame to system RAM and re-uploading it.
|
||||
// The filter chain below uses a zero-copy approach, passing the hardware-scaled
|
||||
// frame directly to the encoder. This is more efficient but may be less stable.
|
||||
template = "scale_rkrga=$value:format=nv12"
|
||||
template = "scale_rkrga=$value"
|
||||
default:
|
||||
return VideoFilter(sargs)
|
||||
}
|
||||
@@ -443,7 +411,7 @@ func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHei
|
||||
|
||||
// Return if a hardware accelerated for HLS is available
|
||||
func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
for _, element := range f.hwCodecSupport {
|
||||
switch element {
|
||||
case VideoCodecN264,
|
||||
VideoCodecN264H,
|
||||
@@ -461,7 +429,7 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
|
||||
|
||||
// Return if a hardware accelerated codec for MP4 is available
|
||||
func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
for _, element := range f.hwCodecSupport {
|
||||
switch element {
|
||||
case VideoCodecN264,
|
||||
VideoCodecN264H,
|
||||
@@ -477,7 +445,7 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
|
||||
|
||||
// Return if a hardware accelerated codec for WebM is available
|
||||
func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
for _, element := range f.hwCodecSupport {
|
||||
switch element {
|
||||
case VideoCodecIVP9,
|
||||
VideoCodecVVP9:
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
@@ -217,10 +216,9 @@ func (v Version) String() string {
|
||||
|
||||
// FFMpeg provides an interface to ffmpeg.
|
||||
type FFMpeg struct {
|
||||
ffmpeg string
|
||||
version Version
|
||||
hwCodecSupport []VideoCodec
|
||||
hwCodecSupportMutex sync.RWMutex
|
||||
ffmpeg string
|
||||
version Version
|
||||
hwCodecSupport []VideoCodec
|
||||
}
|
||||
|
||||
// Creates a new FFMpeg encoder
|
||||
@@ -243,9 +241,3 @@ func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
|
||||
func (f *FFMpeg) Path() string {
|
||||
return f.ffmpeg
|
||||
}
|
||||
|
||||
func (f *FFMpeg) getHWCodecSupport() []VideoCodec {
|
||||
f.hwCodecSupportMutex.RLock()
|
||||
defer f.hwCodecSupportMutex.RUnlock()
|
||||
return f.hwCodecSupport
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ type Cleaner struct {
|
||||
FS models.FS
|
||||
Repository Repository
|
||||
|
||||
Handlers []CleanHandler
|
||||
TrashPath string
|
||||
Handlers []CleanHandler
|
||||
}
|
||||
|
||||
type cleanJob struct {
|
||||
@@ -393,7 +392,7 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool
|
||||
|
||||
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {
|
||||
// delete associated objects
|
||||
fileDeleter := NewDeleterWithTrash(j.TrashPath)
|
||||
fileDeleter := NewDeleter()
|
||||
r := j.Repository
|
||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
fileDeleter.RegisterHooks(ctx)
|
||||
@@ -411,7 +410,7 @@ func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn stri
|
||||
|
||||
func (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) {
|
||||
// delete associated objects
|
||||
fileDeleter := NewDeleterWithTrash(j.TrashPath)
|
||||
fileDeleter := NewDeleter()
|
||||
r := j.Repository
|
||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
fileDeleter.RegisterHooks(ctx)
|
||||
|
||||
@@ -58,33 +58,20 @@ func newRenamerRemoverImpl() renamerRemoverImpl {
|
||||
|
||||
// Deleter is used to safely delete files and directories from the filesystem.
|
||||
// During a transaction, files and directories are marked for deletion using
|
||||
// the Files and Dirs methods. If TrashPath is set, files are moved to trash
|
||||
// immediately. Otherwise, they are renamed with a .delete suffix. If the
|
||||
// transaction is rolled back, then the files/directories can be restored to
|
||||
// their original state with the Rollback method. If the transaction is
|
||||
// committed, the marked files are then deleted from the filesystem using the
|
||||
// Commit method.
|
||||
// the Files and Dirs methods. This will rename the files/directories to be
|
||||
// deleted. If the transaction is rolled back, then the files/directories can
|
||||
// be restored to their original state with the Abort method. If the
|
||||
// transaction is committed, the marked files are then deleted from the
|
||||
// filesystem using the Complete method.
|
||||
type Deleter struct {
|
||||
RenamerRemover RenamerRemover
|
||||
files []string
|
||||
dirs []string
|
||||
TrashPath string // if set, files will be moved to this directory instead of being permanently deleted
|
||||
trashedPaths map[string]string // map of original path -> trash path (only used when TrashPath is set)
|
||||
}
|
||||
|
||||
func NewDeleter() *Deleter {
|
||||
return &Deleter{
|
||||
RenamerRemover: newRenamerRemoverImpl(),
|
||||
TrashPath: "",
|
||||
trashedPaths: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDeleterWithTrash(trashPath string) *Deleter {
|
||||
return &Deleter{
|
||||
RenamerRemover: newRenamerRemoverImpl(),
|
||||
TrashPath: trashPath,
|
||||
trashedPaths: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,17 +92,6 @@ func (d *Deleter) RegisterHooks(ctx context.Context) {
|
||||
// Abort should be called to restore marked files if this function returns an
|
||||
// error.
|
||||
func (d *Deleter) Files(paths []string) error {
|
||||
return d.filesInternal(paths, false)
|
||||
}
|
||||
|
||||
// FilesWithoutTrash designates files to be deleted, bypassing the trash directory.
|
||||
// Files will be permanently deleted even if TrashPath is configured.
|
||||
// This is useful for deleting generated files that can be easily recreated.
|
||||
func (d *Deleter) FilesWithoutTrash(paths []string) error {
|
||||
return d.filesInternal(paths, true)
|
||||
}
|
||||
|
||||
func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error {
|
||||
for _, p := range paths {
|
||||
// fail silently if the file does not exist
|
||||
if _, err := d.RenamerRemover.Stat(p); err != nil {
|
||||
@@ -127,7 +103,7 @@ func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error {
|
||||
return fmt.Errorf("check file %q exists: %w", p, err)
|
||||
}
|
||||
|
||||
if err := d.renameForDelete(p, bypassTrash); err != nil {
|
||||
if err := d.renameForDelete(p); err != nil {
|
||||
return fmt.Errorf("marking file %q for deletion: %w", p, err)
|
||||
}
|
||||
d.files = append(d.files, p)
|
||||
@@ -142,17 +118,6 @@ func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error {
|
||||
// Abort should be called to restore marked files/directories if this function returns an
|
||||
// error.
|
||||
func (d *Deleter) Dirs(paths []string) error {
|
||||
return d.dirsInternal(paths, false)
|
||||
}
|
||||
|
||||
// DirsWithoutTrash designates directories to be deleted, bypassing the trash directory.
|
||||
// Directories will be permanently deleted even if TrashPath is configured.
|
||||
// This is useful for deleting generated directories that can be easily recreated.
|
||||
func (d *Deleter) DirsWithoutTrash(paths []string) error {
|
||||
return d.dirsInternal(paths, true)
|
||||
}
|
||||
|
||||
func (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error {
|
||||
for _, p := range paths {
|
||||
// fail silently if the file does not exist
|
||||
if _, err := d.RenamerRemover.Stat(p); err != nil {
|
||||
@@ -164,7 +129,7 @@ func (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error {
|
||||
return fmt.Errorf("check directory %q exists: %w", p, err)
|
||||
}
|
||||
|
||||
if err := d.renameForDelete(p, bypassTrash); err != nil {
|
||||
if err := d.renameForDelete(p); err != nil {
|
||||
return fmt.Errorf("marking directory %q for deletion: %w", p, err)
|
||||
}
|
||||
d.dirs = append(d.dirs, p)
|
||||
@@ -185,65 +150,33 @@ func (d *Deleter) Rollback() {
|
||||
|
||||
d.files = nil
|
||||
d.dirs = nil
|
||||
d.trashedPaths = make(map[string]string)
|
||||
}
|
||||
|
||||
// Commit deletes all files marked for deletion and clears the marked list.
|
||||
// When using trash, files have already been moved during renameForDelete, so
|
||||
// this just clears the tracking. Otherwise, permanently delete the .delete files.
|
||||
// Any errors encountered are logged. All files will be attempted, regardless
|
||||
// of the errors encountered.
|
||||
func (d *Deleter) Commit() {
|
||||
if d.TrashPath != "" {
|
||||
// Files were already moved to trash during renameForDelete, just clear tracking
|
||||
logger.Debugf("Commit: %d files and %d directories already in trash, clearing tracking", len(d.files), len(d.dirs))
|
||||
} else {
|
||||
// Permanently delete files and directories marked with .delete suffix
|
||||
for _, f := range d.files {
|
||||
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
|
||||
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
|
||||
}
|
||||
for _, f := range d.files {
|
||||
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
|
||||
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range d.dirs {
|
||||
if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {
|
||||
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
|
||||
}
|
||||
for _, f := range d.dirs {
|
||||
if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {
|
||||
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
|
||||
}
|
||||
}
|
||||
|
||||
d.files = nil
|
||||
d.dirs = nil
|
||||
d.trashedPaths = make(map[string]string)
|
||||
}
|
||||
|
||||
func (d *Deleter) renameForDelete(path string, bypassTrash bool) error {
|
||||
if d.TrashPath != "" && !bypassTrash {
|
||||
// Move file to trash immediately
|
||||
trashDest, err := fsutil.MoveToTrash(path, d.TrashPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.trashedPaths[path] = trashDest
|
||||
logger.Infof("Moved %q to trash at %s", path, trashDest)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Standard behavior: rename with .delete suffix (or when bypassing trash)
|
||||
func (d *Deleter) renameForDelete(path string) error {
|
||||
return d.RenamerRemover.Rename(path, path+deleteFileSuffix)
|
||||
}
|
||||
|
||||
func (d *Deleter) renameForRestore(path string) error {
|
||||
if d.TrashPath != "" {
|
||||
// Restore file from trash
|
||||
trashPath, ok := d.trashedPaths[path]
|
||||
if !ok {
|
||||
return fmt.Errorf("no trash path found for %q", path)
|
||||
}
|
||||
return d.RenamerRemover.Rename(trashPath, path)
|
||||
}
|
||||
|
||||
// Standard behavior: restore from .delete suffix
|
||||
return d.RenamerRemover.Rename(path+deleteFileSuffix, path)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
@@ -39,23 +35,3 @@ func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {
|
||||
func (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error {
|
||||
return txn.WithDatabase(ctx, r.TxnManager, fn)
|
||||
}
|
||||
|
||||
// ModTime returns the modification time truncated to seconds.
|
||||
func ModTime(info fs.FileInfo) time.Time {
|
||||
// truncate to seconds, since we don't store beyond that in the database
|
||||
return info.ModTime().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// GetFileSize gets the size of the file, taking into account symlinks.
|
||||
func GetFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) {
|
||||
// #2196/#3042 - replace size with target size if file is a symlink
|
||||
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
targetInfo, err := f.Stat(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading info for symlink %q: %w", path, err)
|
||||
}
|
||||
return targetInfo.Size(), nil
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
// Does not create any folders in the file system
|
||||
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) {
|
||||
// get or create folder hierarchy
|
||||
// assume case sensitive when searching for the folder
|
||||
const caseSensitive = true
|
||||
folder, err := fc.FindByPath(ctx, path, caseSensitive)
|
||||
folder, err := fc.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (d *folderRenameDetector) bestCandidate() *models.Folder {
|
||||
return best.folder
|
||||
}
|
||||
|
||||
func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.Folder, error) {
|
||||
// in order for a folder to be considered moved, the existing folder must be
|
||||
// missing, and the majority of the old folder's files must be present, unchanged,
|
||||
// in the new folder.
|
||||
@@ -88,7 +88,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
|
||||
|
||||
r := s.Repository
|
||||
|
||||
if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error {
|
||||
if err := symWalk(file.fs, file.Path, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
logger.Errorf("error scanning %s: %v", path, err)
|
||||
@@ -111,11 +111,11 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.AcceptEntry(ctx, path, info) {
|
||||
if !s.acceptEntry(ctx, path, info) {
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := GetFileSize(file.FS, path, info)
|
||||
size, err := getFileSize(file.fs, path, info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting file size for %q: %w", path, err)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
|
||||
}
|
||||
|
||||
// parent folder must be missing
|
||||
_, err = file.FS.Lstat(pf.Path)
|
||||
_, err = file.fs.Lstat(pf.Path)
|
||||
if err == nil {
|
||||
// parent folder exists, not a candidate
|
||||
detector.reject(parentFolderID)
|
||||
|
||||
@@ -2,11 +2,8 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
@@ -20,8 +17,6 @@ import (
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var ErrUnsupportedAVIFInZip = errors.New("AVIF images in zip files is unsupported")
|
||||
|
||||
// Decorator adds image specific fields to a File.
|
||||
type Decorator struct {
|
||||
FFProbe *ffmpeg.FFProbe
|
||||
@@ -33,10 +28,6 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (
|
||||
// ignore clips in non-OsFS filesystems as ffprobe cannot read them
|
||||
// TODO - copy to temp file if not an OsFS
|
||||
if _, isOs := fs.(*file.OsFS); !isOs {
|
||||
// AVIF images inside zip files are not supported
|
||||
if strings.ToLower(filepath.Ext(base.Path)) == ".avif" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedAVIFInZip, base.Path)
|
||||
}
|
||||
logger.Debugf("assuming ImageFile for non-OsFS file %q", base.Path)
|
||||
return decorateFallback(fs, f)
|
||||
}
|
||||
@@ -76,25 +67,6 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (
|
||||
Height: probe.Height,
|
||||
}
|
||||
|
||||
// FFprobe has a known bug where it returns 0x0 dimensions for some animated WebP files
|
||||
// Fall back to image.DecodeConfig in this case.
|
||||
// See: https://trac.ffmpeg.org/ticket/4907
|
||||
if ret.Width == 0 || ret.Height == 0 {
|
||||
logger.Warnf("FFprobe returned invalid dimensions (%dx%d) for %q, trying fallback decoder", ret.Width, ret.Height, base.Path)
|
||||
c, format, err := decodeConfig(fs, base.Path)
|
||||
if err != nil {
|
||||
logger.Warnf("Fallback decoder failed for %q: %s. Proceeding with original FFprobe result", base.Path, err)
|
||||
} else {
|
||||
ret.Width = c.Width
|
||||
ret.Height = c.Height
|
||||
// Update format if it differs (fallback decoder may be more accurate)
|
||||
if format != "" && format != ret.Format {
|
||||
logger.Debugf("Updating format from %q to %q for %q", ret.Format, format, base.Path)
|
||||
ret.Format = format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adjustForOrientation(fs, base.Path, ret)
|
||||
|
||||
return ret, nil
|
||||
|
||||
@@ -120,7 +120,7 @@ func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonsch
|
||||
func (i *Importer) populateZipFileID(ctx context.Context, f *models.DirEntry) error {
|
||||
zipFilePath := i.Input.DirEntry().ZipFile
|
||||
if zipFilePath != "" {
|
||||
zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath, true)
|
||||
zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding file by path %q: %v", zipFilePath, err)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (i *Importer) Name() string {
|
||||
|
||||
func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
|
||||
path := i.Input.DirEntry().Path
|
||||
existing, err := i.ReaderWriter.FindByPath(ctx, path, true)
|
||||
existing, err := i.ReaderWriter.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func (i *Importer) createFolderHierarchy(ctx context.Context, p string) (*models
|
||||
}
|
||||
|
||||
func (i *Importer) getOrCreateFolder(ctx context.Context, path string, parent *models.Folder) (*models.Folder, error) {
|
||||
folder, err := i.FolderStore.FindByPath(ctx, path, true)
|
||||
folder, err := i.FolderStore.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
629
pkg/file/scan.go
629
pkg/file/scan.go
@@ -2,16 +2,27 @@ package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/remeh/sizedwaitgroup"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
scanQueueSize = 200000
|
||||
// maximum number of times to retry in the event of a locked database
|
||||
// use -1 to retry forever
|
||||
maxRetries = -1
|
||||
)
|
||||
|
||||
// Scanner scans files into the database.
|
||||
@@ -44,26 +55,8 @@ type Scanner struct {
|
||||
Repository Repository
|
||||
FingerprintCalculator FingerprintCalculator
|
||||
|
||||
// ZipFileExtensions is a list of file extensions that are considered zip files.
|
||||
// Extension does not include the . character.
|
||||
ZipFileExtensions []string
|
||||
|
||||
// ScanFilters are used to determine if a file should be scanned.
|
||||
ScanFilters []PathFilter
|
||||
|
||||
// HandlerRequiredFilters are used to determine if an unchanged file needs to be handled
|
||||
HandlerRequiredFilters []Filter
|
||||
|
||||
// FileDecorators are applied to files as they are scanned.
|
||||
FileDecorators []Decorator
|
||||
|
||||
// handlers are called after a file has been scanned.
|
||||
FileHandlers []Handler
|
||||
|
||||
// Rescan indicates whether files should be rescanned even if they haven't changed.
|
||||
Rescan bool
|
||||
|
||||
folderPathToID sync.Map
|
||||
}
|
||||
|
||||
// FingerprintCalculator calculates a fingerprint for the provided file.
|
||||
@@ -98,18 +91,246 @@ func (d *FilteredDecorator) IsMissingMetadata(ctx context.Context, fs models.FS,
|
||||
return false
|
||||
}
|
||||
|
||||
// ScannedFile represents a file being scanned.
|
||||
type ScannedFile struct {
|
||||
*models.BaseFile
|
||||
FS models.FS
|
||||
Info fs.FileInfo
|
||||
// ProgressReporter is used to report progress of the scan.
|
||||
type ProgressReporter interface {
|
||||
AddTotal(total int)
|
||||
Increment()
|
||||
Definite()
|
||||
ExecuteTask(description string, fn func())
|
||||
}
|
||||
|
||||
// AcceptEntry determines if the file entry should be accepted for scanning
|
||||
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
type scanJob struct {
|
||||
*Scanner
|
||||
|
||||
// handlers are called after a file has been scanned.
|
||||
handlers []Handler
|
||||
|
||||
ProgressReports ProgressReporter
|
||||
options ScanOptions
|
||||
|
||||
startTime time.Time
|
||||
fileQueue chan scanFile
|
||||
retryList []scanFile
|
||||
retrying bool
|
||||
folderPathToID sync.Map
|
||||
zipPathToID sync.Map
|
||||
count int
|
||||
|
||||
txnRetryer txn.Retryer
|
||||
}
|
||||
|
||||
// ScanOptions provides options for scanning files.
|
||||
type ScanOptions struct {
|
||||
Paths []string
|
||||
|
||||
// ZipFileExtensions is a list of file extensions that are considered zip files.
|
||||
// Extension does not include the . character.
|
||||
ZipFileExtensions []string
|
||||
|
||||
// ScanFilters are used to determine if a file should be scanned.
|
||||
ScanFilters []PathFilter
|
||||
|
||||
// HandlerRequiredFilters are used to determine if an unchanged file needs to be handled
|
||||
HandlerRequiredFilters []Filter
|
||||
|
||||
ParallelTasks int
|
||||
|
||||
// When true files in path will be rescanned even if they haven't changed
|
||||
Rescan bool
|
||||
}
|
||||
|
||||
// Scan starts the scanning process.
|
||||
func (s *Scanner) Scan(ctx context.Context, handlers []Handler, options ScanOptions, progressReporter ProgressReporter) {
|
||||
job := &scanJob{
|
||||
Scanner: s,
|
||||
handlers: handlers,
|
||||
ProgressReports: progressReporter,
|
||||
options: options,
|
||||
txnRetryer: txn.Retryer{
|
||||
Manager: s.Repository.TxnManager,
|
||||
Retries: maxRetries,
|
||||
},
|
||||
}
|
||||
|
||||
job.execute(ctx)
|
||||
}
|
||||
|
||||
type scanFile struct {
|
||||
*models.BaseFile
|
||||
fs models.FS
|
||||
info fs.FileInfo
|
||||
}
|
||||
|
||||
func (s *scanJob) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
return s.txnRetryer.WithTxn(ctx, fn)
|
||||
}
|
||||
|
||||
func (s *scanJob) withDB(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
return s.Repository.WithDB(ctx, fn)
|
||||
}
|
||||
|
||||
func (s *scanJob) execute(ctx context.Context) {
|
||||
paths := s.options.Paths
|
||||
logger.Infof("scanning %d paths", len(paths))
|
||||
s.startTime = time.Now()
|
||||
|
||||
s.fileQueue = make(chan scanFile, scanQueueSize)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := s.queueFiles(ctx, paths); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Errorf("error queuing files for scan: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Finished adding files to queue. %d files queued", s.count)
|
||||
}()
|
||||
|
||||
defer wg.Wait()
|
||||
|
||||
if err := s.processQueue(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Errorf("error scanning files: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
|
||||
var err error
|
||||
s.ProgressReports.ExecuteTask("Walking directory tree", func() {
|
||||
for _, p := range paths {
|
||||
err = symWalk(s.FS, p, s.queueFileFunc(ctx, s.FS, nil))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
close(s.fileQueue)
|
||||
|
||||
if s.ProgressReports != nil {
|
||||
s.ProgressReports.AddTotal(s.count)
|
||||
s.ProgressReports.Definite()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *scanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *scanFile) fs.WalkDirFunc {
|
||||
return func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
logger.Errorf("error scanning %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
logger.Errorf("reading info for %q: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.acceptEntry(ctx, path, info) {
|
||||
if info.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := getFileSize(f, path, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ff := scanFile{
|
||||
BaseFile: &models.BaseFile{
|
||||
DirEntry: models.DirEntry{
|
||||
ModTime: modTime(info),
|
||||
},
|
||||
Path: path,
|
||||
Basename: filepath.Base(path),
|
||||
Size: size,
|
||||
},
|
||||
fs: f,
|
||||
info: info,
|
||||
}
|
||||
|
||||
if zipFile != nil {
|
||||
zipFileID, err := s.getZipFileID(ctx, zipFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ff.ZipFileID = zipFileID
|
||||
ff.ZipFile = zipFile
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// handle folders immediately
|
||||
if err := s.handleFolder(ctx, ff); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
|
||||
// skip the directory since we won't be able to process the files anyway
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// if zip file is present, we handle immediately
|
||||
if zipFile != nil {
|
||||
s.ProgressReports.ExecuteTask("Scanning "+path, func() {
|
||||
if err := s.handleFile(ctx, ff); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
// don't return an error, just skip the file
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
s.fileQueue <- ff
|
||||
|
||||
s.count++
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) {
|
||||
// #2196/#3042 - replace size with target size if file is a symlink
|
||||
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
targetInfo, err := f.Stat(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading info for symlink %q: %w", path, err)
|
||||
}
|
||||
return targetInfo.Size(), nil
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func (s *scanJob) acceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
// always accept if there's no filters
|
||||
accept := len(s.ScanFilters) == 0
|
||||
for _, filter := range s.ScanFilters {
|
||||
accept := len(s.options.ScanFilters) == 0
|
||||
for _, filter := range s.options.ScanFilters {
|
||||
// accept if any filter accepts the file
|
||||
if filter.Accept(ctx, path, info) {
|
||||
accept = true
|
||||
@@ -120,17 +341,109 @@ func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo
|
||||
return accept
|
||||
}
|
||||
|
||||
func (s *Scanner) getFolderID(ctx context.Context, path string) (*models.FolderID, error) {
|
||||
func (s *scanJob) scanZipFile(ctx context.Context, f scanFile) error {
|
||||
zipFS, err := f.fs.OpenZip(f.Path, f.Size)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotReaderAt) {
|
||||
// can't walk the zip file
|
||||
// just return
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
defer zipFS.Close()
|
||||
|
||||
return symWalk(zipFS, f.Path, s.queueFileFunc(ctx, zipFS, &f))
|
||||
}
|
||||
|
||||
func (s *scanJob) processQueue(ctx context.Context) error {
|
||||
parallelTasks := s.options.ParallelTasks
|
||||
if parallelTasks < 1 {
|
||||
parallelTasks = 1
|
||||
}
|
||||
|
||||
wg := sizedwaitgroup.New(parallelTasks)
|
||||
|
||||
if err := func() error {
|
||||
defer wg.Wait()
|
||||
|
||||
for f := range s.fileQueue {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
ff := f
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.processQueueItem(ctx, ff)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.retrying = true
|
||||
|
||||
if err := func() error {
|
||||
defer wg.Wait()
|
||||
|
||||
for _, f := range s.retryList {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
ff := f
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.processQueueItem(ctx, ff)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scanJob) incrementProgress(f scanFile) {
|
||||
// don't increment for files inside zip files since these aren't
|
||||
// counted during the initial walking
|
||||
if s.ProgressReports != nil && f.ZipFile == nil {
|
||||
s.ProgressReports.Increment()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scanJob) processQueueItem(ctx context.Context, f scanFile) {
|
||||
s.ProgressReports.ExecuteTask("Scanning "+f.Path, func() {
|
||||
var err error
|
||||
if f.info.IsDir() {
|
||||
err = s.handleFolder(ctx, f)
|
||||
} else {
|
||||
err = s.handleFile(ctx, f)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", f.Path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderID, error) {
|
||||
// check the folder cache first
|
||||
if f, ok := s.folderPathToID.Load(path); ok {
|
||||
v := f.(models.FolderID)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// assume case sensitive when searching for the folder
|
||||
const caseSensitive = true
|
||||
|
||||
ret, err := s.Repository.Folder.FindByPath(ctx, path, caseSensitive)
|
||||
ret, err := s.Repository.Folder.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,35 +456,48 @@ func (s *Scanner) getFolderID(ctx context.Context, path string) (*models.FolderI
|
||||
return &ret.ID, nil
|
||||
}
|
||||
|
||||
// ScanFolder scans the provided folder into the database, returning the folder entry.
|
||||
// If the folder already exists, it is updated if necessary.
|
||||
func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
var f *models.Folder
|
||||
var err error
|
||||
func (s *scanJob) getZipFileID(ctx context.Context, zipFile *scanFile) (*models.FileID, error) {
|
||||
if zipFile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if zipFile.ID != 0 {
|
||||
return &zipFile.ID, nil
|
||||
}
|
||||
|
||||
path := zipFile.Path
|
||||
|
||||
// check the folder cache first
|
||||
if f, ok := s.zipPathToID.Load(path); ok {
|
||||
v := f.(models.FileID)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
ret, err := s.Repository.File.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting zip file ID for %q: %w", path, err)
|
||||
}
|
||||
|
||||
if ret == nil {
|
||||
return nil, fmt.Errorf("zip file %q doesn't exist in database", zipFile.Path)
|
||||
}
|
||||
|
||||
s.zipPathToID.Store(path, ret.Base().ID)
|
||||
return &ret.Base().ID, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error {
|
||||
path := file.Path
|
||||
|
||||
err = s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
return s.withTxn(ctx, func(ctx context.Context) error {
|
||||
defer s.incrementProgress(file)
|
||||
|
||||
// determine if folder already exists in data store (by path)
|
||||
// assume case sensitive by default
|
||||
f, err = s.Repository.Folder.FindByPath(ctx, path, true)
|
||||
f, err := s.Repository.Folder.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing folder %q: %w", path, err)
|
||||
}
|
||||
|
||||
// #1426 / #6326 - if folder is in a case-insensitive filesystem, then try
|
||||
// case insensitive searching
|
||||
// assume case sensitive if in zip
|
||||
if f == nil && file.ZipFileID == nil {
|
||||
caseSensitive, _ := file.FS.IsPathCaseSensitive(file.Path)
|
||||
|
||||
if !caseSensitive {
|
||||
f, err = s.Repository.Folder.FindByPath(ctx, path, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing folder %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if folder not exists, create it
|
||||
if f == nil {
|
||||
f, err = s.onNewFolder(ctx, file)
|
||||
@@ -189,11 +515,9 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*models.Folder, error) {
|
||||
renamed, err := s.handleFolderRename(ctx, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -240,7 +564,7 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo
|
||||
return toCreate, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) handleFolderRename(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
func (s *scanJob) handleFolderRename(ctx context.Context, file scanFile) (*models.Folder, error) {
|
||||
// ignore folders in zip files
|
||||
if file.ZipFileID != nil {
|
||||
return nil, nil
|
||||
@@ -281,24 +605,16 @@ func (s *Scanner) handleFolderRename(ctx context.Context, file ScannedFile) (*mo
|
||||
return renamedFrom, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing *models.Folder) (*models.Folder, error) {
|
||||
func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *models.Folder) (*models.Folder, error) {
|
||||
update := false
|
||||
|
||||
// update if mod time is changed
|
||||
entryModTime := f.ModTime
|
||||
if !entryModTime.Equal(existing.ModTime) {
|
||||
existing.Path = f.Path
|
||||
existing.ModTime = entryModTime
|
||||
update = true
|
||||
}
|
||||
|
||||
// #6326 - update if path has changed - should only happen if case is
|
||||
// changed and filesystem is case insensitive
|
||||
if existing.Path != f.Path {
|
||||
existing.Path = f.Path
|
||||
update = true
|
||||
}
|
||||
|
||||
// update if zip file ID has changed
|
||||
fZfID := f.ZipFileID
|
||||
existingZfID := existing.ZipFileID
|
||||
@@ -322,59 +638,55 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
type ScanFileResult struct {
|
||||
File models.File
|
||||
New bool
|
||||
Renamed bool
|
||||
Updated bool
|
||||
func modTime(info fs.FileInfo) time.Time {
|
||||
// truncate to seconds, since we don't store beyond that in the database
|
||||
return info.ModTime().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// ScanFile scans the provided file into the database, returning the scan result.
|
||||
func (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) {
|
||||
var r *ScanFileResult
|
||||
func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
|
||||
defer s.incrementProgress(f)
|
||||
|
||||
var ff models.File
|
||||
// don't use a transaction to check if new or existing
|
||||
if err := s.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
if err := s.withDB(ctx, func(ctx context.Context) error {
|
||||
// determine if file already exists in data store
|
||||
// assume case sensitive when searching for the file to begin with
|
||||
ff, err := s.Repository.File.FindByPath(ctx, f.Path, true)
|
||||
var err error
|
||||
ff, err = s.Repository.File.FindByPath(ctx, f.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing file %q: %w", f.Path, err)
|
||||
}
|
||||
|
||||
// #1426 / #6326 - if file is in a case-insensitive filesystem, then try
|
||||
// case insensitive search
|
||||
// assume case sensitive if in zip
|
||||
if ff == nil && f.ZipFileID != nil {
|
||||
caseSensitive, _ := f.FS.IsPathCaseSensitive(f.Path)
|
||||
|
||||
if !caseSensitive {
|
||||
ff, err = s.Repository.File.FindByPath(ctx, f.Path, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing file %q: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ff == nil {
|
||||
// returns a file only if it is actually new
|
||||
r, err = s.onNewFile(ctx, f)
|
||||
ff, err = s.onNewFile(ctx, f)
|
||||
return err
|
||||
}
|
||||
|
||||
r, err = s.onExistingFile(ctx, f, ff)
|
||||
ff, err = s.onExistingFile(ctx, f, ff)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
if ff != nil && s.isZipFile(f.info.Name()) {
|
||||
f.BaseFile = ff.Base()
|
||||
|
||||
// scan zip files with a different context that is not cancellable
|
||||
// cancelling while scanning zip file contents results in the scan
|
||||
// contents being partially completed
|
||||
zipCtx := utils.ValueOnlyContext{Context: ctx}
|
||||
|
||||
if err := s.scanZipFile(zipCtx, f); err != nil {
|
||||
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsZipFile determines if the provided path is a zip file based on its extension.
|
||||
func (s *Scanner) IsZipFile(path string) bool {
|
||||
func (s *scanJob) isZipFile(path string) bool {
|
||||
fExt := filepath.Ext(path)
|
||||
for _, ext := range s.ZipFileExtensions {
|
||||
for _, ext := range s.options.ZipFileExtensions {
|
||||
if strings.EqualFold(fExt, "."+ext) {
|
||||
return true
|
||||
}
|
||||
@@ -383,7 +695,7 @@ func (s *Scanner) IsZipFile(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) {
|
||||
func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error) {
|
||||
now := time.Now()
|
||||
|
||||
baseFile := f.BaseFile
|
||||
@@ -399,20 +711,28 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
|
||||
}
|
||||
|
||||
if parentFolderID == nil {
|
||||
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
|
||||
// if parent folder doesn't exist, assume it's not yet created
|
||||
// add this file to the queue to be created later
|
||||
if s.retrying {
|
||||
// if we're retrying and the folder still doesn't exist, then it's a problem
|
||||
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
|
||||
}
|
||||
|
||||
s.retryList = append(s.retryList, f)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
baseFile.ParentFolderID = *parentFolderID
|
||||
|
||||
const useExisting = false
|
||||
fp, err := s.calculateFingerprints(f.FS, baseFile, path, useExisting)
|
||||
fp, err := s.calculateFingerprints(f.fs, baseFile, path, useExisting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseFile.SetFingerprints(fp)
|
||||
|
||||
file, err := s.fireDecorators(ctx, f.FS, baseFile)
|
||||
file, err := s.fireDecorators(ctx, f.fs, baseFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,17 +745,14 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
|
||||
}
|
||||
|
||||
if renamed != nil {
|
||||
return &ScanFileResult{
|
||||
File: renamed,
|
||||
Renamed: true,
|
||||
}, nil
|
||||
// handle rename should have already handled the contents of the zip file
|
||||
// so shouldn't need to scan it again
|
||||
// return nil so it doesn't
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// if not renamed, queue file for creation
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Create(ctx, file); err != nil {
|
||||
return fmt.Errorf("creating file %q: %w", path, err)
|
||||
}
|
||||
@@ -449,13 +766,10 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ScanFileResult{
|
||||
File: file,
|
||||
New: true,
|
||||
}, nil
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) {
|
||||
func (s *scanJob) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) {
|
||||
for _, h := range s.FileDecorators {
|
||||
var err error
|
||||
f, err = h.Decorate(ctx, fs, f)
|
||||
@@ -467,8 +781,8 @@ func (s *Scanner) fireDecorators(ctx context.Context, fs models.FS, f models.Fil
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error {
|
||||
for _, h := range s.FileHandlers {
|
||||
func (s *scanJob) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error {
|
||||
for _, h := range s.handlers {
|
||||
if err := h.Handle(ctx, f, oldFile); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -477,7 +791,7 @@ func (s *Scanner) fireHandlers(ctx context.Context, f models.File, oldFile model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) {
|
||||
func (s *scanJob) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) {
|
||||
// only log if we're (re)calculating fingerprints
|
||||
if !useExisting {
|
||||
logger.Infof("Calculating fingerprints for %s ...", path)
|
||||
@@ -514,7 +828,7 @@ func appendFileUnique(v []models.File, toAdd []models.File) []models.File {
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {
|
||||
func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) {
|
||||
if f.ZipFile == nil {
|
||||
return s.FS, nil
|
||||
}
|
||||
@@ -525,11 +839,10 @@ func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {
|
||||
}
|
||||
|
||||
zipPath := f.ZipFile.Base().Path
|
||||
zipSize := f.ZipFile.Base().Size
|
||||
return fs.OpenZip(zipPath, zipSize)
|
||||
return fs.OpenZip(zipPath, f.Size)
|
||||
}
|
||||
|
||||
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
|
||||
func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
|
||||
var others []models.File
|
||||
|
||||
for _, tfp := range fp {
|
||||
@@ -566,12 +879,11 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
// #1426 - if file exists but is a case-insensitive match for the
|
||||
// original filename, and the filesystem is case-insensitive
|
||||
// then treat it as a move
|
||||
// #6326 - this should now be handled earlier, and this shouldn't be necessary
|
||||
if caseSensitive, _ := fs.IsPathCaseSensitive(other.Base().Path); !caseSensitive {
|
||||
// treat as a move
|
||||
missing = append(missing, other)
|
||||
}
|
||||
case !s.AcceptEntry(ctx, other.Base().Path, info):
|
||||
case !s.acceptEntry(ctx, other.Base().Path, info):
|
||||
// #4393 - if the file is no longer in the configured library paths, treat it as a move
|
||||
logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path)
|
||||
missing = append(missing, other)
|
||||
@@ -604,12 +916,12 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
fBaseCopy.Fingerprints = updatedBase.Fingerprints
|
||||
*updatedBase = fBaseCopy
|
||||
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, updated); err != nil {
|
||||
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
|
||||
}
|
||||
|
||||
if s.IsZipFile(updatedBase.Basename) {
|
||||
if s.isZipFile(updatedBase.Basename) {
|
||||
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
|
||||
}
|
||||
@@ -627,9 +939,9 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool {
|
||||
accept := len(s.HandlerRequiredFilters) == 0
|
||||
for _, filter := range s.HandlerRequiredFilters {
|
||||
func (s *scanJob) isHandlerRequired(ctx context.Context, f models.File) bool {
|
||||
accept := len(s.options.HandlerRequiredFilters) == 0
|
||||
for _, filter := range s.options.HandlerRequiredFilters {
|
||||
// accept if any filter accepts the file
|
||||
if filter.Accept(ctx, f) {
|
||||
accept = true
|
||||
@@ -648,9 +960,9 @@ func (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool {
|
||||
// - file size
|
||||
// - image format, width or height
|
||||
// - video codec, audio codec, format, width, height, framerate or bitrate
|
||||
func (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) bool {
|
||||
func (s *scanJob) isMissingMetadata(ctx context.Context, f scanFile, existing models.File) bool {
|
||||
for _, h := range s.FileDecorators {
|
||||
if h.IsMissingMetadata(ctx, f.FS, existing) {
|
||||
if h.IsMissingMetadata(ctx, f.fs, existing) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -658,20 +970,20 @@ func (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Scanner) setMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) {
|
||||
func (s *scanJob) setMissingMetadata(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
path := existing.Base().Path
|
||||
logger.Infof("Updating metadata for %s", path)
|
||||
|
||||
existing.Base().Size = f.Size
|
||||
|
||||
var err error
|
||||
existing, err = s.fireDecorators(ctx, f.FS, existing)
|
||||
existing, err = s.fireDecorators(ctx, f.fs, existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// queue file for update
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("updating file %q: %w", path, err)
|
||||
}
|
||||
@@ -684,9 +996,9 @@ func (s *Scanner) setMissingMetadata(ctx context.Context, f ScannedFile, existin
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) {
|
||||
func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
const useExisting = true
|
||||
fp, err := s.calculateFingerprints(f.FS, existing.Base(), f.Path, useExisting)
|
||||
fp, err := s.calculateFingerprints(f.fs, existing.Base(), f.Path, useExisting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -694,7 +1006,7 @@ func (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, exi
|
||||
if fp.ContentsChanged(existing.Base().Fingerprints) {
|
||||
existing.SetFingerprints(fp)
|
||||
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("updating file %q: %w", f.Path, err)
|
||||
}
|
||||
@@ -709,14 +1021,13 @@ func (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, exi
|
||||
}
|
||||
|
||||
// returns a file only if it was updated
|
||||
func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) {
|
||||
func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
base := existing.Base()
|
||||
path := base.Path
|
||||
|
||||
fileModTime := f.ModTime
|
||||
// #6326 - also force a rescan if the basename changed
|
||||
updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename
|
||||
forceRescan := s.Rescan
|
||||
updated := !fileModTime.Equal(base.ModTime)
|
||||
forceRescan := s.options.Rescan
|
||||
|
||||
if !updated && !forceRescan {
|
||||
return s.onUnchangedFile(ctx, f, existing)
|
||||
@@ -730,15 +1041,13 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
|
||||
logger.Infof("%s has been updated: rescanning", path)
|
||||
}
|
||||
|
||||
// #6326 - update basename in case it changed
|
||||
base.Basename = f.Basename
|
||||
base.ModTime = fileModTime
|
||||
base.Size = f.Size
|
||||
base.UpdatedAt = time.Now()
|
||||
|
||||
// calculate and update fingerprints for the file
|
||||
const useExisting = false
|
||||
fp, err := s.calculateFingerprints(f.FS, base, path, useExisting)
|
||||
fp, err := s.calculateFingerprints(f.fs, base, path, useExisting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -746,13 +1055,13 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
|
||||
s.removeOutdatedFingerprints(existing, fp)
|
||||
existing.SetFingerprints(fp)
|
||||
|
||||
existing, err = s.fireDecorators(ctx, f.FS, existing)
|
||||
existing, err = s.fireDecorators(ctx, f.fs, existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// queue file for update
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("updating file %q: %w", path, err)
|
||||
}
|
||||
@@ -765,13 +1074,11 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
Updated: true,
|
||||
}, nil
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) {
|
||||
func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) {
|
||||
// HACK - if no MD5 fingerprint was returned, and the oshash is changed
|
||||
// then remove the MD5 fingerprint
|
||||
oshash := fp.For(models.FingerprintTypeOshash)
|
||||
@@ -799,7 +1106,7 @@ func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fin
|
||||
}
|
||||
|
||||
// returns a file only if it was updated
|
||||
func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) {
|
||||
func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
var err error
|
||||
|
||||
isMissingMetdata := s.isMissingMetadata(ctx, f, existing)
|
||||
@@ -818,7 +1125,7 @@ func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing m
|
||||
}
|
||||
|
||||
handlerRequired := false
|
||||
if err := s.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
if err := s.withDB(ctx, func(ctx context.Context) error {
|
||||
// check if the handler needs to be run
|
||||
handlerRequired = s.isHandlerRequired(ctx, existing)
|
||||
return nil
|
||||
@@ -828,20 +1135,15 @@ func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing m
|
||||
|
||||
if !handlerRequired {
|
||||
// if this file is a zip file, then we need to rescan the contents
|
||||
// as well. We do this by indicating that the file is updated.
|
||||
// as well. We do this by returning the file, instead of nil.
|
||||
if isMissingMetdata {
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
Updated: true,
|
||||
}, nil
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
}, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.fireHandlers(ctx, existing, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -852,9 +1154,6 @@ func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing m
|
||||
}
|
||||
|
||||
// if this file is a zip file, then we need to rescan the contents
|
||||
// as well. We do this by indicating that the file is updated.
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
Updated: true,
|
||||
}, nil
|
||||
// as well. We do this by returning the file, instead of nil.
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag
|
||||
captionPrefix := getCaptionPrefix(captionPath)
|
||||
if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error {
|
||||
var err error
|
||||
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true)
|
||||
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*")
|
||||
|
||||
if er != nil {
|
||||
return fmt.Errorf("searching for scene %s: %w", captionPrefix, er)
|
||||
|
||||
@@ -81,8 +81,8 @@ func walkSym(f models.FS, filename string, linkDirname string, walkFn fs.WalkDir
|
||||
return fsWalk(f, filename, symWalkFunc)
|
||||
}
|
||||
|
||||
// SymWalk extends filepath.Walk to also follow symlinks
|
||||
func SymWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error {
|
||||
// symWalk extends filepath.Walk to also follow symlinks
|
||||
func symWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error {
|
||||
return walkSym(fs, path, path, walkFn)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotReaderAt = errors.New("invalid reader: does not implement io.ReaderAt")
|
||||
errNotReaderAt = errors.New("not a ReaderAt")
|
||||
errZipFSOpenZip = errors.New("cannot open zip file inside zip file")
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func newZipFS(fs models.FS, path string, size int64) (*zipFS, error) {
|
||||
asReaderAt, _ := reader.(io.ReaderAt)
|
||||
if asReaderAt == nil {
|
||||
reader.Close()
|
||||
return nil, ErrNotReaderAt
|
||||
return nil, errNotReaderAt
|
||||
}
|
||||
|
||||
zipReader, err := zip.NewReader(asReaderAt, size)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MoveToTrash moves a file or directory to a custom trash directory.
|
||||
// If a file with the same name already exists in the trash, a timestamp is appended.
|
||||
// Returns the destination path where the file was moved to.
|
||||
func MoveToTrash(sourcePath string, trashPath string) (string, error) {
|
||||
// Get absolute path for the source
|
||||
absSourcePath, err := filepath.Abs(sourcePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Ensure trash directory exists
|
||||
if err := os.MkdirAll(trashPath, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create trash directory: %w", err)
|
||||
}
|
||||
|
||||
// Get the base name of the file/directory
|
||||
baseName := filepath.Base(absSourcePath)
|
||||
destPath := filepath.Join(trashPath, baseName)
|
||||
|
||||
// If a file with the same name already exists in trash, append timestamp
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
ext := filepath.Ext(baseName)
|
||||
nameWithoutExt := baseName[:len(baseName)-len(ext)]
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
destPath = filepath.Join(trashPath, fmt.Sprintf("%s_%s%s", nameWithoutExt, timestamp, ext))
|
||||
}
|
||||
|
||||
// Move the file to trash using SafeMove to support cross-filesystem moves
|
||||
if err := SafeMove(absSourcePath, destPath); err != nil {
|
||||
return "", fmt.Errorf("failed to move to trash: %w", err)
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
|
||||
var imgsDestroyed []*models.Image
|
||||
|
||||
// chapter deletion is done via delete cascade, so we don't need to do anything here
|
||||
|
||||
// if this is a zip-based gallery, delete the images as well first
|
||||
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter,
|
||||
return qb.Destroy(ctx, galleryChapter.ID)
|
||||
}
|
||||
|
||||
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {
|
||||
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
|
||||
if err := i.LoadFiles(ctx, s.Repository); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,12 +81,6 @@ func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, f
|
||||
if err := destroyer.DestroyZip(ctx, f, fileDeleter.Deleter, deleteFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if destroyFileEntry {
|
||||
// destroy file DB entry without deleting filesystem file
|
||||
const deleteFileFromFS = false
|
||||
if err := destroyer.DestroyZip(ctx, f, nil, deleteFileFromFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ func (i *Importer) populateStudio(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio := models.NewStudio()
|
||||
newStudio.Name = name
|
||||
|
||||
err := i.StudioWriter.Create(ctx, &newStudio)
|
||||
@@ -265,7 +265,7 @@ func (i *Importer) populateFilesFolder(ctx context.Context) error {
|
||||
|
||||
for _, ref := range i.Input.ZipFiles {
|
||||
path := ref
|
||||
f, err := i.FileFinder.FindByPath(ctx, path, true)
|
||||
f, err := i.FileFinder.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding file: %w", err)
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func (i *Importer) populateFilesFolder(ctx context.Context) error {
|
||||
|
||||
if i.Input.FolderPath != "" {
|
||||
path := i.Input.FolderPath
|
||||
f, err := i.FolderFinder.FindByPath(ctx, path, true)
|
||||
f, err := i.FolderFinder.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding folder: %w", err)
|
||||
}
|
||||
|
||||
@@ -115,9 +115,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.Studio.ID = existingStudioID
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = existingStudioID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -147,7 +147,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -16,7 +16,7 @@ type ImageFinder interface {
|
||||
}
|
||||
|
||||
type ImageService interface {
|
||||
Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
|
||||
Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
|
||||
DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ func (i *Importer) populateStudio(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio := models.NewStudio()
|
||||
newStudio.Name = name
|
||||
|
||||
err := i.StudioWriter.Create(ctx, &newStudio)
|
||||
|
||||
@@ -121,9 +121,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.Studio.ID = existingStudioID
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = existingStudioID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -156,7 +156,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package imagephash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/corona10/goimagehash"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// Generate computes a perceptual hash for an image file.
|
||||
func Generate(imageFile *models.ImageFile) (*uint64, error) {
|
||||
img, err := loadImage(imageFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading image: %w", err)
|
||||
}
|
||||
|
||||
hash, err := goimagehash.PerceptionHash(img)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing phash from image: %w", err)
|
||||
}
|
||||
|
||||
hashValue := hash.GetHash()
|
||||
return &hashValue, nil
|
||||
}
|
||||
|
||||
// loadImage loads an image from disk and decodes it.
|
||||
func loadImage(imageFile *models.ImageFile) (image.Image, error) {
|
||||
reader, err := imageFile.Open(&file.OsFS{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding image: %w", err)
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
@@ -19,7 +19,6 @@ type FileDeleter struct {
|
||||
}
|
||||
|
||||
// MarkGeneratedFiles marks for deletion the generated files for the provided image.
|
||||
// Generated files bypass trash and are permanently deleted since they can be regenerated.
|
||||
func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
|
||||
var files []string
|
||||
thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)
|
||||
@@ -33,12 +32,12 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
|
||||
files = append(files, prevPath)
|
||||
}
|
||||
|
||||
return d.FilesWithoutTrash(files)
|
||||
return d.Files(files)
|
||||
}
|
||||
|
||||
// Destroy destroys an image, optionally marking the file and generated files for deletion.
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
|
||||
return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
|
||||
return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile)
|
||||
}
|
||||
|
||||
// DestroyZipImages destroys all images in zip, optionally marking the files and generated files for deletion.
|
||||
@@ -75,8 +74,7 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil
|
||||
}
|
||||
|
||||
const deleteFileInZip = false
|
||||
const destroyFileEntry = false
|
||||
if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip, destroyFileEntry); err != nil {
|
||||
if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -136,8 +134,7 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde
|
||||
continue
|
||||
}
|
||||
|
||||
const destroyFileEntry = false
|
||||
if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -148,15 +145,11 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde
|
||||
}
|
||||
|
||||
// Destroy destroys an image, optionally marking the file and generated files for deletion.
|
||||
func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
|
||||
func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
|
||||
if deleteFile {
|
||||
if err := s.deleteFiles(ctx, i, fileDeleter); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if destroyFileEntry {
|
||||
if err := s.destroyFileEntries(ctx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if deleteGenerated {
|
||||
@@ -198,35 +191,3 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// destroyFileEntries destroys file entries from the database without deleting
|
||||
// the files from the filesystem
|
||||
func (s *Service) destroyFileEntries(ctx context.Context, i *models.Image) error {
|
||||
if err := i.LoadFiles(ctx, s.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range i.Files.List() {
|
||||
// only destroy file entries where there is no other associated image
|
||||
otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(otherImages) > 1 {
|
||||
// other image associated, don't remove
|
||||
continue
|
||||
}
|
||||
|
||||
// don't destroy files in zip archives
|
||||
if f.Base().ZipFileID == nil {
|
||||
const deleteFile = false
|
||||
logger.Info("Destroying image file entry: ", f.Base().Path)
|
||||
if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (i *Importer) populateFiles(ctx context.Context) error {
|
||||
|
||||
for _, ref := range i.Input.Files {
|
||||
path := ref
|
||||
f, err := i.FileFinder.FindByPath(ctx, path, true)
|
||||
f, err := i.FileFinder.FindByPath(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding file: %w", err)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func (i *Importer) populateStudio(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio := models.NewStudio()
|
||||
newStudio.Name = name
|
||||
|
||||
err := i.StudioWriter.Create(ctx, &newStudio)
|
||||
|
||||
@@ -77,9 +77,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.Studio.ID = existingStudioID
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = existingStudioID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -109,7 +109,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -22,8 +22,12 @@ const ffmpegImageQuality = 5
|
||||
var vipsPath string
|
||||
var once sync.Once
|
||||
|
||||
// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation
|
||||
var ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
|
||||
var (
|
||||
ErrUnsupportedImageFormat = errors.New("unsupported image format")
|
||||
|
||||
// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation
|
||||
ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
|
||||
)
|
||||
|
||||
type ThumbnailEncoder struct {
|
||||
FFMpeg *ffmpeg.FFMpeg
|
||||
@@ -79,9 +83,8 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
|
||||
|
||||
data := buf.Bytes()
|
||||
|
||||
format := ""
|
||||
if imageFile, ok := f.(*models.ImageFile); ok {
|
||||
format = imageFile.Format
|
||||
format := imageFile.Format
|
||||
animated := imageFile.Format == formatGif
|
||||
|
||||
// #2266 - if image is webp, then determine if it is animated
|
||||
@@ -93,19 +96,6 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
|
||||
if animated {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format)
|
||||
}
|
||||
|
||||
// AVIF cannot be read from stdin, must use file path
|
||||
// AVIF in zip files is not supported
|
||||
// Note: No Windows check needed here since we use file path, not stdin
|
||||
if format == "avif" {
|
||||
if f.Base().ZipFileID != nil {
|
||||
return nil, fmt.Errorf("%w: AVIF in zip file", ErrNotSupportedForThumbnail)
|
||||
}
|
||||
if e.vips != nil {
|
||||
return e.vips.ImageThumbnailPath(f.Base().Path, maxSize)
|
||||
}
|
||||
return e.ffmpegImageThumbnailPath(f.Base().Path, maxSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Videofiles can only be thumbnailed with ffmpeg
|
||||
@@ -114,15 +104,11 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
|
||||
}
|
||||
|
||||
// vips has issues loading files from stdin on Windows
|
||||
if e.vips != nil {
|
||||
if runtime.GOOS == "windows" && f.Base().ZipFileID == nil {
|
||||
return e.vips.ImageThumbnailPath(f.Base().Path, maxSize)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
return e.vips.ImageThumbnail(buf, maxSize)
|
||||
}
|
||||
if e.vips != nil && runtime.GOOS != "windows" {
|
||||
return e.vips.ImageThumbnail(buf, maxSize)
|
||||
} else {
|
||||
return e.ffmpegImageThumbnail(buf, maxSize)
|
||||
}
|
||||
return e.ffmpegImageThumbnail(buf, maxSize)
|
||||
}
|
||||
|
||||
// GetPreview returns the preview clip of the provided image clip resized to
|
||||
@@ -144,32 +130,16 @@ func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int
|
||||
}
|
||||
|
||||
func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {
|
||||
options := transcoder.ImageThumbnailOptions{
|
||||
args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{
|
||||
OutputFormat: ffmpeg.ImageFormatJpeg,
|
||||
OutputPath: "-",
|
||||
MaxDimensions: maxSize,
|
||||
Quality: ffmpegImageQuality,
|
||||
}
|
||||
|
||||
args := transcoder.ImageThumbnail("-", options)
|
||||
})
|
||||
|
||||
return e.FFMpeg.GenerateOutput(context.TODO(), args, image)
|
||||
}
|
||||
|
||||
// ffmpegImageThumbnailPath generates a thumbnail from a file path (used for AVIF which can't be piped)
|
||||
func (e *ThumbnailEncoder) ffmpegImageThumbnailPath(inputPath string, maxSize int) ([]byte, error) {
|
||||
options := transcoder.ImageThumbnailOptions{
|
||||
OutputFormat: ffmpeg.ImageFormatJpeg,
|
||||
OutputPath: "-",
|
||||
MaxDimensions: maxSize,
|
||||
Quality: ffmpegImageQuality,
|
||||
}
|
||||
|
||||
args := transcoder.ImageThumbnail(inputPath, options)
|
||||
|
||||
return e.FFMpeg.GenerateOutput(context.TODO(), args, nil)
|
||||
}
|
||||
|
||||
func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error {
|
||||
var thumbFilter ffmpeg.VideoFilter
|
||||
thumbFilter = thumbFilter.ScaleMaxSize(maxSize)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user