Compare commits

..

3 Commits

Author SHA1 Message Date
WithoutPants
acb3b4ee44 Fix typo 2025-11-25 08:34:22 +11:00
DogmaDragon
b7c6e370dc Add missing line breaks in UIPluginApi documentation 2025-11-22 23:34:12 +02:00
DogmaDragon
19d6f5543d Document "# requires" in the plugin config 2025-11-22 23:19:18 +02:00
357 changed files with 6825 additions and 14309 deletions

View File

@@ -10,8 +10,7 @@
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](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.**
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
![demo image](docs/readme_assets/demo_image.png)
* 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
[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](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!
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](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-tos 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

View File

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

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

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

View File

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

View File

@@ -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 {
@@ -420,7 +413,6 @@ type ConfigDisableDropdownCreate {
tag: Boolean!
studio: Boolean!
movie: Boolean!
gallery: Boolean!
}
type ConfigInterfaceResult {

View File

@@ -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,9 +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 url"
url: StringCriterionInput
"Filter by interactive"
@@ -448,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"
@@ -625,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"

View File

@@ -344,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!]
}

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ type Studio {
updated_at: Time!
groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead")
o_counter: Int
}
input StudioCreateInput {

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ var imageBoxExts = []string{
".gif",
".svg",
".webp",
".avif",
}
func newImageBox(box fs.FS) (*imageBox, error) {

View File

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

View File

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

View File

@@ -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 {
@@ -521,7 +510,6 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
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)

View File

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

View File

@@ -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,12 +333,10 @@ 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,
}

View File

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

View File

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

View File

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

View File

@@ -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.TrimSpace(input.AliasList))
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,17 +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 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
}
@@ -409,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 {
@@ -446,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
@@ -457,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
}
}
@@ -538,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
}

View File

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

View File

@@ -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,11 +428,10 @@ 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,
}
@@ -485,10 +482,9 @@ 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,
}
@@ -597,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,
}
@@ -655,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
@@ -741,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,
}
@@ -955,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,
}

View File

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

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
@@ -33,23 +32,23 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
// Populate a new studio from the input
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.TrimSpace(input.Aliases))
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)
@@ -134,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
}
@@ -211,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
}

View File

@@ -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.TrimSpace(input.Aliases))
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
newTag.Favorite = translator.bool(input.Favorite)
newTag.Description = translator.string(input.Description)
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -209,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"
@@ -219,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"
@@ -274,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
@@ -295,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"}
)
@@ -1298,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
}
@@ -1312,7 +1303,6 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate {
Studio: i.getBool(DisableDropdownCreateStudio),
Tag: i.getBool(DisableDropdownCreateTag),
Movie: i.getBool(DisableDropdownCreateMovie),
Gallery: i.getBool(DisableDropdownCreateGallery),
}
}
@@ -1323,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()
@@ -1499,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.
@@ -1635,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 {

View File

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

View File

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

View File

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

View File

@@ -294,7 +294,6 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
Handlers: []file.CleanHandler{
&cleanHandler{},
},
TrashPath: s.Config.GetDeleteTrashPath(),
}
j := cleanJob{
@@ -365,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
@@ -404,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 {
@@ -552,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)
})
@@ -565,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 {
@@ -686,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)
})

View File

