mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
75 Commits
docs-plugi
...
v0.30.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
857e673d3e | ||
|
|
b2df819283 | ||
|
|
f71d0ac2dd | ||
|
|
b23c3cd618 | ||
|
|
1691280d1b | ||
|
|
7a8a2c7687 | ||
|
|
f64cd5bfac | ||
|
|
65327a6102 | ||
|
|
62babfb332 | ||
|
|
67b1dd8dd0 | ||
|
|
25fdf676d2 | ||
|
|
1580cf9bd9 | ||
|
|
badebfd8f9 | ||
|
|
f1e54bfc73 | ||
|
|
ebfe5c4b5c | ||
|
|
11417590ee | ||
|
|
0980daa99e | ||
|
|
5f0d4e811d | ||
|
|
a4816b4cc9 | ||
|
|
ba0102f2a6 | ||
|
|
fe41561dfe | ||
|
|
7fded66bfa | ||
|
|
945d679158 | ||
|
|
7db394bbea | ||
|
|
eb9d0705bc | ||
|
|
0fd7a2ac20 | ||
|
|
e2dff05081 | ||
|
|
061d21dede | ||
|
|
88a149c085 | ||
|
|
d994df2900 | ||
|
|
39fd8a6550 | ||
|
|
877491e62b | ||
|
|
3d044896ad | ||
|
|
63e8830db4 | ||
|
|
0bc4faef2a | ||
|
|
ee61fc879b | ||
|
|
e02ef436a5 | ||
|
|
41f0612025 | ||
|
|
730e877e73 | ||
|
|
e213fde0cc | ||
|
|
69fd073d5d | ||
|
|
5f16547e58 | ||
|
|
90dd0b58d8 | ||
|
|
4017c42fe2 | ||
|
|
49fd47562e | ||
|
|
84e24eb612 | ||
|
|
c6ae43c1d6 | ||
|
|
de8139cf1b | ||
|
|
0ca416f75a | ||
|
|
1bc32a3099 | ||
|
|
d1ee64d36f | ||
|
|
e052a431d1 | ||
|
|
7e66ce8a49 | ||
|
|
88747b962a | ||
|
|
97c01c70b3 | ||
|
|
a3ed381901 | ||
|
|
b3da730a05 | ||
|
|
e0c1d4c51d | ||
|
|
90d1b2df2d | ||
|
|
4ef3a605dd | ||
|
|
f811590021 | ||
|
|
0bd78f4b62 | ||
|
|
a8bb9ae4d3 | ||
|
|
d10995302d | ||
|
|
d14053b570 | ||
|
|
ca357b9eb3 | ||
|
|
6892c7151c | ||
|
|
d6a2953371 | ||
|
|
50ad3c0778 | ||
|
|
dc520e2b2f | ||
|
|
ecd9c6ec5b | ||
|
|
ca8ee6bc2a | ||
|
|
5d02f916c2 | ||
|
|
e176cf5f71 | ||
|
|
2cac7d5b20 |
75
README.md
75
README.md
@@ -10,7 +10,8 @@
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
|
||||
### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.**
|
||||

|
||||
|
||||