@@ -411,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,
@@ -489,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++

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import (
"io/fs"
"os"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"time"
@@ -16,6 +15,7 @@ import (
"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 (
@@ -179,16 +179,7 @@ func (s *scanJob) execute(ctx context.Context) {
wg.Add(1)
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()))
}
}()
defer wg.Done()
if err := s.queueFiles(ctx, paths); err != nil {
if errors.Is(err, context.Canceled) {
return
@@ -214,15 +205,6 @@ func (s *scanJob) execute(ctx context.Context) {
}
func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
defer func() {
close(s.fileQueue)
if s.ProgressReports != nil {
s.ProgressReports.AddTotal(s.count)
s.ProgressReports.Definite()
}
}()
var err error
s.ProgressReports.ExecuteTask("Walking directory tree", func() {
for _, p := range paths {
@@ -233,6 +215,13 @@ func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
}
})
close(s.fileQueue)
if s.ProgressReports != nil {
s.ProgressReports.AddTotal(s.count)
s.ProgressReports.Definite()
}
return err
}
@@ -454,10 +443,7 @@ func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderI
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
}
@@ -487,10 +473,7 @@ func (s *scanJob) getZipFileID(ctx context.Context, zipFile *scanFile) (*models.
return &v, nil
}
// assume case sensitive when searching for the zip file
const caseSensitive = true
ret, err := s.Repository.File.FindByPath(ctx, path, caseSensitive)
ret, err := s.Repository.File.FindByPath(ctx, path)
if err != nil {
return nil, fmt.Errorf("getting zip file ID for %q: %w", path, err)
}
@@ -510,26 +493,11 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) 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)
@@ -643,18 +611,10 @@ func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *mo
// 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
@@ -687,31 +647,15 @@ 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.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
var err error
ff, err = s.Repository.File.FindByPath(ctx, f.Path, true)
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
ff, err = s.onNewFile(ctx, f)
@@ -730,7 +674,7 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
// 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)
zipCtx := utils.ValueOnlyContext{Context: ctx}
if err := s.scanZipFile(zipCtx, f); err != nil {
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
@@ -895,8 +839,7 @@ func (s *scanJob) 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 *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
@@ -936,7 +879,6 @@ func (s *scanJob) 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)
@@ -1084,8 +1026,7 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
path := base.Path
fileModTime := f.ModTime
// #6326 - also force a rescan if the basename changed
updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename
updated := !fileModTime.Equal(base.ModTime)
forceRescan := s.options.Rescan
if !updated && !forceRescan {
@@ -1100,8 +1041,6 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
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()

View File

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

View File

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

View File

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

View File

@@ -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,7 +32,7 @@ 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.

View File

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

View File

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

View File

@@ -24,38 +24,6 @@ func (e *vipsEncoder) ImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte,
return []byte(data), err
}
// ImageThumbnailPath generates a thumbnail from a file path instead of stdin.
// This is required for formats like AVIF that need random file access (seeking)
// which stdin cannot provide.
func (e *vipsEncoder) ImageThumbnailPath(path string, maxSize int) ([]byte, error) {
// vips thumbnail syntax: thumbnail input output width [options]
// Using .jpg[Q=70,strip] as output writes to stdout
args := []string{
"thumbnail",
path,
".jpg[Q=70,strip]",
fmt.Sprint(maxSize),
"--size", "down",
}
cmd := exec.Command(string(*e), args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, err
}
if err := cmd.Wait(); err != nil {
logger.Errorf("image encoder error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String())
return nil, err
}
return stdout.Bytes(), nil
}
func (e *vipsEncoder) run(args []string, stdin *bytes.Buffer) (string, error) {
cmd := exec.Command(string(*e), args...)

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
)
const maxGraveyardSize = 10
@@ -178,8 +179,7 @@ func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) {
j.StartTime = &t
j.Status = StatusRunning
// create a cancellable context for the job that is not canceled by the outer context
ctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx))
ctx, cancelFunc := context.WithCancel(utils.ValueOnlyContext{Context: ctx})
j.cancelFunc = cancelFunc
done = make(chan struct{})

View File

@@ -9,8 +9,6 @@ type CustomFieldsInput struct {
Full map[string]interface{} `json:"full"`
// If populated, only the keys in this map will be updated
Partial map[string]interface{} `json:"partial"`
// Remove any keys in this list
Remove []string `json:"remove"`
}
type CustomFieldsReader interface {

View File

@@ -1,63 +1,31 @@
package models
import (
"fmt"
"time"
"github.com/stashapp/stash/pkg/utils"
)
type DatePrecision int
const (
// default precision is day
DatePrecisionDay DatePrecision = iota
DatePrecisionMonth
DatePrecisionYear
)
// Date wraps a time.Time with a format of "YYYY-MM-DD"
type Date struct {
time.Time
Precision DatePrecision
}
var dateFormatPrecision = []string{
"2006-01-02",
"2006-01",
"2006",
}
const dateFormat = "2006-01-02"
func (d Date) String() string {
return d.Format(dateFormatPrecision[d.Precision])
return d.Format(dateFormat)
}
func (d Date) After(o Date) bool {
return d.Time.After(o.Time)
}
// ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime.
// If that fails, it attempts to parse the string with decreasing precision (month, then year).
// It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail.
// ParseDate uses utils.ParseDateStringAsTime to parse a string into a date.
func ParseDate(s string) (Date, error) {
var errs []error
// default parse to day precision
ret, err := utils.ParseDateStringAsTime(s)
if err == nil {
return Date{Time: ret, Precision: DatePrecisionDay}, nil
if err != nil {
return Date{}, err
}
errs = append(errs, err)
// try month and year precision
for i, format := range dateFormatPrecision[1:] {
ret, err := time.Parse(format, s)
if err == nil {
return Date{Time: ret, Precision: DatePrecision(i + 1)}, nil
}
errs = append(errs, err)
}
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
return Date{Time: ret}, nil
}

View File

@@ -1,50 +0,0 @@
package models
import (
"testing"
"time"
)
func TestParseDateStringAsTime(t *testing.T) {
tests := []struct {
name string
input string
output Date
expectError bool
}{
// Full date formats (existing support)
{"RFC3339", "2014-01-02T15:04:05Z", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
{"Date only", "2014-01-02", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false},
{"Date with time", "2014-01-02 15:04:05", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
// Partial date formats (new support)
{"Year-Month", "2006-08", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false},
{"Year only", "2014", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false},
// Invalid formats
{"Invalid format", "not-a-date", Date{}, true},
{"Empty string", "", Date{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseDate(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for input %q, but got none", tt.input)
}
return
}
if err != nil {
t.Errorf("Unexpected error for input %q: %v", tt.input, err)
return
}
if !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision {
t.Errorf("For input %q, expected output %+v, got %+v", tt.input, tt.output, result)
}
})
}
}

View File

@@ -130,13 +130,13 @@ func (_m *FileReaderWriter) Find(ctx context.Context, id ...models.FileID) ([]mo
return r0, r1
}
// FindAllByPath provides a mock function with given fields: ctx, path, caseSensitive
func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]models.File, error) {
ret := _m.Called(ctx, path, caseSensitive)
// FindAllByPath provides a mock function with given fields: ctx, path
func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string) ([]models.File, error) {
ret := _m.Called(ctx, path)
var r0 []models.File
if rf, ok := ret.Get(0).(func(context.Context, string, bool) []models.File); ok {
r0 = rf(ctx, path, caseSensitive)
if rf, ok := ret.Get(0).(func(context.Context, string) []models.File); ok {
r0 = rf(ctx, path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]models.File)
@@ -144,8 +144,8 @@ func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, case
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, path, caseSensitive)
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, path)
} else {
r1 = ret.Error(1)
}
@@ -222,13 +222,13 @@ func (_m *FileReaderWriter) FindByFingerprint(ctx context.Context, fp models.Fin
return r0, r1
}
// FindByPath provides a mock function with given fields: ctx, path, caseSensitive
func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (models.File, error) {
ret := _m.Called(ctx, path, caseSensitive)
// FindByPath provides a mock function with given fields: ctx, path
func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string) (models.File, error) {
ret := _m.Called(ctx, path)
var r0 models.File
if rf, ok := ret.Get(0).(func(context.Context, string, bool) models.File); ok {
r0 = rf(ctx, path, caseSensitive)
if rf, ok := ret.Get(0).(func(context.Context, string) models.File); ok {
r0 = rf(ctx, path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(models.File)
@@ -236,8 +236,8 @@ func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string, caseSen
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, path, caseSensitive)
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, path)
} else {
r1 = ret.Error(1)
}

View File

@@ -132,13 +132,13 @@ func (_m *FolderReaderWriter) FindByParentFolderID(ctx context.Context, parentFo
return r0, r1
}
// FindByPath provides a mock function with given fields: ctx, path, caseSensitive
func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (*models.Folder, error) {
ret := _m.Called(ctx, path, caseSensitive)
// FindByPath provides a mock function with given fields: ctx, path
func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string) (*models.Folder, error) {
ret := _m.Called(ctx, path)
var r0 *models.Folder
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Folder); ok {
r0 = rf(ctx, path, caseSensitive)
if rf, ok := ret.Get(0).(func(context.Context, string) *models.Folder); ok {
r0 = rf(ctx, path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Folder)
@@ -146,8 +146,8 @@ func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string, caseS
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, path, caseSensitive)
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, path)
} else {
r1 = ret.Error(1)
}