|
||||
|
||||
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
|
||||
* Stash supports a wide variety of both video and image formats.
|
||||
@@ -19,80 +20,88 @@
|
||||
|
||||
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 [read the in-app manual](ui/v2.5/src/docs/en).
|
||||
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)).
|
||||
|
||||
# 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 doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._
|
||||
Windows 10 or Server 2016 are at least required.
|
||||
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.
|
||||
|
||||
#### Mac Users:
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
<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 page](https://github.com/stashapp/stash/releases).
|
||||
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
|
||||
|
||||
## First Run
|
||||
|
||||
#### Windows/macOS Users: Security Prompt
|
||||
|
||||
On Windows or macOS, running the app might present a security prompt since the binary isn't yet signed.
|
||||
On Windows or macOS, running the app might present a security prompt since the application 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 download a copy for you. 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 prompt you to download a copy during setup. 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 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/).
|
||||
- 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/).
|
||||
- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/).
|
||||
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to Settings -> Metadata Providers -> Available Scrapers -> Community (stable). These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
|
||||
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings → Metadata Providers → Available Scrapers → Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
|
||||
- All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/).
|
||||
|
||||
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
|
||||
|
||||
# Translation
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash 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!
|
||||
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:
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
## Join Our Community
|
||||
# Support & Resources
|
||||
|
||||
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.
|
||||
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
|
||||
|
||||
# Support (FAQ)
|
||||
- Documentation
|
||||
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.
|
||||
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual.
|
||||
- FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers.
|
||||
- Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips.
|
||||
|
||||
- Community & discussion
|
||||
- Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions.
|
||||
- Discord: https://discord.gg/2TsNFKt - real-time chat and community support.
|
||||
- GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions.
|
||||
- Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space.
|
||||
|
||||
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).
|
||||
- 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/
|
||||
|
||||
# For Developers
|
||||
|
||||
|
||||
16
go.mod
16
go.mod
@@ -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.38.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/image v0.18.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/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/time v0.10.0
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -121,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.24.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // 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
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
32
go.sum
32
go.sum
@@ -664,8 +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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
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=
|
||||
@@ -707,8 +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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
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/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=
|
||||
@@ -758,8 +758,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
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.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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
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=
|
||||
@@ -789,8 +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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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/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=
|
||||
@@ -872,13 +872,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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/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.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/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/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=
|
||||
@@ -890,8 +890,8 @@ 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.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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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=
|
||||
@@ -956,8 +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.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -165,6 +165,12 @@ type Query {
|
||||
input: ScrapeSingleStudioInput!
|
||||
): [ScrapedStudio!]!
|
||||
|
||||
"Scrape for a single tag"
|
||||
scrapeSingleTag(
|
||||
source: ScraperSourceInput!
|
||||
input: ScrapeSingleTagInput!
|
||||
): [ScrapedTag!]!
|
||||
|
||||
"Scrape for a single performer"
|
||||
scrapeSinglePerformer(
|
||||
source: ScraperSourceInput!
|
||||
|
||||
@@ -69,6 +69,8 @@ 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"
|
||||
@@ -191,6 +193,8 @@ 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"
|
||||
@@ -315,6 +319,7 @@ input ConfigDisableDropdownCreateInput {
|
||||
tag: Boolean
|
||||
studio: Boolean
|
||||
movie: Boolean
|
||||
gallery: Boolean
|
||||
}
|
||||
|
||||
enum ImageLightboxDisplayMode {
|
||||
@@ -335,6 +340,7 @@ input ConfigImageLightboxInput {
|
||||
resetZoomOnNav: Boolean
|
||||
scrollMode: ImageLightboxScrollMode
|
||||
scrollAttemptsBeforeChange: Int
|
||||
disableAnimation: Boolean
|
||||
}
|
||||
|
||||
type ConfigImageLightboxResult {
|
||||
@@ -344,6 +350,7 @@ type ConfigImageLightboxResult {
|
||||
resetZoomOnNav: Boolean
|
||||
scrollMode: ImageLightboxScrollMode
|
||||
scrollAttemptsBeforeChange: Int!
|
||||
disableAnimation: Boolean
|
||||
}
|
||||
|
||||
input ConfigInterfaceInput {
|
||||
@@ -413,6 +420,7 @@ type ConfigDisableDropdownCreate {
|
||||
tag: Boolean!
|
||||
studio: Boolean!
|
||||
movie: Boolean!
|
||||
gallery: Boolean!
|
||||
}
|
||||
|
||||
type ConfigInterfaceResult {
|
||||
|
||||
@@ -606,6 +606,9 @@ input TagFilterType {
|
||||
"Filter by autotag ignore value"
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
|
||||
"Filter by related scenes that meet this criteria"
|
||||
scenes_filter: SceneFilterType
|
||||
"Filter by related images that meet this criteria"
|
||||
|
||||
@@ -344,4 +344,6 @@ 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!]
|
||||
}
|
||||
|
||||
@@ -198,6 +198,13 @@ 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
|
||||
@@ -281,7 +288,10 @@ type StashBoxFingerprint {
|
||||
duration: Int!
|
||||
}
|
||||
|
||||
"If neither ids nor names are set, tag all items"
|
||||
"""
|
||||
Accepts either ids, or a combination of names and stash_ids.
|
||||
If none are set, then all existing items will be tagged.
|
||||
"""
|
||||
input StashBoxBatchTagInput {
|
||||
"Stash endpoint to use for the tagging"
|
||||
endpoint: Int @deprecated(reason: "use stash_box_endpoint")
|
||||
@@ -293,12 +303,17 @@ input StashBoxBatchTagInput {
|
||||
refresh: Boolean!
|
||||
"If batch adding studios, should their parent studios also be created?"
|
||||
createParent: Boolean!
|
||||
"If set, only tag these ids"
|
||||
"""
|
||||
IDs in stash of the items to update.
|
||||
If set, names and stash_ids fields will be ignored.
|
||||
"""
|
||||
ids: [ID!]
|
||||
"If set, only tag these names"
|
||||
"Names of the items in the stash-box instance to search for and create"
|
||||
names: [String!]
|
||||
"If set, only tag these performer ids"
|
||||
"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"
|
||||
performer_ids: [ID!] @deprecated(reason: "use ids")
|
||||
"If set, only tag these performer names"
|
||||
"Names of the performers in the stash-box instance to search for and create"
|
||||
performer_names: [String!] @deprecated(reason: "use names")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Studio {
|
||||
updated_at: Time!
|
||||
groups: [Group!]!
|
||||
movies: [Movie!]! @deprecated(reason: "use groups instead")
|
||||
o_counter: Int
|
||||
}
|
||||
|
||||
input StudioCreateInput {
|
||||
|
||||
@@ -170,6 +170,21 @@ query FindStudio($id: ID, $name: String) {
|
||||
}
|
||||
}
|
||||
|
||||
query FindTag($id: ID, $name: String) {
|
||||
findTag(id: $id, name: $name) {
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
|
||||
query QueryTags($input: TagQueryInput!) {
|
||||
queryTags(input: $input) {
|
||||
count
|
||||
tags {
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation SubmitFingerprint($input: FingerprintSubmission!) {
|
||||
submitFingerprint(input: $input)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (t changesetTranslator) string(value *string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *value
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
|
||||
@@ -106,7 +106,12 @@ func (t changesetTranslator) optionalString(value *string, field string) models.
|
||||
return models.OptionalString{}
|
||||
}
|
||||
|
||||
return models.NewOptionalStringPtr(value)
|
||||
if value == nil {
|
||||
return models.NewOptionalStringPtr(nil)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
return models.NewOptionalString(trimmed)
|
||||
}
|
||||
|
||||
func (t changesetTranslator) optionalDate(value *string, field string) (models.OptionalDate, error) {
|
||||
@@ -318,8 +323,14 @@ 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: value,
|
||||
Values: trimmedValues,
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
@@ -329,8 +340,14 @@ 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: value.Values,
|
||||
Values: trimmedValues,
|
||||
Mode: value.Mode,
|
||||
}
|
||||
}
|
||||
@@ -448,7 +465,7 @@ func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.
|
||||
GroupID: gID,
|
||||
}
|
||||
if v.Description != nil {
|
||||
ret[i].Description = *v.Description
|
||||
ret[i].Description = strings.TrimSpace(*v.Description)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
internal/api/custom_fields.go
Normal file
12
internal/api/custom_fields.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
import "github.com/stashapp/stash/pkg/models"
|
||||
|
||||
func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput {
|
||||
ret := input
|
||||
// convert json.Numbers to int/float
|
||||
ret.Full = convertMapJSONNumbers(ret.Full)
|
||||
ret.Partial = convertMapJSONNumbers(ret.Partial)
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -26,6 +26,7 @@ var imageBoxExts = []string{
|
||||
".gif",
|
||||
".svg",
|
||||
".webp",
|
||||
".avif",
|
||||
}
|
||||
|
||||
func newImageBox(box fs.FS) (*imageBox, error) {
|
||||
|
||||
35
internal/api/input.go
Normal file
35
internal/api/input.go
Normal file
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
@@ -143,6 +143,24 @@ 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
|
||||
|
||||
@@ -150,6 +150,15 @@ 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 {
|
||||
@@ -484,6 +493,8 @@ 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 {
|
||||
@@ -510,6 +521,7 @@ 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)
|
||||
|
||||
@@ -149,7 +149,9 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
|
||||
return false, fmt.Errorf("converting ids: %w", err)
|
||||
}
|
||||
|
||||
fileDeleter := file.NewDeleter()
|
||||
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||
|
||||
fileDeleter := file.NewDeleterWithTrash(trashPath)
|
||||
destroyer := &file.ZipDestroyer{
|
||||
FileDestroyer: r.repository.File,
|
||||
FolderDestroyer: r.repository.Folder,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
@@ -43,7 +44,7 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
|
||||
// Populate a new gallery from the input
|
||||
newGallery := models.NewGallery()
|
||||
|
||||
newGallery.Title = input.Title
|
||||
newGallery.Title = strings.TrimSpace(input.Title)
|
||||
newGallery.Code = translator.string(input.Code)
|
||||
newGallery.Details = translator.string(input.Details)
|
||||
newGallery.Photographer = translator.string(input.Photographer)
|
||||
@@ -74,9 +75,9 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newGallery.URLs = models.NewRelatedStrings(input.Urls)
|
||||
newGallery.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
} else if input.URL != nil {
|
||||
newGallery.URLs = models.NewRelatedStrings([]string{*input.URL})
|
||||
newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
|
||||
}
|
||||
|
||||
// Start the transaction and save the gallery
|
||||
@@ -333,10 +334,12 @@ 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.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/group"
|
||||
@@ -21,7 +22,7 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
|
||||
// Populate a new group from the input
|
||||
newGroup := models.NewGroup()
|
||||
|
||||
newGroup.Name = input.Name
|
||||
newGroup.Name = strings.TrimSpace(input.Name)
|
||||
newGroup.Aliases = translator.string(input.Aliases)
|
||||
newGroup.Duration = input.Duration
|
||||
newGroup.Rating = input.Rating100
|
||||
@@ -55,7 +56,7 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newGroup.URLs = models.NewRelatedStrings(input.Urls)
|
||||
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
}
|
||||
|
||||
return &newGroup, nil
|
||||
|
||||
@@ -308,9 +308,11 @@ 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.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
@@ -348,9 +350,11 @@ 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.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -32,7 +33,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
|
||||
// Populate a new group from the input
|
||||
newGroup := models.NewGroup()
|
||||
|
||||
newGroup.Name = input.Name
|
||||
newGroup.Name = strings.TrimSpace(input.Name)
|
||||
newGroup.Aliases = translator.string(input.Aliases)
|
||||
newGroup.Duration = input.Duration
|
||||
newGroup.Rating = input.Rating100
|
||||
@@ -56,9 +57,9 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newGroup.URLs = models.NewRelatedStrings(input.Urls)
|
||||
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
} else if input.URL != nil {
|
||||
newGroup.URLs = models.NewRelatedStrings([]string{*input.URL})
|
||||
newGroup.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
|
||||
}
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
@@ -37,9 +38,9 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
// Populate a new performer from the input
|
||||
newPerformer := models.NewPerformer()
|
||||
|
||||
newPerformer.Name = input.Name
|
||||
newPerformer.Name = strings.TrimSpace(input.Name)
|
||||
newPerformer.Disambiguation = translator.string(input.Disambiguation)
|
||||
newPerformer.Aliases = models.NewRelatedStrings(input.AliasList)
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList))
|
||||
newPerformer.Gender = input.Gender
|
||||
newPerformer.Ethnicity = translator.string(input.Ethnicity)
|
||||
newPerformer.Country = translator.string(input.Country)
|
||||
@@ -62,17 +63,17 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
|
||||
newPerformer.URLs = models.NewRelatedStrings([]string{})
|
||||
if input.URL != nil {
|
||||
newPerformer.URLs.Add(*input.URL)
|
||||
newPerformer.URLs.Add(strings.TrimSpace(*input.URL))
|
||||
}
|
||||
if input.Twitter != nil {
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(*input.Twitter, twitterURL))
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Twitter), twitterURL))
|
||||
}
|
||||
if input.Instagram != nil {
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(*input.Instagram, instagramURL))
|
||||
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Instagram), instagramURL))
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newPerformer.URLs.Add(input.Urls...)
|
||||
newPerformer.URLs.Add(stringslice.TrimSpace(input.Urls)...)
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -296,10 +297,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
updatedPerformer.CustomFields = input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
|
||||
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
|
||||
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
@@ -416,6 +414,10 @@ 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
|
||||
|
||||
@@ -32,7 +32,7 @@ func (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput
|
||||
|
||||
f := models.SavedFilter{
|
||||
Mode: input.Mode,
|
||||
Name: input.Name,
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
FindFilter: input.FindFilter,
|
||||
ObjectFilter: input.ObjectFilter,
|
||||
UIOptions: input.UIOptions,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
@@ -62,9 +63,9 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newScene.URLs = models.NewRelatedStrings(input.Urls)
|
||||
newScene.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
} else if input.URL != nil {
|
||||
newScene.URLs = models.NewRelatedStrings([]string{*input.URL})
|
||||
newScene.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
|
||||
}
|
||||
|
||||
newScene.PerformerIDs, err = translator.relatedIds(input.PerformerIds)
|
||||
@@ -428,10 +429,11 @@ 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.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
FileNamingAlgo: fileNamingAlgo,
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
@@ -482,9 +484,10 @@ 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.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
FileNamingAlgo: fileNamingAlgo,
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
@@ -593,8 +596,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
}
|
||||
|
||||
mgr := manager.GetInstance()
|
||||
trashPath := mgr.Config.GetDeleteTrashPath()
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
||||
Paths: mgr.Paths,
|
||||
}
|
||||
@@ -650,7 +654,7 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
|
||||
// Populate a new scene marker from the input
|
||||
newMarker := models.NewSceneMarker()
|
||||
|
||||
newMarker.Title = input.Title
|
||||
newMarker.Title = strings.TrimSpace(input.Title)
|
||||
newMarker.Seconds = input.Seconds
|
||||
newMarker.PrimaryTagID = primaryTagID
|
||||
newMarker.SceneID = sceneID
|
||||
@@ -736,9 +740,10 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
|
||||
}
|
||||
|
||||
mgr := manager.GetInstance()
|
||||
trashPath := mgr.Config.GetDeleteTrashPath()
|
||||
|
||||
fileDeleter := &scene.FileDeleter{
|
||||
Deleter: file.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
||||
Paths: mgr.Paths,
|
||||
}
|
||||
@@ -949,9 +954,10 @@ 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.NewDeleter(),
|
||||
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||
FileNamingAlgo: fileNamingAlgo,
|
||||
Paths: manager.GetInstance().Paths,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
|
||||
}
|
||||
|
||||
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint)
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
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)
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
@@ -32,23 +33,23 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
// Populate a new studio from the input
|
||||
newStudio := models.NewStudio()
|
||||
|
||||
newStudio.Name = input.Name
|
||||
newStudio.Name = strings.TrimSpace(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(input.Aliases)
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(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(*input.URL)
|
||||
newStudio.URLs.Add(strings.TrimSpace(*input.URL))
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newStudio.URLs.Add(input.Urls...)
|
||||
newStudio.URLs.Add(stringslice.TrimSpace(input.Urls)...)
|
||||
}
|
||||
|
||||
newStudio.ParentID, err = translator.intPtrFromString(input.ParentID)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -32,9 +33,9 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
// Populate a new tag from the input
|
||||
newTag := models.NewTag()
|
||||
|
||||
newTag.Name = input.Name
|
||||
newTag.Name = strings.TrimSpace(input.Name)
|
||||
newTag.SortName = translator.string(input.SortName)
|
||||
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newTag.Favorite = translator.bool(input.Favorite)
|
||||
newTag.Description = translator.string(input.Description)
|
||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
|
||||
@@ -82,6 +82,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
||||
Stashes: config.GetStashPaths(),
|
||||
DatabasePath: config.GetDatabasePath(),
|
||||
BackupDirectoryPath: config.GetBackupDirectoryPath(),
|
||||
DeleteTrashPath: config.GetDeleteTrashPath(),
|
||||
GeneratedPath: config.GetGeneratedPath(),
|
||||
MetadataPath: config.GetMetadataPath(),
|
||||
ConfigFilePath: config.GetConfigFile(),
|
||||
|
||||
@@ -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)
|
||||
ret, err = qb.FindByPath(ctx, *path, true)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("file not found")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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) {
|
||||
@@ -25,7 +24,7 @@ func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string
|
||||
return err
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
ret, err = qb.FindByPath(ctx, *path, true)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("folder not found")
|
||||
}
|
||||
@@ -49,7 +48,7 @@ func (r *queryResolver) FindFolders(
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
folderIDsInt, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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) {
|
||||
@@ -25,7 +24,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 := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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) {
|
||||
@@ -25,7 +24,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 := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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) {
|
||||
@@ -55,7 +54,7 @@ func (r *queryResolver) FindImages(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindImagesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
imageIds, err = stringslice.StringSliceToIntSlice(ids)
|
||||
imageIds, err = handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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) {
|
||||
@@ -25,7 +24,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 := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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) {
|
||||
@@ -26,7 +25,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 = stringslice.StringSliceToIntSlice(ids)
|
||||
performerIDs, err = handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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) {
|
||||
@@ -83,7 +82,7 @@ func (r *queryResolver) FindScenes(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindScenesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
sceneIDs, err = handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ 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 := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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) {
|
||||
@@ -26,7 +25,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 := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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) {
|
||||
@@ -25,7 +24,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 := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -350,7 +350,46 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_index must be set")
|
||||
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")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {
|
||||
|
||||
@@ -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)
|
||||
f, err := folderStore.FindByPath(ctx, folderPath, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting folder by path: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/kermieisinthehouse/systray"
|
||||
@@ -20,7 +21,12 @@ 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() {
|
||||
SendNotification("Stash has moved!", "Stash now runs in your tray, instead of a terminal window.")
|
||||
// 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.")
|
||||
c.SetBool(config.ShowOneTimeMovedNotification, false)
|
||||
if err := c.Write(); err != nil {
|
||||
logger.Errorf("Error while writing configuration file: %v", err)
|
||||
|
||||
@@ -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:*"
|
||||
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:*"
|
||||
|
||||
type connectionManagerService struct {
|
||||
*Server
|
||||
|
||||
@@ -209,6 +209,7 @@ 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"
|
||||
|
||||
@@ -218,6 +219,7 @@ 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"
|
||||
@@ -272,6 +274,9 @@ const (
|
||||
DeleteGeneratedDefault = "defaults.delete_generated"
|
||||
deleteGeneratedDefaultDefault = true
|
||||
|
||||
// Trash/Recycle Bin options
|
||||
DeleteTrashPath = "delete_trash_path"
|
||||
|
||||
// Desktop Integration Options
|
||||
NoBrowser = "nobrowser"
|
||||
NoBrowserDefault = false
|
||||
@@ -290,7 +295,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"}
|
||||
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif"}
|
||||
defaultGalleryExtensions = []string{"zip", "cbz"}
|
||||
defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"}
|
||||
)
|
||||
@@ -1293,6 +1298,10 @@ 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
|
||||
}
|
||||
@@ -1303,6 +1312,7 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate {
|
||||
Studio: i.getBool(DisableDropdownCreateStudio),
|
||||
Tag: i.getBool(DisableDropdownCreateTag),
|
||||
Movie: i.getBool(DisableDropdownCreateMovie),
|
||||
Gallery: i.getBool(DisableDropdownCreateGallery),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1469,6 +1479,14 @@ 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.
|
||||
|
||||
@@ -13,6 +13,7 @@ type ConfigImageLightboxResult struct {
|
||||
ResetZoomOnNav *bool `json:"resetZoomOnNav"`
|
||||
ScrollMode *ImageLightboxScrollMode `json:"scrollMode"`
|
||||
ScrollAttemptsBeforeChange int `json:"scrollAttemptsBeforeChange"`
|
||||
DisableAnimation *bool `json:"disableAnimation"`
|
||||
}
|
||||
|
||||
type ImageLightboxDisplayMode string
|
||||
@@ -104,4 +105,5 @@ type ConfigDisableDropdownCreate struct {
|
||||
Tag bool `json:"tag"`
|
||||
Studio bool `json:"studio"`
|
||||
Movie bool `json:"movie"`
|
||||
Gallery bool `json:"gallery"`
|
||||
}
|
||||
|
||||
@@ -313,6 +313,7 @@ func (s *Manager) RefreshFFMpeg(ctx context.Context) {
|
||||
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
|
||||
s.FFProbe = ffmpeg.NewFFProbe(ffprobePath)
|
||||
|
||||
s.FFMpeg.InitHWSupport(ctx)
|
||||
// initialise hardware support with background context
|
||||
s.FFMpeg.InitHWSupport(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +219,11 @@ 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.
|
||||
configFile, _ := filepath.Abs(input.ConfigLocation)
|
||||
|
||||
// #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
|
||||
configDir := filepath.Dir(configFile)
|
||||
|
||||
if exists, _ := fsutil.DirExists(configDir); !exists {
|
||||
|
||||
@@ -294,6 +294,7 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
|
||||
Handlers: []file.CleanHandler{
|
||||
&cleanHandler{},
|
||||
},
|
||||
TrashPath: s.Config.GetDeleteTrashPath(),
|
||||
}
|
||||
|
||||
j := cleanJob{
|
||||
@@ -364,9 +365,37 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
|
||||
return s.JobManager.Add(ctx, "Migrating scene hashes...", j)
|
||||
}
|
||||
|
||||
// If neither ids nor names are set, tag all items
|
||||
// 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.
|
||||
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
|
||||
@@ -375,128 +404,143 @@ type StashBoxBatchTagInput struct {
|
||||
Refresh bool `json:"refresh"`
|
||||
// If batch adding studios, should their parent studios also be created?
|
||||
CreateParent bool `json:"createParent"`
|
||||
// If set, only tag these ids
|
||||
// IDs in stash of the items to update.
|
||||
// If set, names and stash_ids fields will be ignored.
|
||||
Ids []string `json:"ids"`
|
||||
// If set, only tag these names
|
||||
// Names of the items in the stash-box instance to search for and create
|
||||
Names []string `json:"names"`
|
||||
// If set, only tag these performer ids
|
||||
// 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
|
||||
//
|
||||
// Deprecated: please use Ids
|
||||
// Deprecated: use Ids
|
||||
PerformerIds []string `json:"performer_ids"`
|
||||
// If set, only tag these performer names
|
||||
// Names of the performers in the stash-box instance to search for and create
|
||||
//
|
||||
// Deprecated: please use Names
|
||||
// Deprecated: 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 []StashBoxBatchTagTask
|
||||
var tasks []Task
|
||||
var err error
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
|
||||
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 err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
@@ -508,7 +552,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.Description(), func() {
|
||||
progress.ExecuteTask(task.GetDescription(), func() {
|
||||
task.Start(ctx)
|
||||
})
|
||||
|
||||
@@ -521,103 +565,116 @@ 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 []StashBoxBatchTagTask
|
||||
var tasks []Task
|
||||
var err error
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
|
||||
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 err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
@@ -629,7 +686,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.Description(), func() {
|
||||
progress.ExecuteTask(task.GetDescription(), func() {
|
||||
task.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
@@ -20,6 +21,13 @@ 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
|
||||
@@ -46,14 +54,15 @@ 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: %w", path, err)
|
||||
logger.Errorf("[generator] getting thumbnail for image %s: %s", path, err.Error())
|
||||
t.logStderr(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = fsutil.WriteFile(thumbPath, data)
|
||||
if err != nil {
|
||||
logger.Errorf("[generator] writing thumbnail for image %s: %w", path, err)
|
||||
logger.Errorf("[generator] writing thumbnail for image %s: %s", path, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,57 +14,33 @@ import (
|
||||
"github.com/stashapp/stash/pkg/studio"
|
||||
)
|
||||
|
||||
type StashBoxTagTaskType int
|
||||
|
||||
const (
|
||||
Performer StashBoxTagTaskType = iota
|
||||
Studio
|
||||
)
|
||||
|
||||
type StashBoxBatchTagTask struct {
|
||||
// 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 {
|
||||
box *models.StashBox
|
||||
name *string
|
||||
stashID *string
|
||||
performer *models.Performer
|
||||
studio *models.Studio
|
||||
refresh bool
|
||||
createParent bool
|
||||
excludedFields []string
|
||||
taskType StashBoxTagTaskType
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) Start(ctx context.Context) {
|
||||
switch t.taskType {
|
||||
case Performer:
|
||||
t.stashBoxPerformerTag(ctx)
|
||||
case Studio:
|
||||
t.stashBoxStudioTag(ctx)
|
||||
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
|
||||
default:
|
||||
logger.Errorf("Error starting batch task, unknown task_type %d", t.taskType)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
|
||||
performer, err := t.findStashBoxPerformer(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Error fetching performer data from stash-box: %v", err)
|
||||
@@ -76,21 +52,18 @@ func (t *StashBoxBatchTagTask) stashBoxPerformerTag(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 {
|
||||
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)
|
||||
logger.Infof("No match found for %s", t.getName())
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
|
||||
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) {
|
||||
var performer *models.ScrapedPerformer
|
||||
var err error
|
||||
|
||||
@@ -98,7 +71,24 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
|
||||
|
||||
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
|
||||
|
||||
if t.refresh {
|
||||
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
|
||||
var remoteID string
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Performer
|
||||
@@ -118,6 +108,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if remoteID != "" {
|
||||
performer, err = client.FindPerformerByID(ctx, remoteID)
|
||||
|
||||
@@ -132,15 +123,10 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
|
||||
performer = mergedPerformer
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else {
|
||||
name = t.performer.Name
|
||||
// find by performer name instead
|
||||
performer, err = client.FindPerformerByName(ctx, t.performer.Name)
|
||||
}
|
||||
performer, err = client.FindPerformerByName(ctx, name)
|
||||
}
|
||||
|
||||
if performer != nil {
|
||||
@@ -154,7 +140,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
|
||||
return performer, err
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
|
||||
func (t *stashBoxBatchPerformerTagTask) 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)
|
||||
@@ -169,8 +155,7 @@ func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, perfor
|
||||
return mergedPerformer, nil
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
|
||||
// Refreshing an existing performer
|
||||
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
|
||||
if t.performer != nil {
|
||||
storedID, _ := strconv.Atoi(*p.StoredID)
|
||||
|
||||
@@ -180,7 +165,6 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
|
||||
return
|
||||
}
|
||||
|
||||
// Start the transaction and update the performer
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Performer
|
||||
@@ -226,8 +210,8 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
|
||||
} else {
|
||||
logger.Infof("Updated performer %s", *p.Name)
|
||||
}
|
||||
} else if t.name != nil && p.Name != nil {
|
||||
// Creating a new performer
|
||||
} else {
|
||||
// no existing performer, create a new one
|
||||
newPerformer := p.ToPerformer(t.box.Endpoint, excluded)
|
||||
image, err := p.GetImage(ctx, excluded)
|
||||
if err != nil {
|
||||
@@ -263,7 +247,34 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) {
|
||||
// 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) {
|
||||
studio, err := t.findStashBoxStudio(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Error fetching studio data from stash-box: %v", err)
|
||||
@@ -275,21 +286,18 @@ func (t *StashBoxBatchTagTask) stashBoxStudioTag(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 {
|
||||
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)
|
||||
logger.Infof("No match found for %s", t.getName())
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {
|
||||
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) {
|
||||
var studio *models.ScrapedStudio
|
||||
var err error
|
||||
|
||||
@@ -297,7 +305,12 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
|
||||
|
||||
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
|
||||
|
||||
if t.refresh {
|
||||
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:
|
||||
var remoteID string
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
if !t.studio.StashIDs.Loaded() {
|
||||
@@ -315,17 +328,13 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if remoteID != "" {
|
||||
studio, err = client.FindStudio(ctx, remoteID)
|
||||
}
|
||||
} else {
|
||||
var name string
|
||||
if t.name != nil {
|
||||
name = *t.name
|
||||
} else {
|
||||
name = t.studio.Name
|
||||
// find by studio name instead
|
||||
studio, err = client.FindStudio(ctx, t.studio.Name)
|
||||
}
|
||||
studio, err = client.FindStudio(ctx, name)
|
||||
}
|
||||
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
@@ -343,8 +352,7 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
|
||||
return studio, err
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
|
||||
// Refreshing an existing studio
|
||||
func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
|
||||
if t.studio != nil {
|
||||
storedID, _ := strconv.Atoi(*s.StoredID)
|
||||
|
||||
@@ -361,7 +369,6 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
|
||||
return
|
||||
}
|
||||
|
||||
// Start the transaction and update the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
@@ -394,8 +401,8 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
|
||||
} else {
|
||||
logger.Infof("Updated studio %s", s.Name)
|
||||
}
|
||||
} else if t.name != nil && s.Name != "" {
|
||||
// Creating a new studio
|
||||
} else if s.Name != "" {
|
||||
// no existing studio, create a new one
|
||||
if s.Parent != nil && t.createParent {
|
||||
err := t.processParentStudio(ctx, s.Parent, excluded)
|
||||
if err != nil {
|
||||
@@ -410,7 +417,6 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
|
||||
return
|
||||
}
|
||||
|
||||
// Start the transaction and save the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
@@ -439,9 +445,8 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {
|
||||
func (t *stashBoxBatchStudioTagTask) 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)
|
||||
@@ -450,7 +455,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the transaction and save the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
@@ -476,7 +480,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
|
||||
}
|
||||
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)
|
||||
@@ -485,7 +488,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the transaction and update the studio
|
||||
r := instance.Repository
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
|
||||
@@ -36,6 +36,32 @@ 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
|
||||
@@ -69,7 +95,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
args = args.Output("-")
|
||||
|
||||
// #6064 - add timeout to context to prevent hangs
|
||||
const hwTestTimeoutSecondsDefault = 1
|
||||
const hwTestTimeoutSecondsDefault = 10
|
||||
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second
|
||||
|
||||
// allow timeout to be overridden with environment variable
|
||||
@@ -83,6 +109,7 @@ 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
|
||||
@@ -90,7 +117,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 %d seconds", codec, hwTestTimeoutSeconds)
|
||||
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -112,6 +139,8 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
}
|
||||
logger.Info(outstr)
|
||||
|
||||
f.hwCodecSupportMutex.Lock()
|
||||
defer f.hwCodecSupportMutex.Unlock()
|
||||
f.hwCodecSupport = hwCodecSupport
|
||||
}
|
||||
|
||||
@@ -411,7 +440,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.hwCodecSupport {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
switch element {
|
||||
case VideoCodecN264,
|
||||
VideoCodecN264H,
|
||||
@@ -429,7 +458,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.hwCodecSupport {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
switch element {
|
||||
case VideoCodecN264,
|
||||
VideoCodecN264H,
|
||||
@@ -445,7 +474,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.hwCodecSupport {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
switch element {
|
||||
case VideoCodecIVP9,
|
||||
VideoCodecVVP9:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
@@ -216,9 +217,10 @@ func (v Version) String() string {
|
||||
|
||||
// FFMpeg provides an interface to ffmpeg.
|
||||
type FFMpeg struct {
|
||||
ffmpeg string
|
||||
version Version
|
||||
hwCodecSupport []VideoCodec
|
||||
ffmpeg string
|
||||
version Version
|
||||
hwCodecSupport []VideoCodec
|
||||
hwCodecSupportMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// Creates a new FFMpeg encoder
|
||||
@@ -241,3 +243,9 @@ func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
|
||||
func (f *FFMpeg) Path() string {
|
||||
return f.ffmpeg
|
||||
}
|
||||
|
||||
func (f *FFMpeg) getHWCodecSupport() []VideoCodec {
|
||||
f.hwCodecSupportMutex.RLock()
|
||||
defer f.hwCodecSupportMutex.RUnlock()
|
||||
return f.hwCodecSupport
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ type Cleaner struct {
|
||||
FS models.FS
|
||||
Repository Repository
|
||||
|
||||
Handlers []CleanHandler
|
||||
Handlers []CleanHandler
|
||||
TrashPath string
|
||||
}
|
||||
|
||||
type cleanJob struct {
|
||||
@@ -392,7 +393,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 := NewDeleter()
|
||||
fileDeleter := NewDeleterWithTrash(j.TrashPath)
|
||||
r := j.Repository
|
||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
fileDeleter.RegisterHooks(ctx)
|
||||
@@ -410,7 +411,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 := NewDeleter()
|
||||
fileDeleter := NewDeleterWithTrash(j.TrashPath)
|
||||
r := j.Repository
|
||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
fileDeleter.RegisterHooks(ctx)
|
||||
|
||||
@@ -58,20 +58,33 @@ 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. 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.
|
||||
// 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.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +105,17 @@ 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 {
|
||||
@@ -103,7 +127,7 @@ func (d *Deleter) Files(paths []string) error {
|
||||
return fmt.Errorf("check file %q exists: %w", p, err)
|
||||
}
|
||||
|
||||
if err := d.renameForDelete(p); err != nil {
|
||||
if err := d.renameForDelete(p, bypassTrash); err != nil {
|
||||
return fmt.Errorf("marking file %q for deletion: %w", p, err)
|
||||
}
|
||||
d.files = append(d.files, p)
|
||||
@@ -118,6 +142,17 @@ func (d *Deleter) Files(paths []string) 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 {
|
||||
@@ -129,7 +164,7 @@ func (d *Deleter) Dirs(paths []string) error {
|
||||
return fmt.Errorf("check directory %q exists: %w", p, err)
|
||||
}
|
||||
|
||||
if err := d.renameForDelete(p); err != nil {
|
||||
if err := d.renameForDelete(p, bypassTrash); err != nil {
|
||||
return fmt.Errorf("marking directory %q for deletion: %w", p, err)
|
||||
}
|
||||
d.dirs = append(d.dirs, p)
|
||||
@@ -150,33 +185,65 @@ 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() {
|
||||
for _, f := range d.files {
|
||||
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
|
||||
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
|
||||
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.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) error {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ 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
|
||||
folder, err := fc.FindByPath(ctx, path)
|
||||
// assume case sensitive when searching for the folder
|
||||
const caseSensitive = true
|
||||
folder, err := fc.FindByPath(ctx, path, caseSensitive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
@@ -17,6 +20,8 @@ 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
|
||||
@@ -28,6 +33,10 @@ 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)
|
||||
}
|
||||
@@ -67,6 +76,25 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (
|
||||
Height: probe.Height,
|
||||
}
|
||||
|
||||
// FFprobe has a known bug where it returns 0x0 dimensions for some animated WebP files
|
||||
// Fall back to image.DecodeConfig in this case.
|
||||
// See: https://trac.ffmpeg.org/ticket/4907
|
||||
if ret.Width == 0 || ret.Height == 0 {
|
||||
logger.Warnf("FFprobe returned invalid dimensions (%dx%d) for %q, trying fallback decoder", ret.Width, ret.Height, base.Path)
|
||||
c, format, err := decodeConfig(fs, base.Path)
|
||||
if err != nil {
|
||||
logger.Warnf("Fallback decoder failed for %q: %s. Proceeding with original FFprobe result", base.Path, err)
|
||||
} else {
|
||||
ret.Width = c.Width
|
||||
ret.Height = c.Height
|
||||
// Update format if it differs (fallback decoder may be more accurate)
|
||||
if format != "" && format != ret.Format {
|
||||
logger.Debugf("Updating format from %q to %q for %q", ret.Format, format, base.Path)
|
||||
ret.Format = format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adjustForOrientation(fs, base.Path, ret)
|
||||
|
||||
return ret, nil
|
||||
|
||||
@@ -120,7 +120,7 @@ func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonsch
|
||||
func (i *Importer) populateZipFileID(ctx context.Context, f *models.DirEntry) error {
|
||||
zipFilePath := i.Input.DirEntry().ZipFile
|
||||
if zipFilePath != "" {
|
||||
zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath)
|
||||
zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath, true)
|
||||
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)
|
||||
existing, err := i.ReaderWriter.FindByPath(ctx, path, true)
|
||||
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)
|
||||
folder, err := i.FolderStore.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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 (
|
||||
@@ -443,7 +442,10 @@ func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderI
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
ret, err := s.Repository.Folder.FindByPath(ctx, path)
|
||||
// assume case sensitive when searching for the folder
|
||||
const caseSensitive = true
|
||||
|
||||
ret, err := s.Repository.Folder.FindByPath(ctx, path, caseSensitive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -473,7 +475,10 @@ func (s *scanJob) getZipFileID(ctx context.Context, zipFile *scanFile) (*models.
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
ret, err := s.Repository.File.FindByPath(ctx, path)
|
||||
// assume case sensitive when searching for the zip file
|
||||
const caseSensitive = true
|
||||
|
||||
ret, err := s.Repository.File.FindByPath(ctx, path, caseSensitive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting zip file ID for %q: %w", path, err)
|
||||
}
|
||||
@@ -493,11 +498,26 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error {
|
||||
defer s.incrementProgress(file)
|
||||
|
||||
// determine if folder already exists in data store (by path)
|
||||
f, err := s.Repository.Folder.FindByPath(ctx, path)
|
||||
// assume case sensitive by default
|
||||
f, err := s.Repository.Folder.FindByPath(ctx, path, true)
|
||||
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)
|
||||
@@ -611,10 +631,18 @@ 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
|
||||
@@ -647,15 +675,31 @@ 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)
|
||||
ff, err = s.Repository.File.FindByPath(ctx, f.Path, true)
|
||||
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)
|
||||
@@ -674,7 +718,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 := utils.ValueOnlyContext{Context: ctx}
|
||||
zipCtx := context.WithoutCancel(ctx)
|
||||
|
||||
if err := s.scanZipFile(zipCtx, f); err != nil {
|
||||
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
|
||||
@@ -879,6 +923,7 @@ 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)
|
||||
@@ -1026,7 +1071,8 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
|
||||
path := base.Path
|
||||
|
||||
fileModTime := f.ModTime
|
||||
updated := !fileModTime.Equal(base.ModTime)
|
||||
// #6326 - also force a rescan if the basename changed
|
||||
updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename
|
||||
forceRescan := s.options.Rescan
|
||||
|
||||
if !updated && !forceRescan {
|
||||
@@ -1041,6 +1087,8 @@ 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()
|
||||
|
||||
@@ -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+"*")
|
||||
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true)
|
||||
|
||||
if er != nil {
|
||||
return fmt.Errorf("searching for scene %s: %w", captionPrefix, er)
|
||||
|
||||
43
pkg/fsutil/trash.go
Normal file
43
pkg/fsutil/trash.go
Normal file
@@ -0,0 +1,43 @@
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
f, err := i.FileFinder.FindByPath(ctx, path, true)
|
||||
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)
|
||||
f, err := i.FolderFinder.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding folder: %w", err)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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)
|
||||
@@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
|
||||
files = append(files, prevPath)
|
||||
}
|
||||
|
||||
return d.Files(files)
|
||||
return d.FilesWithoutTrash(files)
|
||||
}
|
||||
|
||||
// Destroy destroys an image, optionally marking the file and generated files for deletion.
|
||||
|
||||
@@ -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)
|
||||
f, err := i.FileFinder.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding file: %w", err)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,8 @@ const ffmpegImageQuality = 5
|
||||
var vipsPath string
|
||||
var once sync.Once
|
||||
|
||||
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")
|
||||
)
|
||||
// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation
|
||||
var ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
|
||||
|
||||
type ThumbnailEncoder struct {
|
||||
FFMpeg *ffmpeg.FFMpeg
|
||||
@@ -83,8 +79,9 @@ 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
|
||||
@@ -96,6 +93,19 @@ 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
|
||||
@@ -104,11 +114,15 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
|
||||
}
|
||||
|
||||
// vips has issues loading files from stdin on Windows
|
||||
if e.vips != nil && runtime.GOOS != "windows" {
|
||||
return e.vips.ImageThumbnail(buf, maxSize)
|
||||
} else {
|
||||
return e.ffmpegImageThumbnail(buf, maxSize)
|
||||
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)
|
||||
}
|
||||
}
|
||||
return e.ffmpegImageThumbnail(buf, maxSize)
|
||||
}
|
||||
|
||||
// GetPreview returns the preview clip of the provided image clip resized to
|
||||
@@ -130,16 +144,32 @@ func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int
|
||||
}
|
||||
|
||||
func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {
|
||||
args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{
|
||||
options := 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)
|
||||
|
||||
@@ -24,6 +24,38 @@ 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...)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
const maxGraveyardSize = 10
|
||||
@@ -179,7 +178,8 @@ func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) {
|
||||
j.StartTime = &t
|
||||
j.Status = StatusRunning
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(utils.ValueOnlyContext{Context: ctx})
|
||||
// create a cancellable context for the job that is not canceled by the outer context
|
||||
ctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx))
|
||||
j.cancelFunc = cancelFunc
|
||||
|
||||
done = make(chan struct{})
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 {
|
||||
|
||||
@@ -1,31 +1,63 @@
|
||||
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
|
||||
}
|
||||
|
||||
const dateFormat = "2006-01-02"
|
||||
var dateFormatPrecision = []string{
|
||||
"2006-01-02",
|
||||
"2006-01",
|
||||
"2006",
|
||||
}
|
||||
|
||||
func (d Date) String() string {
|
||||
return d.Format(dateFormat)
|
||||
return d.Format(dateFormatPrecision[d.Precision])
|
||||
}
|
||||
|
||||
func (d Date) After(o Date) bool {
|
||||
return d.Time.After(o.Time)
|
||||
}
|
||||
|
||||
// ParseDate uses utils.ParseDateStringAsTime to parse a string into a date.
|
||||
// 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.
|
||||
func ParseDate(s string) (Date, error) {
|
||||
var errs []error
|
||||
|
||||
// default parse to day precision
|
||||
ret, err := utils.ParseDateStringAsTime(s)
|
||||
if err != nil {
|
||||
return Date{}, err
|
||||
if err == nil {
|
||||
return Date{Time: ret, Precision: DatePrecisionDay}, nil
|
||||
}
|
||||
return Date{Time: ret}, nil
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
50
pkg/models/date_test.go
Normal file
50
pkg/models/date_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string) ([]models.File, error) {
|
||||
ret := _m.Called(ctx, path)
|
||||
// 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)
|
||||
|
||||
var r0 []models.File
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) []models.File); ok {
|
||||
r0 = rf(ctx, path)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, bool) []models.File); ok {
|
||||
r0 = rf(ctx, path, caseSensitive)
|
||||
} 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) ([]m
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = rf(ctx, path)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
|
||||
r1 = rf(ctx, path, caseSensitive)
|
||||
} 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
|
||||
func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string) (models.File, error) {
|
||||
ret := _m.Called(ctx, path)
|
||||
// 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)
|
||||
|
||||
var r0 models.File
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) models.File); ok {
|
||||
r0 = rf(ctx, path)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, bool) models.File); ok {
|
||||
r0 = rf(ctx, path, caseSensitive)
|
||||
} 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) (models
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = rf(ctx, path)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
|
||||
r1 = rf(ctx, path, caseSensitive)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string) (*models.Folder, error) {
|
||||
ret := _m.Called(ctx, path)
|
||||
// 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)
|
||||
|
||||
var r0 *models.Folder
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *models.Folder); ok {
|
||||
r0 = rf(ctx, path)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Folder); ok {
|
||||
r0 = rf(ctx, path, caseSensitive)
|
||||
} 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) (*mod
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = rf(ctx, path)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
|
||||
r1 = rf(ctx, path, caseSensitive)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
@@ -594,6 +594,27 @@ 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)
|
||||
|
||||
@@ -1183,6 +1183,27 @@ 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)
|
||||
|
||||
@@ -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 = s.Name
|
||||
ret.Name = strings.TrimSpace(s.Name)
|
||||
|
||||
if s.RemoteSiteID != nil && endpoint != "" {
|
||||
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
|
||||
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 && !excluded["aliases"] {
|
||||
if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] {
|
||||
ret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, ","))
|
||||
}
|
||||
|
||||
@@ -95,37 +95,38 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
|
||||
currentTime := time.Now()
|
||||
|
||||
if s.Name != "" && !excluded["name"] {
|
||||
ret.Name = NewOptionalString(s.Name)
|
||||
ret.Name = NewOptionalString(strings.TrimSpace(s.Name))
|
||||
}
|
||||
|
||||
if len(s.URLs) > 0 {
|
||||
if !excluded["urls"] {
|
||||
|
||||
ret.URLs = &UpdateStrings{
|
||||
Values: s.URLs,
|
||||
Values: stringslice.TrimSpace(s.URLs),
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
urls := []string{}
|
||||
if s.URL != nil && !excluded["url"] {
|
||||
urls = append(urls, *s.URL)
|
||||
urls = append(urls, strings.TrimSpace(*s.URL))
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
ret.URLs = &UpdateStrings{
|
||||
Values: urls,
|
||||
Values: stringslice.TrimSpace(urls),
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.Details != nil && !excluded["details"] {
|
||||
ret.Details = NewOptionalString(*s.Details)
|
||||
ret.Details = NewOptionalString(strings.TrimSpace(*s.Details))
|
||||
}
|
||||
|
||||
if s.Aliases != nil && !excluded["aliases"] {
|
||||
if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] {
|
||||
ret.Aliases = &UpdateStrings{
|
||||
Values: stringslice.FromString(*s.Aliases, ","),
|
||||
Values: stringslice.TrimSpace(stringslice.FromString(*s.Aliases, ",")),
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
@@ -140,7 +141,7 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
|
||||
}
|
||||
}
|
||||
|
||||
if s.RemoteSiteID != nil && endpoint != "" {
|
||||
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
|
||||
ret.StashIDs = &UpdateStashIDs{
|
||||
StashIDs: existingStashIDs,
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
@@ -197,10 +198,14 @@ func (ScrapedPerformer) IsScrapedContent() {}
|
||||
func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer {
|
||||
ret := NewPerformer()
|
||||
currentTime := time.Now()
|
||||
ret.Name = *p.Name
|
||||
ret.Name = strings.TrimSpace(*p.Name)
|
||||
|
||||
if p.Aliases != nil && !excluded["aliases"] {
|
||||
ret.Aliases = NewRelatedStrings(stringslice.FromString(*p.Aliases, ","))
|
||||
aliases := stringslice.FromString(*p.Aliases, ",")
|
||||
for i, alias := range aliases {
|
||||
aliases[i] = strings.TrimSpace(alias)
|
||||
}
|
||||
ret.Aliases = NewRelatedStrings(aliases)
|
||||
}
|
||||
if p.Birthdate != nil && !excluded["birthdate"] {
|
||||
date, err := ParseDate(*p.Birthdate)
|
||||
@@ -301,7 +306,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
|
||||
}
|
||||
}
|
||||
|
||||
if p.RemoteSiteID != nil && endpoint != "" {
|
||||
if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
{
|
||||
Endpoint: endpoint,
|
||||
@@ -430,7 +435,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
||||
}
|
||||
}
|
||||
|
||||
if p.RemoteSiteID != nil && endpoint != "" {
|
||||
if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" {
|
||||
ret.StashIDs = &UpdateStashIDs{
|
||||
StashIDs: existingStashIDs,
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
@@ -459,7 +464,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
||||
ret := NewTag()
|
||||
ret.Name = t.Name
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" {
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
{
|
||||
Endpoint: endpoint,
|
||||
|
||||
@@ -13,9 +13,9 @@ type FileGetter interface {
|
||||
// FileFinder provides methods to find files.
|
||||
type FileFinder interface {
|
||||
FileGetter
|
||||
FindAllByPath(ctx context.Context, path string) ([]File, error)
|
||||
FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error)
|
||||
FindByPath(ctx context.Context, path string) (File, error)
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (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)
|
||||
|
||||
@@ -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) (*Folder, error)
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ 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.
|
||||
|
||||
@@ -45,6 +45,7 @@ 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.
|
||||
|
||||
@@ -79,10 +79,23 @@ func (s StashIDInputs) ToStashIDs() StashIDs {
|
||||
return nil
|
||||
}
|
||||
|
||||
ret := make(StashIDs, len(s))
|
||||
for i, v := range s {
|
||||
ret[i] = v.ToStashID()
|
||||
// #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)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ 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 related scenes that meet this criteria
|
||||
ScenesFilter *SceneFilterType `json:"scenes_filter"`
|
||||
// Filter by related images that meet this criteria
|
||||
|
||||
@@ -21,6 +21,7 @@ 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)
|
||||
|
||||
@@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
|
||||
|
||||
exists, _ := fsutil.FileExists(markersFolder)
|
||||
if exists {
|
||||
if err := d.Dirs([]string{markersFolder}); err != nil {
|
||||
if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -75,11 +76,12 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
|
||||
files = append(files, heatmapPath)
|
||||
}
|
||||
|
||||
return d.Files(files)
|
||||
return d.FilesWithoutTrash(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)
|
||||
@@ -102,7 +104,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
|
||||
files = append(files, screenshotPath)
|
||||
}
|
||||
|
||||
return d.Files(files)
|
||||
return d.FilesWithoutTrash(files)
|
||||
}
|
||||
|
||||
// Destroy deletes a scene and its associated relationships from the
|
||||
|
||||
@@ -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)
|
||||
f, err := i.FileFinder.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding file: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -262,19 +261,23 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogIgnoredTags(ignoredTags)
|
||||
LogIgnoredTags(pp.ignoredTags)
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
@@ -37,88 +37,43 @@ func setPerformerImage(ctx context.Context, client *http.Client, p *models.Scrap
|
||||
return nil
|
||||
}
|
||||
|
||||
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") {
|
||||
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 {
|
||||
// nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
img, err := getImage(ctx, *s.Image, client, globalConfig)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Image = img
|
||||
p.Image = img
|
||||
// Image is deprecated. Use images instead
|
||||
p.Images = []string{*img}
|
||||
|
||||
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
|
||||
if m.FrontImage == nil || !strings.HasPrefix(*m.FrontImage, "http") {
|
||||
// nothing to do
|
||||
func processImageField(ctx context.Context, imageField *string, client *http.Client, globalConfig GlobalConfig) error {
|
||||
if imageField == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
img, err := getImage(ctx, *m.FrontImage, client, globalConfig)
|
||||
img, err := getImage(ctx, *imageField, client, globalConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
*imageField = *img
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,85 +11,91 @@ import (
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type postScraper struct {
|
||||
Cache
|
||||
excludeTagRE []*regexp.Regexp
|
||||
|
||||
// ignoredTags is a list of tags that were ignored during post-processing
|
||||
ignoredTags []string
|
||||
}
|
||||
|
||||
// postScrape handles post-processing of scraped content. If the content
|
||||
// requires post-processing, this function fans out to the given content
|
||||
// type and post-processes it.
|
||||
func (c Cache) postScrape(ctx context.Context, content ScrapedContent, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
// Assumes called within a read transaction.
|
||||
func (c *postScraper) postScrape(ctx context.Context, content ScrapedContent) (_ ScrapedContent, err error) {
|
||||
const related = false
|
||||
|
||||
// Analyze the concrete type, call the right post-processing function
|
||||
switch v := content.(type) {
|
||||
case *models.ScrapedPerformer:
|
||||
if v != nil {
|
||||
return c.postScrapePerformer(ctx, *v, excludeTagRE)
|
||||
return c.postScrapePerformer(ctx, *v, related)
|
||||
}
|
||||
case models.ScrapedPerformer:
|
||||
return c.postScrapePerformer(ctx, v, excludeTagRE)
|
||||
return c.postScrapePerformer(ctx, v, related)
|
||||
case *models.ScrapedScene:
|
||||
if v != nil {
|
||||
return c.postScrapeScene(ctx, *v, excludeTagRE)
|
||||
return c.postScrapeScene(ctx, *v)
|
||||
}
|
||||
case models.ScrapedScene:
|
||||
return c.postScrapeScene(ctx, v, excludeTagRE)
|
||||
return c.postScrapeScene(ctx, v)
|
||||
case *models.ScrapedGallery:
|
||||
if v != nil {
|
||||
return c.postScrapeGallery(ctx, *v, excludeTagRE)
|
||||
return c.postScrapeGallery(ctx, *v)
|
||||
}
|
||||
case models.ScrapedGallery:
|
||||
return c.postScrapeGallery(ctx, v, excludeTagRE)
|
||||
return c.postScrapeGallery(ctx, v)
|
||||
case *models.ScrapedImage:
|
||||
if v != nil {
|
||||
return c.postScrapeImage(ctx, *v, excludeTagRE)
|
||||
return c.postScrapeImage(ctx, *v)
|
||||
}
|
||||
case models.ScrapedImage:
|
||||
return c.postScrapeImage(ctx, v, excludeTagRE)
|
||||
return c.postScrapeImage(ctx, v)
|
||||
case *models.ScrapedMovie:
|
||||
if v != nil {
|
||||
return c.postScrapeMovie(ctx, *v, excludeTagRE)
|
||||
return c.postScrapeMovie(ctx, *v, related)
|
||||
}
|
||||
case models.ScrapedMovie:
|
||||
return c.postScrapeMovie(ctx, v, excludeTagRE)
|
||||
return c.postScrapeMovie(ctx, v, related)
|
||||
case *models.ScrapedGroup:
|
||||
if v != nil {
|
||||
return c.postScrapeGroup(ctx, *v, excludeTagRE)
|
||||
return c.postScrapeGroup(ctx, *v, related)
|
||||
}
|
||||
case models.ScrapedGroup:
|
||||
return c.postScrapeGroup(ctx, v, excludeTagRE)
|
||||
return c.postScrapeGroup(ctx, v, related)
|
||||
}
|
||||
|
||||
// If nothing matches, pass the content through
|
||||
return content, nil, nil
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// postScrapeSingle handles post-processing of a single scraped content item.
|
||||
// This is a convenience function that includes logging the ignored tags, as opposed to logging them in the caller.
|
||||
func (c Cache) postScrapeSingle(ctx context.Context, content ScrapedContent) (ScrapedContent, error) {
|
||||
ret, ignoredTags, err := c.postScrape(ctx, content, c.compileExcludeTagPatterns())
|
||||
func (c *postScraper) filterTags(tags []*models.ScrapedTag) []*models.ScrapedTag {
|
||||
var ret []*models.ScrapedTag
|
||||
var thisIgnoredTags []string
|
||||
ret, thisIgnoredTags = FilterTags(c.excludeTagRE, tags)
|
||||
c.ignoredTags = sliceutil.AppendUniques(c.ignoredTags, thisIgnoredTags)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer, related bool) (_ ScrapedContent, err error) {
|
||||
r := c.repository
|
||||
tqb := r.TagFinder
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, p.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogIgnoredTags(ignoredTags)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
tqb := r.TagFinder
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, p.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p.Tags = c.filterTags(tags)
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil {
|
||||
logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error())
|
||||
// don't set image for related performers to avoid excessive network calls
|
||||
if !related {
|
||||
if err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil {
|
||||
logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
p.Country = resolveCountryName(p.Country)
|
||||
@@ -119,119 +125,224 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme
|
||||
}
|
||||
}
|
||||
|
||||
return p, ignoredTags, nil
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) {
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
tqb := r.TagFinder
|
||||
tags, err := postProcessTags(ctx, tqb, m.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
|
||||
if m.Studio != nil {
|
||||
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
}
|
||||
if err := setMovieBackImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
|
||||
}
|
||||
|
||||
return m, ignoredTags, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
tqb := r.TagFinder
|
||||
tags, err := postProcessTags(ctx, tqb, m.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
|
||||
if m.Studio != nil {
|
||||
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
}
|
||||
if err := setGroupBackImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
|
||||
}
|
||||
|
||||
return m, ignoredTags, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapeScenePerformer(ctx context.Context, p models.ScrapedPerformer, excludeTagRE []*regexp.Regexp) (ignoredTags []string, err error) {
|
||||
tqb := c.repository.TagFinder
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, p.Tags)
|
||||
tqb := r.TagFinder
|
||||
tags, err := postProcessTags(ctx, tqb, m.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Tags = tags
|
||||
p.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
m.Tags = c.filterTags(tags)
|
||||
|
||||
p.Country = resolveCountryName(p.Country)
|
||||
if m.Studio != nil {
|
||||
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return ignoredTags, nil
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
// don't set images for related movies to avoid excessive network calls
|
||||
if !related {
|
||||
if err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
}
|
||||
if err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapeScene(ctx context.Context, scene models.ScrapedScene, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
func (c *postScraper) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, related bool) (_ ScrapedContent, err error) {
|
||||
r := c.repository
|
||||
tqb := r.TagFinder
|
||||
tags, err := postProcessTags(ctx, tqb, m.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Tags = c.filterTags(tags)
|
||||
|
||||
if m.Studio != nil {
|
||||
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
// don't set images for related groups to avoid excessive network calls
|
||||
if !related {
|
||||
if err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
}
|
||||
if err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// postScrapeRelatedPerformers post-processes a list of performers.
|
||||
// It modifies the performers in place.
|
||||
func (c *postScraper) postScrapeRelatedPerformers(ctx context.Context, items []*models.ScrapedPerformer) error {
|
||||
for _, p := range items {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
const related = true
|
||||
sc, err := c.postScrapePerformer(ctx, *p, related)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newP := sc.(models.ScrapedPerformer)
|
||||
*p = newP
|
||||
|
||||
if err := match.ScrapedPerformer(ctx, c.repository.PerformerFinder, p, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeRelatedMovies(ctx context.Context, items []*models.ScrapedMovie) error {
|
||||
for _, p := range items {
|
||||
const related = true
|
||||
sc, err := c.postScrapeMovie(ctx, *p, related)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newP := sc.(models.ScrapedMovie)
|
||||
*p = newP
|
||||
|
||||
matchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if matchedID != nil {
|
||||
p.StoredID = matchedID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeRelatedGroups(ctx context.Context, items []*models.ScrapedGroup) error {
|
||||
for _, p := range items {
|
||||
const related = true
|
||||
sc, err := c.postScrapeGroup(ctx, *p, related)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newP := sc.(models.ScrapedGroup)
|
||||
*p = newP
|
||||
|
||||
matchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if matchedID != nil {
|
||||
p.StoredID = matchedID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeStudio(ctx context.Context, s models.ScrapedStudio, related bool) (_ ScrapedContent, err error) {
|
||||
r := c.repository
|
||||
tqb := r.TagFinder
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, s.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.Tags = c.filterTags(tags)
|
||||
|
||||
// post-process - set the image if applicable
|
||||
// don't set image for related studios to avoid excessive network calls
|
||||
if !related {
|
||||
if err := setStudioImage(ctx, c.client, &s, c.globalConfig); err != nil {
|
||||
logger.Warnf("Could not set image using URL %s: %s", *s.Image, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(s.URLs) > 0 {
|
||||
s.URL = &s.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if s.URL != nil {
|
||||
urls = append(urls, *s.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
s.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeRelatedStudio(ctx context.Context, s *models.ScrapedStudio) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
const related = true
|
||||
sc, err := c.postScrapeStudio(ctx, *s, related)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newS := sc.(models.ScrapedStudio)
|
||||
*s = newS
|
||||
|
||||
if err = match.ScrapedStudio(ctx, c.repository.StudioFinder, s, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeScene(ctx context.Context, scene models.ScrapedScene) (_ ScrapedContent, err error) {
|
||||
// set the URL/URLs field
|
||||
if scene.URL == nil && len(scene.URLs) > 0 {
|
||||
scene.URL = &scene.URLs[0]
|
||||
@@ -241,92 +352,53 @@ func (c Cache) postScrapeScene(ctx context.Context, scene models.ScrapedScene, e
|
||||
}
|
||||
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
pqb := r.PerformerFinder
|
||||
gqb := r.GroupFinder
|
||||
tqb := r.TagFinder
|
||||
sqb := r.StudioFinder
|
||||
tqb := r.TagFinder
|
||||
|
||||
for _, p := range scene.Performers {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if err = c.postScrapeRelatedPerformers(ctx, scene.Performers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
thisIgnoredTags, err := c.postScrapeScenePerformer(ctx, *p, excludeTagRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = c.postScrapeRelatedMovies(ctx, scene.Movies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := match.ScrapedPerformer(ctx, pqb, p, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = c.postScrapeRelatedGroups(ctx, scene.Groups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
|
||||
// HACK - if movies was returned but not groups, add the groups from the movies
|
||||
// if groups was returned but not movies, add the movies from the groups for backward compatibility
|
||||
if len(scene.Movies) > 0 && len(scene.Groups) == 0 {
|
||||
for _, m := range scene.Movies {
|
||||
g := m.ScrapedGroup()
|
||||
scene.Groups = append(scene.Groups, &g)
|
||||
}
|
||||
|
||||
for _, p := range scene.Movies {
|
||||
matchedID, err := match.ScrapedGroup(ctx, gqb, p.StoredID, p.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if matchedID != nil {
|
||||
p.StoredID = matchedID
|
||||
}
|
||||
} else if len(scene.Groups) > 0 && len(scene.Movies) == 0 {
|
||||
for _, g := range scene.Groups {
|
||||
m := g.ScrapedMovie()
|
||||
scene.Movies = append(scene.Movies, &m)
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range scene.Groups {
|
||||
matchedID, err := match.ScrapedGroup(ctx, gqb, p.StoredID, p.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := postProcessTags(ctx, tqb, scene.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scene.Tags = c.filterTags(tags)
|
||||
|
||||
if matchedID != nil {
|
||||
p.StoredID = matchedID
|
||||
}
|
||||
}
|
||||
|
||||
// HACK - if movies was returned but not groups, add the groups from the movies
|
||||
// if groups was returned but not movies, add the movies from the groups for backward compatibility
|
||||
if len(scene.Movies) > 0 && len(scene.Groups) == 0 {
|
||||
for _, m := range scene.Movies {
|
||||
g := m.ScrapedGroup()
|
||||
scene.Groups = append(scene.Groups, &g)
|
||||
}
|
||||
} else if len(scene.Groups) > 0 && len(scene.Movies) == 0 {
|
||||
for _, g := range scene.Groups {
|
||||
m := g.ScrapedMovie()
|
||||
scene.Movies = append(scene.Movies, &m)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, scene.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scene.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
|
||||
if scene.Studio != nil {
|
||||
err := match.ScrapedStudio(ctx, sqb, scene.Studio, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
if err := c.postScrapeRelatedStudio(ctx, scene.Studio); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setSceneImage(ctx, c.client, &scene, c.globalConfig); err != nil {
|
||||
if err := processImageField(ctx, scene.Image, c.client, c.globalConfig); err != nil {
|
||||
logger.Warnf("Could not set image using URL %s: %v", *scene.Image, err)
|
||||
}
|
||||
|
||||
return scene, ignoredTags, nil
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapeGallery(ctx context.Context, g models.ScrapedGallery, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
func (c *postScraper) postScrapeGallery(ctx context.Context, g models.ScrapedGallery) (_ ScrapedContent, err error) {
|
||||
// set the URL/URLs field
|
||||
if g.URL == nil && len(g.URLs) > 0 {
|
||||
g.URL = &g.URLs[0]
|
||||
@@ -336,70 +408,65 @@ func (c Cache) postScrapeGallery(ctx context.Context, g models.ScrapedGallery, e
|
||||
}
|
||||
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
pqb := r.PerformerFinder
|
||||
tqb := r.TagFinder
|
||||
sqb := r.StudioFinder
|
||||
tqb := r.TagFinder
|
||||
|
||||
for _, p := range g.Performers {
|
||||
err := match.ScrapedPerformer(ctx, pqb, p, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, g.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
|
||||
if g.Studio != nil {
|
||||
err := match.ScrapedStudio(ctx, sqb, g.Studio, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
if err = c.postScrapeRelatedPerformers(ctx, g.Performers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return g, ignoredTags, nil
|
||||
tags, err := postProcessTags(ctx, tqb, g.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Tags = c.filterTags(tags)
|
||||
|
||||
if err := c.postScrapeRelatedStudio(ctx, g.Studio); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (c Cache) postScrapeImage(ctx context.Context, image models.ScrapedImage, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
|
||||
func (c *postScraper) postScrapeImage(ctx context.Context, image models.ScrapedImage) (_ ScrapedContent, err error) {
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
pqb := r.PerformerFinder
|
||||
tqb := r.TagFinder
|
||||
sqb := r.StudioFinder
|
||||
tqb := r.TagFinder
|
||||
|
||||
for _, p := range image.Performers {
|
||||
if err := match.ScrapedPerformer(ctx, pqb, p, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = c.postScrapeRelatedPerformers(ctx, image.Performers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := postProcessTags(ctx, tqb, image.Tags)
|
||||
tags, err := postProcessTags(ctx, tqb, image.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
image.Tags = c.filterTags(tags)
|
||||
|
||||
if err := c.postScrapeRelatedStudio(ctx, image.Studio); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// postScrapeSingle handles post-processing of a single scraped content item.
|
||||
// This is a convenience function that includes logging the ignored tags, as opposed to logging them in the caller.
|
||||
func (c Cache) postScrapeSingle(ctx context.Context, content ScrapedContent) (ret ScrapedContent, err error) {
|
||||
pp := postScraper{
|
||||
Cache: c,
|
||||
excludeTagRE: c.compileExcludeTagPatterns(),
|
||||
}
|
||||
|
||||
if err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = pp.postScrape(ctx, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
image.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
|
||||
|
||||
if image.Studio != nil {
|
||||
err := match.ScrapedStudio(ctx, sqb, image.Studio, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return image, ignoredTags, nil
|
||||
LogIgnoredTags(pp.ignoredTags)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Ima
|
||||
func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) {
|
||||
r, err := loadURL(ctx, url, s.client, s.config, s.globalConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to load URL %q: %w", url, err)
|
||||
}
|
||||
|
||||
ret, err := html.Parse(r)
|
||||
|
||||
@@ -44,3 +44,11 @@ func UniqueFold(s []string) []string {
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// TrimSpace trims whitespace from each string in a slice.
|
||||
func TrimSpace(s []string) []string {
|
||||
for i, v := range s {
|
||||
s[i] = strings.TrimSpace(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values
|
||||
case values.Partial != nil:
|
||||
partial = true
|
||||
valMap = values.Partial
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.validateCustomFields(valMap); err != nil {
|
||||
if valMap != nil {
|
||||
if err := s.validateCustomFields(valMap, values.Remove); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.setCustomFields(ctx, id, valMap, partial); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.setCustomFields(ctx, id, valMap, partial)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) error {
|
||||
func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error {
|
||||
// if values is nil, nothing to validate
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure that custom field names are valid
|
||||
// no leading or trailing whitespace, no empty strings
|
||||
for k := range values {
|
||||
@@ -61,6 +74,13 @@ func (s *customFieldsStore) validateCustomFields(values map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
// ensure delete keys are not also in values
|
||||
for _, k := range deleteKeys {
|
||||
if _, ok := values[k]; ok {
|
||||
return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
q := dialect.Delete(s.table).
|
||||
Where(s.fk.Eq(id)).
|
||||
Where(goqu.I("field").In(keys))
|
||||
|
||||
if _, err := exec(ctx, q); err != nil {
|
||||
return fmt.Errorf("deleting custom fields: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {
|
||||
q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id))
|
||||
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) {
|
||||
}),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid remove",
|
||||
models.CustomFieldsInput{
|
||||
Remove: []string{"real"},
|
||||
},
|
||||
func() map[string]interface{} {
|
||||
m := getPerformerCustomFields(performerIdx)
|
||||
delete(m, "real")
|
||||
return m
|
||||
}(),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"leading space full",
|
||||
models.CustomFieldsInput{
|
||||
@@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) {
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid remove full",
|
||||
models.CustomFieldsInput{
|
||||
Full: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
Remove: []string{"key"},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid remove partial",
|
||||
models.CustomFieldsInput{
|
||||
Partial: map[string]interface{}{
|
||||
"real": float64(4.56),
|
||||
},
|
||||
Remove: []string{"real"},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
// use performer custom fields store
|
||||
store := db.Performer
|
||||
id := performerIDs[performerIdx]
|
||||
|
||||
assert := assert.New(t)
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
err := store.SetCustomFields(ctx, id, tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 74
|
||||
var appSchemaVersion uint = 75
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"gopkg.in/guregu/null.v4"
|
||||
)
|
||||
|
||||
const sqliteDateLayout = "2006-01-02"
|
||||
@@ -54,12 +55,12 @@ func (d NullDate) Value() (driver.Value, error) {
|
||||
return d.Date.Format(sqliteDateLayout), nil
|
||||
}
|
||||
|
||||
func (d *NullDate) DatePtr() *models.Date {
|
||||
func (d *NullDate) DatePtr(precision null.Int) *models.Date {
|
||||
if d == nil || !d.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.Date{Time: d.Date}
|
||||
return &models.Date{Time: d.Date, Precision: models.DatePrecision(precision.Int64)}
|
||||
}
|
||||
|
||||
func NullDateFromDatePtr(d *models.Date) NullDate {
|
||||
@@ -68,3 +69,11 @@ func NullDateFromDatePtr(d *models.Date) NullDate {
|
||||
}
|
||||
return NullDate{Date: d.Time, Valid: true}
|
||||
}
|
||||
|
||||
func datePrecisionFromDatePtr(d *models.Date) null.Int {
|
||||
if d == nil {
|
||||
// default to day precision
|
||||
return null.Int{}
|
||||
}
|
||||
return null.IntFrom(int64(d.Precision))
|
||||
}
|
||||
|
||||
@@ -625,9 +625,9 @@ func (qb *FileStore) find(ctx context.Context, id models.FileID) (models.File, e
|
||||
}
|
||||
|
||||
// FindByPath returns the first file that matches the given path. Wildcard characters are supported.
|
||||
func (qb *FileStore) FindByPath(ctx context.Context, p string) (models.File, error) {
|
||||
func (qb *FileStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (models.File, error) {
|
||||
|
||||
ret, err := qb.FindAllByPath(ctx, p)
|
||||
ret, err := qb.FindAllByPath(ctx, p, caseSensitive)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -642,7 +642,7 @@ func (qb *FileStore) FindByPath(ctx context.Context, p string) (models.File, err
|
||||
|
||||
// FindAllByPath returns all the files that match the given path.
|
||||
// Wildcard characters are supported.
|
||||
func (qb *FileStore) FindAllByPath(ctx context.Context, p string) ([]models.File, error) {
|
||||
func (qb *FileStore) FindAllByPath(ctx context.Context, p string, caseSensitive bool) ([]models.File, error) {
|
||||
// separate basename from path
|
||||
basename := filepath.Base(p)
|
||||
dirName := filepath.Dir(p)
|
||||
@@ -657,7 +657,7 @@ func (qb *FileStore) FindAllByPath(ctx context.Context, p string) ([]models.File
|
||||
// like uses case-insensitive matching. Only use like if wildcards are used
|
||||
q := qb.selectDataset().Prepared(true)
|
||||
|
||||
if strings.Contains(basename, "%") || strings.Contains(dirName, "%") {
|
||||
if strings.Contains(basename, "%") || strings.Contains(dirName, "%") || !caseSensitive {
|
||||
q = q.Where(
|
||||
folderTable.Col("path").Like(dirName),
|
||||
table.Col("basename").Like(basename),
|
||||
|
||||
@@ -551,7 +551,7 @@ func Test_FileStore_FindByPath(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
got, err := qb.FindByPath(ctx, tt.path)
|
||||
got, err := qb.FindByPath(ctx, tt.path, true)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FileStore.FindByPath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -96,6 +96,9 @@ type join struct {
|
||||
onClause string
|
||||
joinType string
|
||||
args []interface{}
|
||||
|
||||
// if true, indicates this is required for sorting only
|
||||
sort bool
|
||||
}
|
||||
|
||||
// equals returns true if the other join alias/table is equal to this one
|
||||
@@ -127,30 +130,45 @@ func (j join) toSQL() string {
|
||||
|
||||
type joins []join
|
||||
|
||||
// addUnique only adds if not already present
|
||||
// returns true if added
|
||||
func (j *joins) addUnique(newJoin join) bool {
|
||||
found := false
|
||||
for i, jj := range *j {
|
||||
if jj.equals(newJoin) {
|
||||
found = true
|
||||
// if sort is false on the new join, but true on the existing, set the false
|
||||
if !newJoin.sort && jj.sort {
|
||||
(*j)[i].sort = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
*j = append(*j, newJoin)
|
||||
}
|
||||
return !found
|
||||
}
|
||||
|
||||
func (j *joins) add(newJoins ...join) {
|
||||
// only add if not already joined
|
||||
for _, newJoin := range newJoins {
|
||||
found := false
|
||||
for _, jj := range *j {
|
||||
if jj.equals(newJoin) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
*j = append(*j, newJoin)
|
||||
}
|
||||
j.addUnique(newJoin)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *joins) toSQL() string {
|
||||
func (j *joins) toSQL(includeSortPagination bool) string {
|
||||
if len(*j) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var ret []string
|
||||
for _, jj := range *j {
|
||||
// skip sort-only joins if not including sort/pagination
|
||||
if !includeSortPagination && jj.sort {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, jj.toSQL())
|
||||
}
|
||||
|
||||
|
||||
@@ -292,8 +292,16 @@ func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindByPath(ctx context.Context, p string) (*models.Folder, error) {
|
||||
q := qb.selectDataset().Prepared(true).Where(qb.table().Col("path").Eq(p))
|
||||
func (qb *FolderStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (*models.Folder, error) {
|
||||
// use like for case insensitive search
|
||||
var criterion exp.BooleanExpression
|
||||
if caseSensitive {
|
||||
criterion = qb.table().Col("path").Eq(p)
|
||||
} else {
|
||||
criterion = qb.table().Col("path").ILike(p)
|
||||
}
|
||||
|
||||
q := qb.selectDataset().Prepared(true).Where(criterion)
|
||||
|
||||
ret, err := qb.get(ctx, q)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -89,7 +89,7 @@ func Test_FolderStore_Create(t *testing.T) {
|
||||
assert.Equal(copy, s)
|
||||
|
||||
// ensure can find the folder
|
||||
found, err := qb.FindByPath(ctx, path)
|
||||
found, err := qb.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
t.Errorf("FolderStore.Find() error = %v", err)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func Test_FolderStore_Update(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := qb.FindByPath(ctx, path)
|
||||
s, err := qb.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
t.Errorf("FolderStore.Find() error = %v", err)
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func Test_FolderStore_FindByPath(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
got, err := qb.FindByPath(ctx, tt.path)
|
||||
got, err := qb.FindByPath(ctx, tt.path, true)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FolderStore.FindByPath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -30,12 +30,13 @@ const (
|
||||
)
|
||||
|
||||
type galleryRow struct {
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Title zero.String `db:"title"`
|
||||
Code zero.String `db:"code"`
|
||||
Date NullDate `db:"date"`
|
||||
Details zero.String `db:"details"`
|
||||
Photographer zero.String `db:"photographer"`
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Title zero.String `db:"title"`
|
||||
Code zero.String `db:"code"`
|
||||
Date NullDate `db:"date"`
|
||||
DatePrecision null.Int `db:"date_precision"`
|
||||
Details zero.String `db:"details"`
|
||||
Photographer zero.String `db:"photographer"`
|
||||
// expressed as 1-100
|
||||
Rating null.Int `db:"rating"`
|
||||
Organized bool `db:"organized"`
|
||||
@@ -50,6 +51,7 @@ func (r *galleryRow) fromGallery(o models.Gallery) {
|
||||
r.Title = zero.StringFrom(o.Title)
|
||||
r.Code = zero.StringFrom(o.Code)
|
||||
r.Date = NullDateFromDatePtr(o.Date)
|
||||
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
|
||||
r.Details = zero.StringFrom(o.Details)
|
||||
r.Photographer = zero.StringFrom(o.Photographer)
|
||||
r.Rating = intFromPtr(o.Rating)
|
||||
@@ -74,7 +76,7 @@ func (r *galleryQueryRow) resolve() *models.Gallery {
|
||||
ID: r.ID,
|
||||
Title: r.Title.String,
|
||||
Code: r.Code.String,
|
||||
Date: r.Date.DatePtr(),
|
||||
Date: r.Date.DatePtr(r.DatePrecision),
|
||||
Details: r.Details.String,
|
||||
Photographer: r.Photographer.String,
|
||||
Rating: nullIntPtr(r.Rating),
|
||||
@@ -102,7 +104,7 @@ type galleryRowRecord struct {
|
||||
func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) {
|
||||
r.setNullString("title", o.Title)
|
||||
r.setNullString("code", o.Code)
|
||||
r.setNullDate("date", o.Date)
|
||||
r.setNullDate("date", "date_precision", o.Date)
|
||||
r.setNullString("details", o.Details)
|
||||
r.setNullString("photographer", o.Photographer)
|
||||
r.setNullInt("rating", o.Rating)
|
||||
@@ -800,10 +802,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
|
||||
addFileTable := func() {
|
||||
query.addJoins(
|
||||
join{
|
||||
sort: true,
|
||||
table: galleriesFilesTable,
|
||||
onClause: "galleries_files.gallery_id = galleries.id",
|
||||
},
|
||||
join{
|
||||
sort: true,
|
||||
table: fileTable,
|
||||
onClause: "galleries_files.file_id = files.id",
|
||||
},
|
||||
@@ -813,10 +817,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
|
||||
addFolderTable := func() {
|
||||
query.addJoins(
|
||||
join{
|
||||
sort: true,
|
||||
table: folderTable,
|
||||
onClause: "folders.id = galleries.folder_id",
|
||||
},
|
||||
join{
|
||||
sort: true,
|
||||
table: folderTable,
|
||||
as: "file_folder",
|
||||
onClause: "files.parent_folder_id = file_folder.id",
|
||||
|
||||
@@ -32,11 +32,12 @@ const (
|
||||
)
|
||||
|
||||
type groupRow struct {
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Name zero.String `db:"name"`
|
||||
Aliases zero.String `db:"aliases"`
|
||||
Duration null.Int `db:"duration"`
|
||||
Date NullDate `db:"date"`
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Name zero.String `db:"name"`
|
||||
Aliases zero.String `db:"aliases"`
|
||||
Duration null.Int `db:"duration"`
|
||||
Date NullDate `db:"date"`
|
||||
DatePrecision null.Int `db:"date_precision"`
|
||||
// expressed as 1-100
|
||||
Rating null.Int `db:"rating"`
|
||||
StudioID null.Int `db:"studio_id,omitempty"`
|
||||
@@ -56,6 +57,7 @@ func (r *groupRow) fromGroup(o models.Group) {
|
||||
r.Aliases = zero.StringFrom(o.Aliases)
|
||||
r.Duration = intFromPtr(o.Duration)
|
||||
r.Date = NullDateFromDatePtr(o.Date)
|
||||
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
|
||||
r.Rating = intFromPtr(o.Rating)
|
||||
r.StudioID = intFromPtr(o.StudioID)
|
||||
r.Director = zero.StringFrom(o.Director)
|
||||
@@ -70,7 +72,7 @@ func (r *groupRow) resolve() *models.Group {
|
||||
Name: r.Name.String,
|
||||
Aliases: r.Aliases.String,
|
||||
Duration: nullIntPtr(r.Duration),
|
||||
Date: r.Date.DatePtr(),
|
||||
Date: r.Date.DatePtr(r.DatePrecision),
|
||||
Rating: nullIntPtr(r.Rating),
|
||||
StudioID: nullIntPtr(r.StudioID),
|
||||
Director: r.Director.String,
|
||||
@@ -90,7 +92,7 @@ func (r *groupRowRecord) fromPartial(o models.GroupPartial) {
|
||||
r.setNullString("name", o.Name)
|
||||
r.setNullString("aliases", o.Aliases)
|
||||
r.setNullInt("duration", o.Duration)
|
||||
r.setNullDate("date", o.Date)
|
||||
r.setNullDate("date", "date_precision", o.Date)
|
||||
r.setNullInt("rating", o.Rating)
|
||||
r.setNullInt("studio_id", o.StudioID)
|
||||
r.setNullString("director", o.Director)
|
||||
@@ -518,7 +520,7 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF
|
||||
} else {
|
||||
// this will give unexpected results if the query is not filtered by a parent group and
|
||||
// the group has multiple parents and order indexes
|
||||
query.join(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
|
||||
query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
|
||||
query.sortAndPagination += getSort("order_index", direction, groupRelationsTable)
|
||||
}
|
||||
case "tag_count":
|
||||
|
||||
@@ -34,15 +34,16 @@ type imageRow struct {
|
||||
Title zero.String `db:"title"`
|
||||
Code zero.String `db:"code"`
|
||||
// expressed as 1-100
|
||||
Rating null.Int `db:"rating"`
|
||||
Date NullDate `db:"date"`
|
||||
Details zero.String `db:"details"`
|
||||
Photographer zero.String `db:"photographer"`
|
||||
Organized bool `db:"organized"`
|
||||
OCounter int `db:"o_counter"`
|
||||
StudioID null.Int `db:"studio_id,omitempty"`
|
||||
CreatedAt Timestamp `db:"created_at"`
|
||||
UpdatedAt Timestamp `db:"updated_at"`
|
||||
Rating null.Int `db:"rating"`
|
||||
Date NullDate `db:"date"`
|
||||
DatePrecision null.Int `db:"date_precision"`
|
||||
Details zero.String `db:"details"`
|
||||
Photographer zero.String `db:"photographer"`
|
||||
Organized bool `db:"organized"`
|
||||
OCounter int `db:"o_counter"`
|
||||
StudioID null.Int `db:"studio_id,omitempty"`
|
||||
CreatedAt Timestamp `db:"created_at"`
|
||||
UpdatedAt Timestamp `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (r *imageRow) fromImage(i models.Image) {
|
||||
@@ -51,6 +52,7 @@ func (r *imageRow) fromImage(i models.Image) {
|
||||
r.Code = zero.StringFrom(i.Code)
|
||||
r.Rating = intFromPtr(i.Rating)
|
||||
r.Date = NullDateFromDatePtr(i.Date)
|
||||
r.DatePrecision = datePrecisionFromDatePtr(i.Date)
|
||||
r.Details = zero.StringFrom(i.Details)
|
||||
r.Photographer = zero.StringFrom(i.Photographer)
|
||||
r.Organized = i.Organized
|
||||
@@ -74,7 +76,7 @@ func (r *imageQueryRow) resolve() *models.Image {
|
||||
Title: r.Title.String,
|
||||
Code: r.Code.String,
|
||||
Rating: nullIntPtr(r.Rating),
|
||||
Date: r.Date.DatePtr(),
|
||||
Date: r.Date.DatePtr(r.DatePrecision),
|
||||
Details: r.Details.String,
|
||||
Photographer: r.Photographer.String,
|
||||
Organized: r.Organized,
|
||||
@@ -103,7 +105,7 @@ func (r *imageRowRecord) fromPartial(i models.ImagePartial) {
|
||||
r.setNullString("title", i.Title)
|
||||
r.setNullString("code", i.Code)
|
||||
r.setNullInt("rating", i.Rating)
|
||||
r.setNullDate("date", i.Date)
|
||||
r.setNullDate("date", "date_precision", i.Date)
|
||||
r.setNullString("details", i.Details)
|
||||
r.setNullString("photographer", i.Photographer)
|
||||
r.setBool("organized", i.Organized)
|
||||
@@ -682,6 +684,20 @@ func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *ImageStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) {
|
||||
table := qb.table()
|
||||
q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).Where(
|
||||
table.Col(studioIDColumn).Eq(studioID),
|
||||
)
|
||||
|
||||
var ret int
|
||||
if err := querySimple(ctx, q, &ret); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *ImageStore) OCount(ctx context.Context) (int, error) {
|
||||
table := qb.table()
|
||||
|
||||
@@ -951,10 +967,12 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
|
||||
addFilesJoin := func() {
|
||||
q.addJoins(
|
||||
join{
|
||||
sort: true,
|
||||
table: imagesFilesTable,
|
||||
onClause: "images_files.image_id = images.id",
|
||||
},
|
||||
join{
|
||||
sort: true,
|
||||
table: fileTable,
|
||||
onClause: "images_files.file_id = files.id",
|
||||
},
|
||||
@@ -963,6 +981,7 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
|
||||
|
||||
addFolderJoin := func() {
|
||||
q.addJoins(join{
|
||||
sort: true,
|
||||
table: folderTable,
|
||||
onClause: "files.parent_folder_id = folders.id",
|
||||
})
|
||||
|
||||
13
pkg/sqlite/migrations/75_date_precision.up.sql
Normal file
13
pkg/sqlite/migrations/75_date_precision.up.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE "scenes" ADD COLUMN "date_precision" TINYINT;
|
||||
ALTER TABLE "images" ADD COLUMN "date_precision" TINYINT;
|
||||
ALTER TABLE "galleries" ADD COLUMN "date_precision" TINYINT;
|
||||
ALTER TABLE "groups" ADD COLUMN "date_precision" TINYINT;
|
||||
ALTER TABLE "performers" ADD COLUMN "birthdate_precision" TINYINT;
|
||||
ALTER TABLE "performers" ADD COLUMN "death_date_precision" TINYINT;
|
||||
|
||||
UPDATE "scenes" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
|
||||
UPDATE "images" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
|
||||
UPDATE "galleries" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
|
||||
UPDATE "groups" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
|
||||
UPDATE "performers" SET "birthdate_precision" = 0 WHERE "birthdate" IS NOT NULL;
|
||||
UPDATE "performers" SET "death_date_precision" = 0 WHERE "death_date" IS NOT NULL;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user