View File

@@ -594,27 +594,6 @@ func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerI
return r0, r1
}
// OCountByStudioID provides a mock function with given fields: ctx, studioID
func (_m *ImageReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) {
ret := _m.Called(ctx, studioID)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = rf(ctx, studioID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, studioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: ctx, options
func (_m *ImageReaderWriter) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) {
ret := _m.Called(ctx, options)

View File

@@ -473,20 +473,6 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int)
return r0, r1
}
// Merge provides a mock function with given fields: ctx, source, destination
func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
ret := _m.Called(ctx, source, destination)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {
r0 = rf(ctx, source, destination)
} else {
r0 = ret.Error(0)
}
return r0
}
// Query provides a mock function with given fields: ctx, performerFilter, findFilter
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
ret := _m.Called(ctx, performerFilter, findFilter)

View File

@@ -1183,27 +1183,6 @@ func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerI
return r0, r1
}
// OCountByStudioID provides a mock function with given fields: ctx, studioID
func (_m *SceneReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) {
ret := _m.Called(ctx, studioID)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = rf(ctx, studioID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, studioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PlayDuration provides a mock function with given fields: ctx
func (_m *SceneReaderWriter) PlayDuration(ctx context.Context) (float64, error) {
ret := _m.Called(ctx)

View File

@@ -30,9 +30,9 @@ func (ScrapedStudio) IsScrapedContent() {}
func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Studio {
// Populate a new studio from the input
ret := NewStudio()
ret.Name = strings.TrimSpace(s.Name)
ret.Name = s.Name
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
if s.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
@@ -62,7 +62,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu
ret.Details = *s.Details
}
if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] {
if s.Aliases != nil && !excluded["aliases"] {
ret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, ","))
}
@@ -95,38 +95,37 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
currentTime := time.Now()
if s.Name != "" && !excluded["name"] {
ret.Name = NewOptionalString(strings.TrimSpace(s.Name))
ret.Name = NewOptionalString(s.Name)
}
if len(s.URLs) > 0 {
if !excluded["urls"] {
ret.URLs = &UpdateStrings{
Values: stringslice.TrimSpace(s.URLs),
Values: s.URLs,
Mode: RelationshipUpdateModeSet,
}
}
} else {
urls := []string{}
if s.URL != nil && !excluded["url"] {
urls = append(urls, strings.TrimSpace(*s.URL))
urls = append(urls, *s.URL)
}
if len(urls) > 0 {
ret.URLs = &UpdateStrings{
Values: stringslice.TrimSpace(urls),
Values: urls,
Mode: RelationshipUpdateModeSet,
}
}
}
if s.Details != nil && !excluded["details"] {
ret.Details = NewOptionalString(strings.TrimSpace(*s.Details))
ret.Details = NewOptionalString(*s.Details)
}
if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] {
if s.Aliases != nil && !excluded["aliases"] {
ret.Aliases = &UpdateStrings{
Values: stringslice.TrimSpace(stringslice.FromString(*s.Aliases, ",")),
Values: stringslice.FromString(*s.Aliases, ","),
Mode: RelationshipUpdateModeSet,
}
}
@@ -141,7 +140,7 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
}
}
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
if s.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
@@ -198,14 +197,10 @@ func (ScrapedPerformer) IsScrapedContent() {}
func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer {
ret := NewPerformer()
currentTime := time.Now()
ret.Name = strings.TrimSpace(*p.Name)
ret.Name = *p.Name
if p.Aliases != nil && !excluded["aliases"] {
aliases := stringslice.FromString(*p.Aliases, ",")
for i, alias := range aliases {
aliases[i] = strings.TrimSpace(alias)
}
ret.Aliases = NewRelatedStrings(aliases)
ret.Aliases = NewRelatedStrings(stringslice.FromString(*p.Aliases, ","))
}
if p.Birthdate != nil && !excluded["birthdate"] {
date, err := ParseDate(*p.Birthdate)
@@ -306,7 +301,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
}
}
if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" {
if p.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
@@ -435,7 +430,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
}
}
if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" {
if p.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
@@ -464,7 +459,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
ret := NewTag()
ret.Name = t.Name
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
if t.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,

View File

@@ -166,8 +166,6 @@ type PerformerFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by url

View File

@@ -13,9 +13,9 @@ type FileGetter interface {
// FileFinder provides methods to find files.
type FileFinder interface {
FileGetter
FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)
FindAllByPath(ctx context.Context, path string) ([]File, error)
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error)
FindByPath(ctx context.Context, path string, caseSensitive bool) (File, error)
FindByPath(ctx context.Context, path string) (File, error)
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error)
FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]File, error)

View File

@@ -12,7 +12,7 @@ type FolderGetter interface {
type FolderFinder interface {
FolderGetter
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*Folder, error)
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
FindByPath(ctx context.Context, path string) (*Folder, error)
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
}

View File

@@ -38,7 +38,6 @@ type ImageCounter interface {
CountByGalleryID(ctx context.Context, galleryID int) (int, error)
OCount(ctx context.Context) (int, error)
OCountByPerformerID(ctx context.Context, performerID int) (int, error)
OCountByStudioID(ctx context.Context, studioID int) (int, error)
}
// ImageCreator provides methods to create images.

View File

@@ -92,8 +92,6 @@ type PerformerWriter interface {
PerformerCreator
PerformerUpdater
PerformerDestroyer
Merge(ctx context.Context, source []int, destination int) error
}
// PerformerReaderWriter provides all performer methods.

View File

@@ -45,7 +45,6 @@ type SceneCounter interface {
CountMissingOSHash(ctx context.Context) (int, error)
OCountByPerformerID(ctx context.Context, performerID int) (int, error)
OCountByGroupID(ctx context.Context, groupID int) (int, error)
OCountByStudioID(ctx context.Context, studioID int) (int, error)
}
// SceneCreator provides methods to create scenes.

View File

@@ -79,8 +79,6 @@ type SceneFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by url
URL *StringCriterionInput `json:"url"`
// Filter by interactive

View File

@@ -79,23 +79,10 @@ func (s StashIDInputs) ToStashIDs() StashIDs {
return nil
}
// #2800 - deduplicate StashIDs based on endpoint and stash_id
ret := make(StashIDs, 0, len(s))
seen := make(map[string]map[string]bool)
for _, v := range s {
stashID := v.ToStashID()
if seen[stashID.Endpoint] == nil {
seen[stashID.Endpoint] = make(map[string]bool)
}
if !seen[stashID.Endpoint][stashID.StashID] {
seen[stashID.Endpoint][stashID.StashID] = true
ret = append(ret, stashID)
}
ret := make(StashIDs, len(s))
for i, v := range s {
ret[i] = v.ToStashID()
}
return ret
}
@@ -129,16 +116,8 @@ func (u *UpdateStashIDs) Set(v StashID) {
type StashIDCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_id with the matching endpoint
// That is, it will filter based on stash_ids with the matching endpoint
Endpoint *string `json:"endpoint"`
StashID *string `json:"stash_id"`
Modifier CriterionModifier `json:"modifier"`
}
type StashIDsCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_ids with the matching endpoint
Endpoint *string `json:"endpoint"`
StashIDs []*string `json:"stash_ids"`
Modifier CriterionModifier `json:"modifier"`
}

View File

@@ -10,8 +10,6 @@ type StudioFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter to only include studios missing this property
IsMissing *string `json:"is_missing"`
// Filter by rating expressed as 1-100

View File

@@ -40,10 +40,6 @@ type TagFilterType struct {
ChildCount *IntCriterionInput `json:"child_count"`
// Filter by autotag ignore value
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by related scenes that meet this criteria
ScenesFilter *SceneFilterType `json:"scenes_filter"`
// Filter by related images that meet this criteria

View File

@@ -21,7 +21,6 @@ type FileDeleter struct {
}
// MarkGeneratedFiles marks for deletion the generated files for the provided scene.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
sceneHash := scene.GetHash(d.FileNamingAlgo)
@@ -33,7 +32,7 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
exists, _ := fsutil.FileExists(markersFolder)
if exists {
if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {
if err := d.Dirs([]string{markersFolder}); err != nil {
return err
}
}
@@ -76,12 +75,11 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
files = append(files, heatmapPath)
}
return d.FilesWithoutTrash(files)
return d.Files(files)
}
// MarkMarkerFiles deletes generated files for a scene marker with the
// provided scene and timestamp.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
@@ -104,7 +102,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
files = append(files, screenshotPath)
}
return d.FilesWithoutTrash(files)
return d.Files(files)
}
// Destroy deletes a scene and its associated relationships from the

View File

@@ -164,7 +164,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)
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
)
@@ -261,23 +262,19 @@ func (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeConten
return nil, fmt.Errorf("error while name scraping with scraper %s: %w", id, err)
}
pp := postScraper{
Cache: c,
excludeTagRE: c.compileExcludeTagPatterns(),
}
if err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error {
for i, cc := range content {
content[i], err = pp.postScrape(ctx, cc)
if err != nil {
return fmt.Errorf("error while post-scraping with scraper %s: %w", id, err)
}
ignoredRegex := c.compileExcludeTagPatterns()
var ignoredTags []string
for i, cc := range content {
var thisIgnoredTags []string
content[i], thisIgnoredTags, err = c.postScrape(ctx, cc, ignoredRegex)
if err != nil {
return nil, fmt.Errorf("error while post-scraping with scraper %s: %w", id, err)
}
return nil
}); err != nil {
return nil, err
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
}
LogIgnoredTags(pp.ignoredTags)
LogIgnoredTags(ignoredTags)
return content, nil
}

View File

@@ -37,49 +37,88 @@ func setPerformerImage(ctx context.Context, client *http.Client, p *models.Scrap
return nil
}
func setStudioImage(ctx context.Context, client *http.Client, p *models.ScrapedStudio, globalConfig GlobalConfig) error {
// backwards compatibility: we fetch the image if it's a URL and set it to the first image
// Image is deprecated, so only do this if Images is unset
if p.Image == nil || len(p.Images) > 0 {
func setSceneImage(ctx context.Context, client *http.Client, s *models.ScrapedScene, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if s.Image == nil || !strings.HasPrefix(*s.Image, "http") {
// nothing to do
return nil
}
// don't try to get the image if it doesn't appear to be a URL
if !strings.HasPrefix(*p.Image, "http") {
p.Images = []string{*p.Image}
return nil
}
img, err := getImage(ctx, *p.Image, client, globalConfig)
img, err := getImage(ctx, *s.Image, client, globalConfig)
if err != nil {
return err
}
p.Image = img
// Image is deprecated. Use images instead
p.Images = []string{*img}
s.Image = img
return nil
}
func processImageField(ctx context.Context, imageField *string, client *http.Client, globalConfig GlobalConfig) error {
if imageField == nil {
return nil
}
func setMovieFrontImage(ctx context.Context, client *http.Client, m *models.ScrapedMovie, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
// this allows scrapers to return base64 data URIs directly
if !strings.HasPrefix(*imageField, "http") {
if m.FrontImage == nil || !strings.HasPrefix(*m.FrontImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *imageField, client, globalConfig)
img, err := getImage(ctx, *m.FrontImage, client, globalConfig)
if err != nil {
return err
}
*imageField = *img
m.FrontImage = img
return nil
}
func setMovieBackImage(ctx context.Context, client *http.Client, m *models.ScrapedMovie, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.BackImage == nil || !strings.HasPrefix(*m.BackImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *m.BackImage, client, globalConfig)
if err != nil {
return err
}
m.BackImage = img
return nil
}
func setGroupFrontImage(ctx context.Context, client *http.Client, m *models.ScrapedGroup, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.FrontImage == nil || !strings.HasPrefix(*m.FrontImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *m.FrontImage, client, globalConfig)
if err != nil {
return err
}
m.FrontImage = img
return nil
}
func setGroupBackImage(ctx context.Context, client *http.Client, m *models.ScrapedGroup, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.BackImage == nil || !strings.HasPrefix(*m.BackImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *m.BackImage, client, globalConfig)
if err != nil {
return err
}
m.BackImage = img
return nil
}

Some files were not shown because too many files have changed in this diff Show More