Compare commits

...

95 Commits

Author SHA1 Message Date
DogmaDragon
fc1ba653cd Refactor issue templates: replace markdown files with YAML configurations for bug reports, feature requests 2025-09-24 17:51:57 +03:00
WithoutPants
823ed346c1 Add separate sidebar toggle button (#6077)
* Move sidebar toggle to right. Change icon
* Show sidebar button on selection
* Fix clicking toggle cycling visibility on smaller views
* Show more tags component when cutoff == 0
* Hide filter/filter icon buttons in certain situations
* Move sidebar toggle to left on xl viewports
2025-09-24 11:27:08 +10:00
WithoutPants
3bb771a149 Add search term filter tag to scene list filter tags (#6095)
* Add search term to filter tags on scene list page

Clicking on the tag selects all on the search term input. Clicking on the x erases it.

* Ensure clear criteria maintains consistent behaviour on other pages
* Hide search term tag when input is visible
2025-09-24 10:45:09 +10:00
WithoutPants
793a5f826e Edit filter load save (#6092)
* Add load/save buttons to edit filter dialog
* Add title to save filter dialog
* Change ExistingSavedFilterList parameters
* Add title to load/save buttons
2025-09-18 12:09:19 +10:00
WithoutPants
8012f2eb8a Add search term input to edit filter dialog (#6082) 2025-09-17 15:08:47 +10:00
WithoutPants
98716d5568 Show search field always (#6079) 2025-09-17 14:41:48 +10:00
feederbox826
edcc4e8968 Fix descender line-height (#6087) 2025-09-16 14:25:22 +10:00
Otter Bot Society
12c4e1f61c Treat images with no exif metadata as well-oriented (#6006) 2025-09-09 16:48:16 +10:00
WithoutPants
cc97e96eaa Add wall zoom functionality (#6011)
* Show zoom slider when wall view active
* Add zoom functionality to scene wall
* Add zoom functionality to image wall
* Add zoom functionality to gallery wall
* Add zoom functionality for marker wall
2025-09-09 16:45:29 +10:00
WithoutPants
b1883f3df5 Add gallery link to image lightbox (#6012) 2025-09-09 16:44:51 +10:00
gregpetersonanon
fd36c0fac7 Allow scan to continue when encountering an error (#6073) 2025-09-09 15:10:13 +10:00
feederbox826
b5b207c940 remove ruby and faraday gem (#6020) 2025-09-09 15:07:00 +10:00
feederbox826
c0ba119ebf exclude empty regex exclude (#6023) 2025-09-09 15:04:39 +10:00
feederbox826
e23bdfa204 Add media hardware key support (#6031) 2025-09-09 15:03:55 +10:00
WithoutPants
14be3c24ff Revert "Search term filter tag on scene list (#5991)" (#6003)
This reverts commit 21ee88b149.
2025-07-08 13:12:46 +10:00
WithoutPants
21ee88b149 Search term filter tag on scene list (#5991)
* Add search term to filter tags on scene list page

Clicking on the tag selects all on the search term input. Clicking on the x erases it.

* Ensure clear criteria maintains consistent behaviour on other pages
2025-07-08 10:41:33 +10:00
WithoutPants
dd9a1f5ce3 Apply filter hook to subpage sidebar filters (#5995)
* Apply filterHook to sidebar filters on subpages
* Hide studio filter in studio subpage
2025-07-08 10:41:12 +10:00
WithoutPants
694675470e Map parent studio after creation (#5996) 2025-07-08 10:40:54 +10:00
WithoutPants
642b0f2291 Add missing keybinds to scene list (#5994) 2025-07-03 14:41:06 +10:00
WithoutPants
1b3a8acab2 Bring back select all/none to scene list (#5993) 2025-07-03 13:07:37 +10:00
WithoutPants
108c0c7de5 Use /plugins instead of /plugin in testReact 2025-07-03 10:40:51 +10:00
WithoutPants
dcfb3b7d37 Throw error on re-registering component only if in prod environment (#5990)
This was causing an error when hot-reloading components, meaning that the components would not be refreshed.
2025-07-02 17:07:01 +10:00
WithoutPants
d98e9c6618 Show filter tags in scene list toolbar (#5969)
* Add filter tags to toolbar
* Show overflow control if filter tags overflow
* Remove second set of filter tags from top of page
* Add border around filter area
2025-07-02 16:34:40 +10:00
QxxxGit
f01f95ddfb Organize UIPluginApi.md docs and pluginApi.d.ts (#5971)
* Organized alphabetically and removed duplicate Setting and TabTitleCounter
* Organized components alphabetically
* Add missing PerformerDetailsPanel and PerformerDetailsPanel.DetailGroup
* Add missing SceneFileInfoPanel component
* Add missing MainNavBar.MenuItems and MainNavBar.UtilityItems in docs
2025-07-01 13:48:16 +10:00
WithoutPants
3a232b1d6c Pagination styling (#5973)
* Raise pagination slightly to avoid occlusion from link bar
* Add shadow to pagination
2025-06-30 07:53:33 +10:00
WithoutPants
6f4920cb81 Update custom css links and replace plex theme link with themes link (#5976) 2025-06-30 07:53:08 +10:00
WithoutPants
61ab6ce6bd Fix funscript parsing issues (#5978)
* Accept floating point numbers for at field in funscript
* Ignore type of script version field
* Write rounded ints for csv
2025-06-30 07:52:53 +10:00
WithoutPants
7215b6e918 Ensure tmp dir is created before creating temp file (#5977) 2025-06-30 07:52:32 +10:00
WithoutPants
bd8ec8cb83 Don't update stash ids when scraping from stash-box (#5975) 2025-06-30 07:52:12 +10:00
WithoutPants
429022a468 Handle marshalling scraped movie to group (#5974) 2025-06-30 07:51:53 +10:00
WithoutPants
5323d68d3d Add graphql playground link to tools panel (#5807)
* Add graphql playground link to tools panel
* Move to separate section
2025-06-27 16:26:03 +10:00
DogmaDragon
3af472d3b2 Fix typo in Plugins documentation for clarity (#5972) 2025-06-27 12:54:44 +10:00
WithoutPants
7eff7f02d0 Add findFolder and findFolders queries to graphql schema (#5965)
* Add findFolder and findFolders queries to graphql schema
* Add zip file criterion to file and folder queries
2025-06-26 15:48:29 +10:00
WithoutPants
661d2f64bb Make path criterion default modifier includes instead of equals (#5968) 2025-06-26 15:47:45 +10:00
WithoutPants
d0a7b09bf3 Scene list toolbar (#5938)
* Add sticky query toolbar to scenes page
* Filter button accept count instead of filter
* Add play button
* Add create button functionality. Remove new scene button from navbar
* Separate toolbar into component
* Separate sort by select component
* Don't show filter tags control if no criteria
* Add utility setter methods to ListFilterModel
* Add results header with display options
* Use css for filter tag styling
* Add className to OperationDropdown and Item
* Increase size of sidebar controls on mobile
2025-06-26 09:17:22 +10:00
DogmaDragon
27bc6c8fca Update captions documentation (#5967)
* Update captions documentation to clarify file location and naming conventions
* Clarify naming convention
2025-06-26 07:33:55 +10:00
WithoutPants
704041d5e0 Add findFiles and findFile graphql queries (#5941)
* Add findFile and findFiles
* Add parent folder and zip file fields to file graphql types
* Add parent_folder, zip_file fields to Folder graphql type
* Add format to ImageFile type
* Add format filter fields to image/video file filters
2025-06-24 13:05:17 +10:00
damontecres
8d78fd682d Include searching by tag sort name (#5963) 2025-06-24 13:02:19 +10:00
WithoutPants
81c3988777 Give bottom pagination bar transparent background (#5958) 2025-06-24 13:01:28 +10:00
WithoutPants
4b5424dd51 Update manual with new patchable components 2025-06-24 08:27:41 +10:00
dogwithakeyboard
e69238307c add missing property to death date item (#5962) 2025-06-24 07:59:27 +10:00
feederbox826
019fe81de9 Update Freeones scraper from CommunityScrapers (#5956)
1b103ad2d5

Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2025-06-23 14:13:01 +10:00
WithoutPants
5177f71dbd Fix UI crash in performer -> gallery wall lightbox (#5947) 2025-06-23 14:12:07 +10:00
WithoutPants
497146adc5 Support patching Pagination and PaginationIndex (#5957) 2025-06-23 14:11:51 +10:00
WithoutPants
f81f60e76f Show custom fields on compact expanded details (#5946) 2025-06-20 16:04:10 +10:00
WithoutPants
849a368d3d Fix ordering of tags (#5945) 2025-06-20 16:03:56 +10:00
QxxxGit
c09913a614 Add useLightbox and useGalleryLightbox in plugin api (#5936) 2025-06-20 16:03:22 +10:00
WithoutPants
c5fe6748c0 Hide list view options popover on select (#5940) 2025-06-19 16:40:06 +10:00
WithoutPants
fe9a6d87d2 Fix filtered list toolbar overflow on mobile devices (#5933)
Scenes list page is still ugly, but that will be addressed separately.
2025-06-17 12:30:28 +10:00
WithoutPants
7d692232ed Move pagination to a sticky bottom toolbar on scenes page (#5924)
* Adjust main padding to be the same as navbar height
* Add LoadedContent component for loading and error display
* Add option for pagination popup placement
* Show results summary at top only. Add sticky bottom pagination
2025-06-17 11:00:00 +10:00
WithoutPants
a145576f39 Display mode options dropdown (#5923)
* Separate ZoomSlider into own component
* Turn ListViewOptions into dropdown
Also puts zoom slider in the dropdown
* Move ZoomSlider into separate file
* Add title
* Restyle slider
2025-06-13 11:45:10 +10:00
WithoutPants
574fd680c9 Filter performers/tags/studios list by current filter (#5920) 2025-06-13 09:07:11 +10:00
QxxxGit
e95c1bbc76 Patched AlertModal, SweatDrops, TruncatedText, BackgroundImage components (#5913)
* Patched AlertModal, SweatDrops, TruncatedText
* Patch BackgroundImage component
* Inline PatchComponent calls
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-06-11 17:32:36 +10:00
Rémi Marseault
155dbc370b fix: Prevent generating invalid link on empty scraper response (#5876) 2025-06-11 17:32:11 +10:00
philMorel
60f1ee2360 feat: Add Performers tab to Group detail page (#5895)
* Feat(#1401): Show all performers from group's scenes on group detail
* Add Groups criterion to performers
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-06-11 17:07:09 +10:00
WithoutPants
3d03072da0 Error loading plugins (#5813)
* Improve error messages when unable to contact server
* Improve error message presentation
* Catch errors when configuration can't be loaded
* Use ErrorMessage in PagedList
* Add icon to error message
2025-06-11 16:54:11 +10:00
WithoutPants
ed4d17b8f0 Scene Filter sidebar (#5714)
* Add Sidebar component
* Add PerformerQuickFilter to Scene filter sidebar
* Add other quick filters
* Add confirmVariant field to AlertModal
* Add SidebarSavedFilterList
* Add sidebar toggle button
* Add data-type attr for criterion option
* Refactor LabeledIdFilter
* Move search input into sidebar
* Save sidebar state in local forage
* Add sidebar rating filter
* Add organised filter
* Open sidebar to / key. Focus search input on sidebar open
* Blur clearable input on escape key
2025-06-11 15:55:10 +10:00
smith113-p
a91b9c4d92 Slightly simplify code after PR #5894 (#5917)
The code looks like it does because it initially used string pointers; however, the version that landed used a regular string array, so we can just the = operator.
2025-06-11 11:49:47 +10:00
QxxxGit
709fdb14de Rating system patched components (#5912) 2025-06-11 11:46:05 +10:00
CJ
46b0b8cba4 Patch CustomFields Component (#5914) 2025-06-11 11:45:03 +10:00
WithoutPants
815ce7139c Add handler for /plugin/{}/assets (#5907)
This allows for React applications to be hosted in a plugins asset directory.
2025-06-03 20:35:29 +10:00
feederbox826
358193e25e Add note for macOS restriction (#5906) 2025-06-03 10:59:10 +10:00
dependabot[bot]
4aca81ad9b Bump vite from 4.5.13 to 4.5.14 in /ui/v2.5 (#5902)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.13 to 4.5.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 4.5.14
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 10:21:47 +10:00
CJ
c66ef42480 New patchable performer page components (#5897) 2025-06-03 10:16:57 +10:00
DirtyRacer1337
d9a316d083 add explorer plugin (#5882) 2025-06-03 10:13:14 +10:00
smith113-p
96d2b36a08 Submit all scene URLs to Stashbox (#5894) 2025-06-03 10:05:43 +10:00
WithoutPants
00f5d0d984 Upgrade gqlgenc (#5901)
* Update gqlgenc
* Fix type error
* Fix package names in config
* Remove override and regenerate
* Update compiler and bump version
2025-06-03 08:55:51 +10:00
QxxxGit
044ed2708f Gallery card patched component (#5880)
* Gallery card patched component
* Define in pluginApi.d.ts
2025-06-02 17:20:34 +10:00
WithoutPants
8e697b50eb Revamp scene and marker wall views (#5816)
* Use gallery for scene wall
* Move into separate file
* Remove unnecessary class names
* Apply configuration
* Reuse styling
* Add Scene Marker wall panel
* Adjust target row height
2025-06-02 17:18:36 +10:00
DogmaDragon
5ea4c507b2 docs: Update scraper objects (#5794) 2025-06-02 17:16:42 +10:00
dogwithakeyboard
10d4fcce8d Add zoomIndex to gallery card (#5844) 2025-06-02 17:15:23 +10:00
Rémi Marseault
86848e7d70 feat(onDelete): Redirect to previous page to preserve filters (#5818) 2025-06-02 17:12:17 +10:00
dependabot[bot]
91ac2833f5 Bump vite from 4.5.11 to 4.5.13 in /ui/v2.5 (#5847)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.11 to 4.5.13.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.13/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.13/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 4.5.13
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 17:10:50 +10:00
DogmaDragon
8ecbf4f7e4 Update tripwire link to direct to the forum (#5885) 2025-06-02 17:07:55 +10:00
smith113-p
0bd4edd9f4 Use StashIDPill to show stash IDs in the tagger view (#5879)
* Use StashIDPill to show stash IDs in the tagger view

This is visually nicer, but more importantly, lets you see easily which stash-boxes are already associated with this scene.

* Move into separate component. Add key
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-06-02 17:07:24 +10:00
Maista
af34829f38 Stash box validation bugfix (#5831)
* Remove accidental copypaste error

The apiKey ref was accidentally associated with the max_requests_per_minute field which made the "Test Credentials" button error out every time

* Fix error messages in stash-box validation

The message from err.Error() can start with any number of errors like NetworkError
so we can check for substrings instead
2025-06-02 15:47:03 +10:00
DogmaDragon
155c4ec72a docs: Add note on Chrome 136 requirements for remote debugging (#5884) 2025-05-23 16:07:15 +03:00
DogmaDragon
26fe812be4 Remove matrix references + add community forum (#5853) 2025-05-01 11:31:56 +03:00
DogmaDragon
997e9bfa52 Update runner image (#5846) 2025-04-28 10:34:54 +03:00
DogmaDragon
d0ece86bb8 Update markdown syntax for Scrapers Development page (#5829)
* Update markdown syntax for Scrapers Development

* Fix typo
2025-04-16 08:56:21 +10:00
WithoutPants
62d7076ff3 Add missing group scraper fields (#5820) 2025-04-16 08:55:27 +10:00
WithoutPants
f9fb33e8cc Fix scene gallery viewer displaying incorrect image 2025-04-09 12:30:04 +10:00
mz28k
2375bc6cac Fix to find a match for a parent studio (#5810) 2025-04-07 14:58:59 +10:00
WithoutPants
87d01e86fd Fix range marker alignment (#5804) 2025-04-04 15:32:52 +11:00
feederbox826
e774706f43 update Dockerfile-CUDA (#5689) 2025-04-04 14:47:22 +11:00
WithoutPants
8efae13afb Revert dot marker styling changes (#5801)
* Move marker css into styles

Removes vjs-marker-dot styling, using existing vjs-marker class instead

* Revert dot marker changes
2025-04-02 16:13:54 +11:00
WithoutPants
6ed66f3275 Ignore missing scenes when submitting fingerprints (#5799) 2025-04-02 15:48:07 +11:00
WithoutPants
2eb7bde95a Fix marker preview deleted when modifying marker with duration (#5800) 2025-04-02 14:57:24 +11:00
luigi611
edbd9b69eb Partial fix for #2761 - Add reverse proxy prefix to HLS links (#5791)
* Partial fix for #2761 - Add reverse proxy prefix to HLS links
---------
Co-authored-by: Guido <guido@test.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-04-02 14:26:39 +11:00
its-josh4
db06eae7cb Sort tags by name while scraping or merging scenes (#5752)
* Sort tags by name while scraping scenes
* TagStore.All should sort by sort_name first
* Sort tag by sort name/name in TagIDSelect
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-04-02 14:26:14 +11:00
dependabot[bot]
0f2bc3e01d Bump vite from 4.5.6 to 4.5.11 in /ui/v2.5 (#5797)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.6 to 4.5.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.11/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 14:25:28 +11:00
WithoutPants
ffee4df8b7 Log to stdout on exit error (#5798) 2025-04-02 14:25:11 +11:00
dependabot[bot]
2d5160c576 Bump @babel/runtime from 7.21.0 to 7.27.0 in /ui/v2.5 (#5766)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.21.0 to 7.27.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 13:28:50 +11:00
blaspheme-ship-it
3489dca83a Display tag and performer image on hover. on the scene edit page (#5739)
* add component for PerformerPopover
* show PerformerPopover for performer select values
* show TagPopover for tag select values
2025-04-02 13:27:35 +11:00
WithoutPants
1d3bc40a6b Import/export bug fixes (#5780)
* Include parent tags in export if including dependencies
* Handle uniqueness when sanitising filenames
2025-04-01 15:04:26 +11:00
210 changed files with 10463 additions and 2246 deletions

View File

@@ -1,40 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug Report] Short Form Subject (50 Chars or less)"
labels: bug report
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem please ensure that your screenshots are SFW or at least appropriately censored.
**Stash Version: (from Settings -> About):**
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Bug Report
description: Create a report to help us fix the bug
labels: ["bug report"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: description
attributes:
label: Describe the bug
description: Provide a clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Detail the steps that would replicate this issue.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: Provide clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: context
attributes:
label: Screenshots or additional context
description: Provide any additional context and SFW screenshots here to help us solve this issue.
validations:
required: false
- type: input
id: stashversion
attributes:
label: Stash version
description: This can be found in Settings > About.
placeholder: (e.g. v0.28.1)
validations:
required: true
- type: input
id: devicedetails
attributes:
label: Device details
description: |
If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue.
placeholder: (e.g. Firefox 97 (64-bit) on Windows 11)
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Community forum
url: https://discourse.stashapp.cc
about: Start a discussion on the community forum.
- name: Community Discord
url: https://discord.gg/Y8MNsvQBvZ
about: Chat with the community on Discord.
- name: Documentation
url: https://docs.stashapp.cc
about: Check out documentation for help and information.

View File

@@ -1,24 +0,0 @@
---
name: Discussion / Request for Commentary [RFC]
about: This is for issues that will be discussed and won't necessarily result directly
in commits or pull requests.
title: "[RFC] Short Form Title"
labels: help wanted
assignees: ''
---
<!-- Update or delete the title if you need to delegate your title gore to something
# Title
*### Scope*
<!-- describe the scope of your topic and your goals ideally within a single paragraph or TL;DR kind of summary so its easier for people to determine if they can contribute at a glance. -->
## Long Form
<!-- Only required if your scope and titles can't cover everything. -->
## Examples
<!-- if you can show a picture or video examples post them here, please ensure that you respect people's time and attention and understand that people are volunteering their time, so concision is ideal and considerate. -->
## Reference Reading
<!-- if there is any reference reading or documentation, please refer to it here. -->

View File

@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature] Short Form Title (50 chars or less.)"
labels: feature request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,44 @@
name: Feature Request
description: Request a new feature or idea to be added to Stash
labels: ["feature request"]
body:
- type: textarea
id: description
attributes:
label: Describe the feature you'd like
description: Provide a clear description of the feature you'd like implemented
validations:
required: true
- type: textarea
id: benefits
attributes:
label: Describe the benefits this would bring to existing users
description: |
Explain the measurable benefits this feature would achieve for existing users.
The benefits should be described in terms of outcomes for users, not specific implementations.
validations:
required: true
- type: textarea
id: already_possible
attributes:
label: Is there an existing way to achieve this goal?
description: |
Yes/No. If Yes, describe how your proposed feature differs from or improves upon the current method
validations:
required: true
- type: checkboxes
id: confirm-search
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/stashapp/stash/issues?q=is%3Aissue) for any existing issues that cover the core request or benefit of your proposal.
options:
- label: I have searched for existing issues and none cover the core request of my proposal
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

View File

@@ -12,11 +12,11 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: stashapp/compiler:10
COMPILER_IMAGE: stashapp/compiler:11
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2

View File

@@ -9,7 +9,7 @@ on:
pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:10
COMPILER_IMAGE: stashapp/compiler:11
jobs:
golangci:

View File

@@ -1,14 +1,12 @@
model:
package: graphql
filename: ./pkg/scraper/stashbox/graphql/generated_models.go
filename: ./pkg/stashbox/graphql/generated_models.go
client:
package: graphql
filename: ./pkg/scraper/stashbox/graphql/generated_client.go
filename: ./pkg/stashbox/graphql/generated_client.go
models:
Date:
model: github.com/99designs/gqlgen/graphql.String
SceneDraftInput:
model: github.com/stashapp/stash/pkg/scraper/stashbox/graphql.SceneDraftInput
endpoint:
# This points to stashdb.org currently, but can be directed at any stash-box
# instance. It is used for generation only.

View File

@@ -5,7 +5,6 @@
[![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp)
[![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp)
[![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash)
[![Matrix](https://img.shields.io/matrix/stashapp:unredacted.org?logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#stashapp:unredacted.org)
[![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)
@@ -29,6 +28,11 @@ For further information you can consult the [documentation](https://docs.stashap
As of version 0.27.0, Stash doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._
Windows 10 or Server 2016 are at least required.
#### Mac Users:
As of version 0.29.0, Stash requires at least _macOS 11 Big Sur._
Stash can still be ran through docker on older versions of macOS
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---:
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
@@ -68,19 +72,24 @@ Stash is available in 32 languages (so far!) and it could be in your language to
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)
## Join Our Community
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.
# Support (FAQ)
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 the [Matrix space](https://matrix.to/#/#stashapp:unredacted.org)
* Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support.
* 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).

View File

@@ -152,6 +152,9 @@ func recoverPanic() {
func exitError(err error) {
exitCode = 1
logger.Error(err)
// #5784 - log to stdout as well as the logger
// this does mean that it will log twice if the logger is set to stdout
fmt.Println(err)
if desktop.IsDesktop() {
desktop.FatalError(err)
}

View File

@@ -16,7 +16,7 @@ ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend
FROM golang:1.22.8-alpine AS backend
FROM golang:1.24.3-alpine AS backend
RUN apk add --no-cache make alpine-sdk
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/

View File

@@ -1,4 +1,5 @@
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
ARG CUDA_VERSION=12.8.0
# Build Frontend
FROM node:20-alpine AS frontend
@@ -16,7 +17,7 @@ ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend
FROM golang:1.22.8-bullseye AS backend
FROM golang:1.24.3-bullseye AS backend
RUN apt update && apt install -y build-essential golang
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
@@ -34,19 +35,26 @@ ARG STASH_VERSION
RUN make flags-release flags-pie stash
# Final Runnable Image
FROM nvidia/cuda:12.0.1-base-ubuntu22.04
RUN apt update && apt upgrade -y && apt install -y ca-certificates libvips-tools ffmpeg wget intel-media-va-driver-non-free vainfo
RUN rm -rf /var/lib/apt/lists/*
COPY --from=backend /stash/stash /usr/bin/
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu24.04
RUN apt update && apt upgrade -y && apt install -y \
# stash dependencies
ca-certificates libvips-tools ffmpeg \
# intel dependencies
intel-media-va-driver-non-free vainfo \
# python tools
python3 python3-pip && \
# cleanup
apt autoremove -y && apt clean && \
rm -rf /var/lib/apt/lists/*
COPY --from=backend --chmod=555 /stash/stash /usr/bin/
# NVENC Patch
RUN mkdir -p /usr/local/bin /patched-lib
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh -O /usr/local/bin/patch.sh
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh -O /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/patch.sh /usr/local/bin/docker-entrypoint.sh /usr/bin/stash
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh /usr/local/bin/patch.sh
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
ENV LANG C.UTF-8
ENV NVIDIA_VISIBLE_DEVICES all
ENV LANG=C.UTF-8
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=video,utility
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999

View File

@@ -12,9 +12,8 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
FROM --platform=$TARGETPLATFORM alpine:latest AS app
COPY --from=binary /stash /usr/bin/
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata vips vips-tools \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools \
&& gem install faraday
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
# Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys

View File

@@ -1,4 +1,4 @@
FROM golang:1.22.8
FROM golang:1.24.3
LABEL maintainer="https://discord.gg/2TsNFKt"

View File

@@ -1,6 +1,6 @@
user=stashapp
repo=compiler
version=10
version=11
latest:
docker build -t ${user}/${repo}:latest .

36
go.mod
View File

@@ -1,11 +1,11 @@
module github.com/stashapp/stash
go 1.22.8
go 1.24.3
require (
github.com/99designs/gqlgen v0.17.55
github.com/99designs/gqlgen v0.17.73
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
github.com/Yamashou/gqlgenc v0.25.3
github.com/Yamashou/gqlgenc v0.32.1
github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.0
github.com/asticode/go-astisub v0.25.1
@@ -43,40 +43,42 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.16.0
github.com/vearutop/statigz v1.4.0
github.com/vektah/dataloaden v0.3.0
github.com/vektah/gqlparser/v2 v2.5.18
github.com/vektah/gqlparser/v2 v2.5.27
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.31.0
golang.org/x/crypto v0.38.0
golang.org/x/image v0.18.0
golang.org/x/net v0.33.0
golang.org/x/sys v0.28.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
golang.org/x/net v0.40.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/agnivade/levenshtein v1.2.0 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antchfx/xpath v1.2.3 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -86,7 +88,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -109,12 +111,12 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/urfave/cli/v2 v2.27.5 // indirect
github.com/urfave/cli/v2 v2.27.6 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/tools v0.26.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/tools v0.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

79
go.sum
View File

@@ -51,23 +51,23 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/99designs/gqlgen v0.17.55 h1:3vzrNWYyzSZjGDFo68e5j9sSauLxfKvLp+6ioRokVtM=
github.com/99designs/gqlgen v0.17.55/go.mod h1:3Bq768f8hgVPGZxL8aY9MaYmbxa6llPM/qu1IGH1EJo=
github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=
github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.9.3 h1:mpJr/ikUA9/GNJB/DBZcGeFDXUtosHRyRrwh7KGdTG0=
github.com/PuerkitoBio/goquery v1.9.3/go.mod h1:1ndLHPdTz+DyQPICCWYlYQMPl0oXZj0G6D4LCYA6u4U=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc=
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8=
github.com/Yamashou/gqlgenc v0.25.3 h1:mVV8/Ho8EDZUQKQZbQqqXGNq8jc8aQfPpHhZOnTkMNE=
github.com/Yamashou/gqlgenc v0.25.3/go.mod h1:G0g1N81xpIklVdnyboW1zwOHcj/n4hNfhTwfN29Rjig=
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/Yamashou/gqlgenc v0.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ=
github.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -84,8 +84,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
@@ -162,8 +162,8 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9M
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -236,6 +236,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@@ -243,6 +245,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -302,8 +306,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -468,8 +472,9 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
@@ -638,8 +643,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -652,14 +657,14 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84=
github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.5.18 h1:zSND3GtutylAQ1JpWnTHcqtaRZjl+y3NROeW8vuNo6Y=
github.com/vektah/gqlparser/v2 v2.5.18/go.mod h1:6HLzf7JKv9Fi3APymudztFQNmLXR5qJeEo6BOFcXVfc=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks=
github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
@@ -713,8 +718,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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -756,8 +761,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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -807,8 +812,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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -838,8 +843,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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -930,13 +935,13 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -949,8 +954,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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1015,8 +1020,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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -35,6 +35,8 @@ models:
model: github.com/stashapp/stash/internal/api.BoolMap
PluginConfigMap:
model: github.com/stashapp/stash/internal/api.PluginConfigMap
File:
model: github.com/stashapp/stash/internal/api.File
VideoFile:
fields:
# override float fields - #1572

View File

@@ -6,6 +6,26 @@ type Query {
findDefaultFilter(mode: FilterMode!): SavedFilter
@deprecated(reason: "default filter now stored in UI config")
"Find a file by its id or path"
findFile(id: ID, path: String): BaseFile!
"Queries for Files"
findFiles(
file_filter: FileFilterType
filter: FindFilterType
ids: [ID!]
): FindFilesResultType!
"Find a file by its id or path"
findFolder(id: ID, path: String): Folder!
"Queries for Files"
findFolders(
folder_filter: FolderFilterType
filter: FindFilterType
ids: [ID!]
): FindFoldersResultType!
"Find a scene by ID or Checksum"
findScene(id: ID, checksum: String): Scene
findSceneByHash(input: SceneHashInput!): Scene

View File

@@ -7,8 +7,11 @@ type Folder {
id: ID!
path: String!
parent_folder_id: ID
zip_file_id: ID
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder
zip_file: BasicFile
mod_time: Time!
@@ -21,8 +24,32 @@ interface BaseFile {
path: String!
basename: String!
parent_folder_id: ID!
zip_file_id: ID
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
mod_time: Time!
size: Int64!
fingerprint(type: String!): String
fingerprints: [Fingerprint!]!
created_at: Time!
updated_at: Time!
}
type BasicFile implements BaseFile {
id: ID!
path: String!
basename: String!
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
mod_time: Time!
size: Int64!
@@ -39,8 +66,11 @@ type VideoFile implements BaseFile {
path: String!
basename: String!
parent_folder_id: ID!
zip_file_id: ID
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
mod_time: Time!
size: Int64!
@@ -66,8 +96,11 @@ type ImageFile implements BaseFile {
path: String!
basename: String!
parent_folder_id: ID!
zip_file_id: ID
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
mod_time: Time!
size: Int64!
@@ -75,6 +108,7 @@ type ImageFile implements BaseFile {
fingerprint(type: String!): String
fingerprints: [Fingerprint!]!
format: String!
width: Int!
height: Int!
@@ -89,8 +123,11 @@ type GalleryFile implements BaseFile {
path: String!
basename: String!
parent_folder_id: ID!
zip_file_id: ID
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
mod_time: Time!
size: Int64!
@@ -125,3 +162,22 @@ input FileSetFingerprintsInput {
"only supplied fingerprint types will be modified"
fingerprints: [SetFingerprintsInput!]!
}
type FindFilesResultType {
count: Int!
"Total megapixels of any image files"
megapixels: Float!
"Total duration in seconds of any video files"
duration: Float!
"Total file size in bytes"
size: Int!
files: [BaseFile!]!
}
type FindFoldersResultType {
count: Int!
folders: [Folder!]!
}

View File

@@ -168,6 +168,8 @@ input PerformerFilterType {
death_year: IntCriterionInput
"Filter by studios where performer appears in scene/image/gallery"
studios: HierarchicalMultiCriterionInput
"Filter by groups where performer appears in scene"
groups: HierarchicalMultiCriterionInput
"Filter by performers where performer appears with another performer in scene/image/gallery"
performers: MultiCriterionInput
"Filter by autotag ignore value"
@@ -679,6 +681,104 @@ input ImageFilterType {
tags_filter: TagFilterType
}
input FileFilterType {
AND: FileFilterType
OR: FileFilterType
NOT: FileFilterType
path: StringCriterionInput
basename: StringCriterionInput
dir: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput
"Filter by modification time"
mod_time: TimestampCriterionInput
"Filter files that have an exact match available"
duplicated: PHashDuplicationCriterionInput
"find files based on hash"
hashes: [FingerprintFilterInput!]
video_file_filter: VideoFileFilterInput
image_file_filter: ImageFileFilterInput
scene_count: IntCriterionInput
image_count: IntCriterionInput
gallery_count: IntCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"
images_filter: ImageFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
}
input FolderFilterType {
AND: FolderFilterType
OR: FolderFilterType
NOT: FolderFilterType
path: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput
"Filter by modification time"
mod_time: TimestampCriterionInput
gallery_count: IntCriterionInput
"Filter by files that meet this criteria"
files_filter: FileFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
}
input VideoFileFilterInput {
resolution: ResolutionCriterionInput
orientation: OrientationCriterionInput
framerate: IntCriterionInput
bitrate: IntCriterionInput
format: StringCriterionInput
video_codec: StringCriterionInput
audio_codec: StringCriterionInput
"in seconds"
duration: IntCriterionInput
captions: StringCriterionInput
interactive: Boolean
interactive_speed: IntCriterionInput
}
input ImageFileFilterInput {
format: StringCriterionInput
resolution: ResolutionCriterionInput
orientation: OrientationCriterionInput
}
input FingerprintFilterInput {
type: String!
value: String!
"Hamming distance - defaults to 0"
distance: Int
}
enum CriterionModifier {
"="
EQUALS

View File

@@ -27,6 +27,7 @@ type Group {
front_image_path: String # Resolver
back_image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver
performer_count(depth: Int): Int! # Resolver
sub_group_count(depth: Int): Int! # Resolver
scenes: [Scene!]!
}

View File

@@ -16,12 +16,12 @@ import (
const (
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
"More information and fixes are available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
"More information and fixes are available at https://discourse.stashapp.cc/t/-/1658"
externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " +
"This is extremely dangerous! The whole world can see your your stash page and browse your files! " +
"Stash is not answering any other requests to protect your privacy. " +
"Please read the log entry or visit https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
"Please read the log entry or visit https://discourse.stashapp.cc/t/-/1658"
)
func allowUnauthenticated(r *http.Request) bool {

23
internal/api/fields.go Normal file
View File

@@ -0,0 +1,23 @@
package api
import (
"context"
"github.com/99designs/gqlgen/graphql"
)
type queryFields []string
func collectQueryFields(ctx context.Context) queryFields {
fields := graphql.CollectAllFields(ctx)
return queryFields(fields)
}
func (f queryFields) Has(field string) bool {
for _, v := range f {
if v == field {
return true
}
}
return false
}

View File

@@ -10,6 +10,7 @@
//go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
@@ -62,6 +63,7 @@ type Loaders struct {
TagByID *TagLoader
GroupByID *GroupLoader
FileByID *FileLoader
FolderByID *FolderLoader
}
type Middleware struct {
@@ -117,6 +119,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchFiles(ctx),
},
FolderByID: &FolderLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFolders(ctx),
},
SceneFiles: &SceneFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
@@ -279,6 +286,17 @@ func (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) (
}
}
func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) {
return func(keys []models.FolderID) (ret []*models.Folder, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.FindMany(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
return func(keys []int) (ret [][]models.FileID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -0,0 +1,224 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// FolderLoaderConfig captures the config to create a new FolderLoader
type FolderLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []models.FolderID) ([]*models.Folder, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch
func NewFolderLoader(config FolderLoaderConfig) *FolderLoader {
return &FolderLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// FolderLoader batches and caches requests
type FolderLoader struct {
// this method provides the data for the loader
fetch func(keys []models.FolderID) ([]*models.Folder, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[models.FolderID]*models.Folder
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *folderLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type folderLoaderBatch struct {
keys []models.FolderID
data []*models.Folder
error []error
closing bool
done chan struct{}
}
// Load a Folder by key, batching and caching will be applied automatically
func (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a Folder.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (*models.Folder, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &folderLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (*models.Folder, error) {
<-batch.done
var data *models.Folder
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) {
results := make([]func() (*models.Folder, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
folders := make([]*models.Folder, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folders[i], errors[i] = thunk()
}
return folders, errors
}
// LoadAllThunk returns a function that when called will block waiting for a Folders.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) {
results := make([]func() (*models.Folder, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]*models.Folder, []error) {
folders := make([]*models.Folder, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folders[i], errors[i] = thunk()
}
return folders, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := *value
l.unsafeSet(key, &cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *FolderLoader) Clear(key models.FolderID) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) {
if l.cache == nil {
l.cache = map[models.FolderID]*models.Folder{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *folderLoaderBatch) startTimer(l *FolderLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *folderLoaderBatch) end(l *FolderLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
type BaseFile interface {
@@ -27,6 +28,29 @@ func convertVisualFile(f models.File) (VisualFile, error) {
}
}
func convertBaseFile(f models.File) BaseFile {
if f == nil {
return nil
}
switch f := f.(type) {
case BaseFile:
return f
case *models.VideoFile:
return &VideoFile{VideoFile: f}
case *models.ImageFile:
return &ImageFile{ImageFile: f}
case *models.BaseFile:
return &BasicFile{BaseFile: f}
default:
panic("unknown file type")
}
}
func convertBaseFiles(files []models.File) []BaseFile {
return sliceutil.Map(files, convertBaseFile)
}
type GalleryFile struct {
*models.BaseFile
}
@@ -62,3 +86,15 @@ func (ImageFile) IsVisualFile() {}
func (f *ImageFile) Fingerprints() []models.Fingerprint {
return f.ImageFile.Fingerprints
}
type BasicFile struct {
*models.BaseFile
}
func (BasicFile) IsBaseFile() {}
func (BasicFile) IsVisualFile() {}
func (f *BasicFile) Fingerprints() []models.Fingerprint {
return f.BaseFile.Fingerprints
}

View File

@@ -95,6 +95,12 @@ func (r *Resolver) VideoFile() VideoFileResolver {
func (r *Resolver) ImageFile() ImageFileResolver {
return &imageFileResolver{r}
}
func (r *Resolver) BasicFile() BasicFileResolver {
return &basicFileResolver{r}
}
func (r *Resolver) Folder() FolderResolver {
return &folderResolver{r}
}
func (r *Resolver) SavedFilter() SavedFilterResolver {
return &savedFilterResolver{r}
}
@@ -125,6 +131,8 @@ type tagResolver struct{ *Resolver }
type galleryFileResolver struct{ *Resolver }
type videoFileResolver struct{ *Resolver }
type imageFileResolver struct{ *Resolver }
type basicFileResolver struct{ *Resolver }
type folderResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver }
type pluginResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }

View File

@@ -1,30 +1,80 @@
package api
import "context"
import (
"context"
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
fp := obj.BaseFile.Fingerprints.For(type_)
if fp != nil {
v := fp.Value()
return &v, nil
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/pkg/models"
)
func fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) {
fingerprint := fp.For(type_)
if fingerprint != nil {
value := fingerprint.Value()
return &value, nil
}
return nil, nil
}
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
}
func (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) {
fp := obj.ImageFile.Fingerprints.For(type_)
if fp != nil {
v := fp.Value()
return &v, nil
}
return nil, nil
return fingerprintResolver(obj.ImageFile.Fingerprints, type_)
}
func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) {
fp := obj.VideoFile.Fingerprints.For(type_)
if fp != nil {
v := fp.Value()
return &v, nil
}
return nil, nil
return fingerprintResolver(obj.VideoFile.Fingerprints, type_)
}
func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) {
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
}
func (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) {
if zipFileID == nil {
return nil, nil
}
f, err := loaders.From(ctx).FileByID.Load(*zipFileID)
if err != nil {
return nil, err
}
return &BasicFile{
BaseFile: f.Base(),
}, nil
}
func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}
func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}
func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}
func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"context"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/pkg/models"
)
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
if obj.ParentFolderID == nil {
return nil, nil
}
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
}
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scene"
)
@@ -181,6 +182,17 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth
return ret, nil
}
func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error

View File

@@ -694,6 +694,13 @@ func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error {
return nil
}
func float64OrZero(f *float64) float64 {
if f == nil {
return 0
}
return *f
}
func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) {
markerID, err := strconv.Atoi(input.ID)
if err != nil {
@@ -784,7 +791,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
}
// remove the marker preview if the scene changed or if the timestamp was changed
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != newMarker.EndSeconds {
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || float64OrZero(existingMarker.EndSeconds) != float64OrZero(newMarker.EndSeconds) {
seconds := int(existingMarker.Seconds)
if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil {
return err

View File

@@ -29,7 +29,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
var scenes []*models.Scene
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
scenes, err = r.sceneService.FindMany(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
return err
}); err != nil {
return false, err

View File

@@ -249,18 +249,19 @@ func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input c
if valid {
status = fmt.Sprintf("Successfully authenticated as %s", user.Me.Name)
} else {
errorStr := strings.ToLower(err.Error())
switch {
case strings.Contains(strings.ToLower(err.Error()), "doctype"):
case strings.Contains(errorStr, "doctype"):
// Index file returned rather than graphql
status = "Invalid endpoint"
case strings.Contains(err.Error(), "request failed"):
case strings.Contains(errorStr, "request failed"):
status = "No response from server"
case strings.HasPrefix(err.Error(), "invalid character") ||
strings.HasPrefix(err.Error(), "illegal base64 data") ||
err.Error() == "unexpected end of JSON input" ||
err.Error() == "token contains an invalid number of segments":
case strings.Contains(errorStr, "invalid character") ||
strings.Contains(errorStr, "illegal base64 data") ||
strings.Contains(errorStr, "unexpected end of json input") ||
strings.Contains(errorStr, "token contains an invalid number of segments"):
status = "Malformed API key."
case err.Error() == "" || err.Error() == "signature is invalid":
case strings.Contains(errorStr, "signature is invalid"):
status = "Invalid or expired API key."
default:
status = fmt.Sprintf("Unknown error: %s", err)

View File

@@ -0,0 +1,120 @@
package api
import (
"context"
"errors"
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string) (BaseFile, error) {
var ret models.File
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.File
var err error
switch {
case id != nil:
idInt, err := strconv.Atoi(*id)
if err != nil {
return err
}
var files []models.File
files, err = qb.Find(ctx, models.FileID(idInt))
if err != nil {
return err
}
if len(files) > 0 {
ret = files[0]
}
case path != nil:
ret, err = qb.FindByPath(ctx, *path)
if err == nil && ret == nil {
return errors.New("file not found")
}
default:
return errors.New("either id or path must be provided")
}
return err
}); err != nil {
return nil, err
}
return convertBaseFile(ret), nil
}
func (r *queryResolver) FindFiles(
ctx context.Context,
fileFilter *models.FileFilterType,
filter *models.FindFilterType,
ids []string,
) (ret *FindFilesResultType, err error) {
var fileIDs []models.FileID
if len(ids) > 0 {
fileIDsInt, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
fileIDs = models.FileIDsFromInts(fileIDsInt)
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var files []models.File
var err error
fields := collectQueryFields(ctx)
result := &models.FileQueryResult{}
if len(fileIDs) > 0 {
files, err = r.repository.File.Find(ctx, fileIDs...)
if err == nil {
result.Count = len(files)
for _, f := range files {
if asVideo, ok := f.(*models.VideoFile); ok {
result.TotalDuration += asVideo.Duration
}
if asImage, ok := f.(*models.ImageFile); ok {
result.Megapixels += asImage.Megapixels()
}
result.TotalSize += f.Base().Size
}
}
} else {
result, err = r.repository.File.Query(ctx, models.FileQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: fields.Has("count"),
},
FileFilter: fileFilter,
TotalDuration: fields.Has("duration"),
Megapixels: fields.Has("megapixels"),
TotalSize: fields.Has("size"),
})
if err == nil {
files, err = result.Resolve(ctx)
}
}
if err != nil {
return err
}
ret = &FindFilesResultType{
Count: result.Count,
Files: convertBaseFiles(files),
Duration: result.TotalDuration,
Megapixels: result.Megapixels,
Size: int(result.TotalSize),
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -0,0 +1,100 @@
package api
import (
"context"
"errors"
"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) {
var ret *models.Folder
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Folder
var err error
switch {
case id != nil:
idInt, err := strconv.Atoi(*id)
if err != nil {
return err
}
ret, err = qb.Find(ctx, models.FolderID(idInt))
if err != nil {
return err
}
case path != nil:
ret, err = qb.FindByPath(ctx, *path)
if err == nil && ret == nil {
return errors.New("folder not found")
}
default:
return errors.New("either id or path must be provided")
}
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *queryResolver) FindFolders(
ctx context.Context,
folderFilter *models.FolderFilterType,
filter *models.FindFilterType,
ids []string,
) (ret *FindFoldersResultType, err error) {
var folderIDs []models.FolderID
if len(ids) > 0 {
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
folderIDs = models.FolderIDsFromInts(folderIDsInt)
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var folders []*models.Folder
var err error
fields := collectQueryFields(ctx)
result := &models.FolderQueryResult{}
if len(folderIDs) > 0 {
folders, err = r.repository.Folder.FindMany(ctx, folderIDs)
if err == nil {
result.Count = len(folders)
}
} else {
result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: fields.Has("count"),
},
FolderFilter: folderFilter,
})
if err == nil {
folders, err = result.Resolve(ctx)
}
}
if err != nil {
return err
}
ret = &FindFoldersResultType{
Count: result.Count,
Folders: folders,
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -62,7 +62,11 @@ func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilte
func (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.All(ctx)
return err
if err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/stashapp/stash/pkg/match"
@@ -100,12 +101,12 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
}
func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models.ScrapedGroup, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie)
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGroup)
if err != nil {
return nil, err
}
ret, err := marshalScrapedMovie(content)
ret, err := marshalScrapedGroup(content)
if err != nil {
return nil, err
}
@@ -207,6 +208,10 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
return nil, fmt.Errorf("%w: scraper_id or stash_box_index must be set", ErrInput)
}
for i := range ret {
slices.SortFunc(ret[i].Tags, models.ScrapedTagSortFunction)
}
return ret, nil
}

View File

@@ -20,6 +20,7 @@ func (rs pluginRoutes) Routes() chi.Router {
r.Route("/{pluginId}", func(r chi.Router) {
r.Use(rs.PluginCtx)
r.Get("/assets", rs.Assets)
r.Get("/assets/*", rs.Assets)
r.Get("/javascript", rs.Javascript)
r.Get("/css", rs.CSS)

View File

@@ -113,7 +113,37 @@ func marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMo
case models.ScrapedMovie:
ret = append(ret, &m)
default:
return nil, fmt.Errorf("%w: cannot turn ScrapedConetnt into ScrapedMovie", models.ErrConversion)
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedMovie", models.ErrConversion)
}
}
return ret, nil
}
// marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion
// fails, an error is returned.
func marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGroup, error) {
var ret []*models.ScrapedGroup
for _, c := range content {
if c == nil {
// graphql schema requires groups to be non-nil
continue
}
switch m := c.(type) {
case *models.ScrapedGroup:
ret = append(ret, m)
case models.ScrapedGroup:
ret = append(ret, &m)
// it's possible that a scraper returns models.ScrapedMovie
case *models.ScrapedMovie:
g := m.ScrapedGroup()
ret = append(ret, &g)
case models.ScrapedMovie:
g := m.ScrapedGroup()
ret = append(ret, &g)
default:
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGroup", models.ErrConversion)
}
}
@@ -169,3 +199,13 @@ func marshalScrapedMovie(content scraper.ScrapedContent) (*models.ScrapedMovie,
return m[0], nil
}
// marshalScrapedMovie will marshal a single scraped movie
func marshalScrapedGroup(content scraper.ScrapedContent) (*models.ScrapedGroup, error) {
m, err := marshalScrapedGroups([]scraper.ScrapedContent{content})
if err != nil {
return nil, err
}
return m[0], nil
}

View File

@@ -207,7 +207,7 @@ func Initialize() (*Server, error) {
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
endpoint := getProxyPrefix(r) + gqlEndpoint
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
gqlPlayground.Handler("GraphQL playground", endpoint, gqlPlayground.WithGraphiqlEnablePluginExplorer(true))(w, r)
})
r.Mount("/performer", server.getPerformerRoutes())

View File

@@ -1534,7 +1534,7 @@ func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
}
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
// See https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet
// See https://discourse.stashapp.cc/t/-/1658
func (i *Config) GetDangerousAllowPublicWithoutAuth() bool {
return i.getBool(dangerousAllowPublicWithoutAuth)
}

View File

@@ -60,6 +60,10 @@ func generateRegexps(patterns []string) []*regexp.Regexp {
var fileRegexps []*regexp.Regexp
for _, pattern := range patterns {
if pattern == "" || pattern == " " {
logger.Warnf("Skipping empty exclude pattern")
continue
}
if !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}

View File

@@ -28,7 +28,8 @@ type InteractiveHeatmapSpeedGenerator struct {
type Script struct {
// Version of Launchscript
Version string `json:"version"`
// #5600 - ignore version, don't validate type
Version json.RawMessage `json:"version"`
// Inverted causes up and down movement to be flipped.
Inverted bool `json:"inverted,omitempty"`
// Range is the percentage of a full stroke to use.
@@ -40,7 +41,7 @@ type Script struct {
// Action is a move at a specific time.
type Action struct {
// At time in milliseconds the action should fire.
At int64 `json:"at"`
At float64 `json:"at"`
// Pos is the place in percent to move to.
Pos int `json:"pos"`
@@ -109,8 +110,8 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
// trim actions with negative timestamps to avoid index range errors when generating heatmap
// #3181 - also trim actions that occur after the scene duration
loggedBadTimestamp := false
sceneDurationMilli := int64(sceneDuration * 1000)
isValid := func(x int64) bool {
sceneDurationMilli := sceneDuration * 1000
isValid := func(x float64) bool {
return x >= 0 && x < sceneDurationMilli
}
@@ -132,7 +133,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
func (funscript *Script) UpdateIntensityAndSpeed() {
var t1, t2 int64
var t1, t2 float64
var p1, p2 int
var intensity float64
for i := range funscript.Actions {
@@ -241,13 +242,13 @@ func (gt GradientTable) GetYRange(t float64) [2]float64 {
func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable {
const windowSize = 15
const backfillThreshold = 500
const backfillThreshold = float64(500)
segments := make([]struct {
count int
intensity int
yRange [2]float64
at int64
at float64
}, numSegments)
gradient := make(GradientTable, numSegments)
posList := []int{}
@@ -297,7 +298,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int
// Fill in gaps in segments
for i := 0; i < numSegments; i++ {
segmentTS := (maxts / int64(numSegments)) * int64(i)
segmentTS := float64((maxts / int64(numSegments)) * int64(i))
// Empty segment - fill it with the previous up to backfillThreshold ms
if segments[i].count == 0 {
@@ -406,7 +407,8 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) {
pos = convertRange(pos, 0, funscript.Range, 0, 100)
}
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", action.At, pos))
// I don't know whether the csv format requires int or float, so for now we'll use int
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos))
}
return buffer.Bytes(), nil
}

View File

@@ -15,7 +15,7 @@ type SceneService interface {
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
FindMany(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
sceneFingerprintGetter
}

View File

@@ -1042,23 +1042,43 @@ func (t *ExportTask) ExportTags(ctx context.Context, workers int) {
logger.Info("[tags] exporting")
startTime := time.Now()
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
for w := 0; w < workers; w++ { // create export Tag workers
tagsWg.Add(1)
go t.exportTag(ctx, &tagsWg, jobCh)
tagIdx := 0
if t.tags != nil {
tagIdx = len(t.tags.IDs)
}
for i, tag := range tags {
index := i + 1
logger.Progressf("[tags] %d of %d", index, len(tags))
for {
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
jobCh <- tag // feed workers
for w := 0; w < workers; w++ { // create export Tag workers
tagsWg.Add(1)
go t.exportTag(ctx, &tagsWg, jobCh)
}
for i, tag := range tags {
index := i + 1 + tagIdx
logger.Progressf("[tags] %d of %d", index, len(tags)+tagIdx)
jobCh <- tag // feed workers
}
close(jobCh)
tagsWg.Wait()
// if more tags were added, we need to export those too
if t.tags == nil || len(t.tags.IDs) == tagIdx {
break
}
newTags, err := reader.FindMany(ctx, t.tags.IDs[tagIdx:])
if err != nil {
logger.Errorf("[tags] failed to fetch tags: %v", err)
}
tags = newTags
tagIdx = len(t.tags.IDs)
}
close(jobCh)
tagsWg.Wait()
logger.Infof("[tags] export complete in %s. %d workers used.", time.Since(startTime), workers)
}
@@ -1075,6 +1095,15 @@ func (t *ExportTask) exportTag(ctx context.Context, wg *sync.WaitGroup, jobChan
continue
}
if t.includeDependencies {
tagIDs, err := tag.GetDependentTagIDs(ctx, tagReader, thisTag)
if err != nil {
logger.Errorf("[tags] <%s> error getting dependent tags: %v", thisTag.Name, err)
continue
}
t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs)
}
fn := newTagJSON.Filename()
if err := t.json.saveTag(fn, newTagJSON); err != nil {

View File

@@ -426,9 +426,11 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
return
}
prefix := r.Header.Get("X-Forwarded-Prefix")
baseUrl := *r.URL
baseUrl.RawQuery = ""
baseURL := baseUrl.String()
baseURL := prefix + baseUrl.String()
urlQuery := url.Values{}
apikey := r.URL.Query().Get(apiKeyParamKey)
@@ -559,9 +561,11 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
mediaDuration := mpd.Duration(time.Duration(probeResult.FileDuration * float64(time.Second)))
m := mpd.NewMPD(mpd.DASH_PROFILE_LIVE, mediaDuration.String(), "PT4.0S")
prefix := r.Header.Get("X-Forwarded-Prefix")
baseUrl := r.URL.JoinPath("/")
baseUrl.RawQuery = ""
m.BaseURL = baseUrl.String()
m.BaseURL = prefix + baseUrl.String()
video, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, "progressive", true, 1)

View File

@@ -107,7 +107,8 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.
info, err := d.Info()
if err != nil {
return fmt.Errorf("reading info for %q: %w", path, err)
logger.Errorf("reading info for %q: %v", path, err)
return nil
}
if !s.acceptEntry(ctx, path, info) {

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"strings"
"github.com/rwcarlsen/goexif/exif"
"github.com/stashapp/stash/pkg/logger"
@@ -33,7 +34,7 @@ func areDimensionsFlipped(fs models.FS, path string) (bool, error) {
x, err := exif.Decode(r)
if err != nil {
if errors.Is(err, io.EOF) {
if errors.Is(err, io.EOF) || strings.Contains(err.Error(), "failed to find exif") {
// no exif data
return false, nil
}

View File

@@ -1,6 +1,8 @@
package fsutil
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"os"
@@ -151,7 +153,12 @@ var (
)
// SanitiseBasename returns a file basename removing any characters that are illegal or problematic to use in the filesystem.
// It appends a short hash of the original string to ensure uniqueness.
func SanitiseBasename(v string) string {
// Generate a short hash for uniqueness
hash := sha1.Sum([]byte(v))
shortHash := hex.EncodeToString(hash[:4]) // Use the first 4 bytes of the hash
v = strings.TrimSpace(v)
// replace illegal filename characters with -
@@ -163,7 +170,7 @@ func SanitiseBasename(v string) string {
// remove multiple hyphens
v = multiHyphenRE.ReplaceAllString(v, "-")
return strings.TrimSpace(v)
return strings.TrimSpace(v) + "-" + shortHash
}
// GetExeName returns the name of the given executable for the current platform.

View File

@@ -8,13 +8,13 @@ func TestSanitiseBasename(t *testing.T) {
v string
want string
}{
{"basic", "basic", "basic"},
{"spaces", `spaced name`, "spaced-name"},
{"leading/trailing spaces", ` spaced name `, "spaced-name"},
{"hyphen name", `hyphened-name`, "hyphened-name"},
{"multi-hyphen", `hyphened--name`, "hyphened-name"},
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g"},
{"removed characters", `foo!!bar@@and, more`, "foobarand-more"},
{"basic", "basic", "basic-61a7508e"},
{"spaces", `spaced name`, "spaced-name-b297cf60"},
{"leading/trailing spaces", ` spaced name `, "spaced-name-175433e9"},
{"hyphen name", `hyphened-name`, "hyphened-name-789c55f2"},
{"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"},
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"},
{"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -30,7 +30,7 @@ type SceneRelationships struct {
func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.ScrapedScene, endpoint string) error {
thisStudio := s.Studio
for thisStudio != nil {
if err := ScrapedStudio(ctx, r.StudioFinder, s.Studio, endpoint); err != nil {
if err := ScrapedStudio(ctx, r.StudioFinder, thisStudio, endpoint); err != nil {
return err
}

View File

@@ -9,15 +9,35 @@ import (
type FileQueryOptions struct {
QueryOptions
FileFilter *FileFilterType
TotalDuration bool
Megapixels bool
TotalSize bool
}
type FileFilterType struct {
And *FileFilterType `json:"AND"`
Or *FileFilterType `json:"OR"`
Not *FileFilterType `json:"NOT"`
OperatorFilter[FileFilterType]
// Filter by path
Path *StringCriterionInput `json:"path"`
Basename *StringCriterionInput `json:"basename"`
Dir *StringCriterionInput `json:"dir"`
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
ZipFile *MultiCriterionInput `json:"zip_file"`
ModTime *TimestampCriterionInput `json:"mod_time"`
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
Hashes []*FingerprintFilterInput `json:"hashes"`
VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"`
ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"`
SceneCount *IntCriterionInput `json:"scene_count"`
ImageCount *IntCriterionInput `json:"image_count"`
GalleryCount *IntCriterionInput `json:"gallery_count"`
ScenesFilter *SceneFilterType `json:"scenes_filter"`
ImagesFilter *ImageFilterType `json:"images_filter"`
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
CreatedAt *TimestampCriterionInput `json:"created_at"`
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
}
func PathsFileFilter(paths []string) *FileFilterType {
@@ -53,10 +73,10 @@ func PathsFileFilter(paths []string) *FileFilterType {
}
type FileQueryResult struct {
// can't use QueryResult because id type is wrong
IDs []FileID
Count int
QueryResult[FileID]
TotalDuration float64
Megapixels float64
TotalSize int64
getter FileGetter
files []File

View File

@@ -200,3 +200,31 @@ type CustomFieldCriterionInput struct {
Value []any `json:"value"`
Modifier CriterionModifier `json:"modifier"`
}
type FingerprintFilterInput struct {
Type string `json:"type"`
Value string `json:"value"`
// Hamming distance - defaults to 0
Distance *int `json:"distance,omitempty"`
}
type VideoFileFilterInput struct {
Format *StringCriterionInput `json:"format,omitempty"`
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
Framerate *IntCriterionInput `json:"framerate,omitempty"`
Bitrate *IntCriterionInput `json:"bitrate,omitempty"`
VideoCodec *StringCriterionInput `json:"video_codec,omitempty"`
AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"`
// in seconds
Duration *IntCriterionInput `json:"duration,omitempty"`
Captions *StringCriterionInput `json:"captions,omitempty"`
Interactive *bool `json:"interactive,omitempty"`
InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"`
}
type ImageFileFilterInput struct {
Format *StringCriterionInput `json:"format,omitempty"`
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
}

92
pkg/models/folder.go Normal file
View File

@@ -0,0 +1,92 @@
package models
import (
"context"
"path/filepath"
"strings"
)
type FolderQueryOptions struct {
QueryOptions
FolderFilter *FolderFilterType
TotalDuration bool
Megapixels bool
TotalSize bool
}
type FolderFilterType struct {
OperatorFilter[FolderFilterType]
Path *StringCriterionInput `json:"path,omitempty"`
Basename *StringCriterionInput `json:"basename,omitempty"`
// Filter by parent directory path
Dir *StringCriterionInput `json:"dir,omitempty"`
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
// Filter by modification time
ModTime *TimestampCriterionInput `json:"mod_time,omitempty"`
GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"`
// Filter by files that meet this criteria
FilesFilter *FileFilterType `json:"files_filter,omitempty"`
// Filter by related galleries that meet this criteria
GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"`
// Filter by creation time
CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"`
// Filter by last update time
UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"`
}
func PathsFolderFilter(paths []string) *FileFilterType {
if paths == nil {
return nil
}
sep := string(filepath.Separator)
var ret *FileFilterType
var or *FileFilterType
for _, p := range paths {
newOr := &FileFilterType{}
if or != nil {
or.Or = newOr
} else {
ret = newOr
}
or = newOr
if !strings.HasSuffix(p, sep) {
p += sep
}
or.Path = &StringCriterionInput{
Modifier: CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}
type FolderQueryResult struct {
QueryResult[FolderID]
getter FolderGetter
folders []*Folder
resolveErr error
}
func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult {
return &FolderQueryResult{
getter: folderGetter,
}
}
func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) {
// cache results
if r.folders == nil && r.resolveErr == nil {
r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs)
}
return r.folders, r.resolveErr
}

View File

@@ -106,7 +106,7 @@ type ImageQueryOptions struct {
}
type ImageQueryResult struct {
QueryResult
QueryResult[int]
Megapixels float64
TotalSize float64

View File

@@ -178,6 +178,52 @@ func (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID mod
return r0, r1
}
// FindMany provides a mock function with given fields: ctx, id
func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) {
ret := _m.Called(ctx, id)
var r0 []*models.Folder
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Folder)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: ctx, options
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
ret := _m.Called(ctx, options)
var r0 *models.FolderQueryResult
if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok {
r0 = rf(ctx, options)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.FolderQueryResult)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok {
r1 = rf(ctx, options)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Update provides a mock function with given fields: ctx, f
func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {
ret := _m.Called(ctx, f)

View File

@@ -549,6 +549,29 @@ func (_m *SceneReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]
return r0, r1
}
// FindByIDs provides a mock function with given fields: ctx, ids
func (_m *SceneReaderWriter) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
ret := _m.Called(ctx, ids)
var r0 []*models.Scene
if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok {
r0 = rf(ctx, ids)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Scene)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
r1 = rf(ctx, ids)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByOSHash provides a mock function with given fields: ctx, oshash
func (_m *SceneReaderWriter) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) {
ret := _m.Called(ctx, oshash)

View File

@@ -18,6 +18,10 @@ func (s *sceneResolver) FindMany(ctx context.Context, ids []int) ([]*models.Scen
return s.scenes, nil
}
func (s *sceneResolver) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
return s.scenes, nil
}
func SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResult {
ret := models.NewSceneQueryResult(&sceneResolver{
scenes: scenes,

View File

@@ -79,6 +79,14 @@ func (i FileID) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(i.String()))
}
func FileIDsFromInts(ids []int) []FileID {
ret := make([]FileID, len(ids))
for i, id := range ids {
ret[i] = FileID(id)
}
return ret
}
// DirEntry represents a file or directory in the file system.
type DirEntry struct {
ZipFileID *FileID `json:"zip_file_id"`
@@ -252,6 +260,10 @@ func (f ImageFile) GetHeight() int {
return f.Height
}
func (f ImageFile) Megapixels() float64 {
return float64(f.Width*f.Height) / 1e6
}
func (f ImageFile) GetFormat() string {
return f.Format
}

View File

@@ -35,6 +35,14 @@ func (i FolderID) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(i.String()))
}
func FolderIDsFromInts(ids []int) []FolderID {
ret := make([]FolderID, len(ids))
for i, id := range ids {
ret[i] = FolderID(id)
}
return ret
}
// Folder represents a folder in the file system.
type Folder struct {
ID FolderID `json:"id"`

View File

@@ -3,6 +3,7 @@ package models
import (
"context"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
@@ -400,6 +401,10 @@ type ScrapedTag struct {
func (ScrapedTag) IsScrapedContent() {}
func ScrapedTagSortFunction(a, b *ScrapedTag) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
}
// A movie from a scraping operation...
type ScrapedMovie struct {
StoredID *string `json:"stored_id"`

View File

@@ -43,6 +43,9 @@ func (gp *generatedPaths) GetTmpPath(fileName string) string {
// TempFile creates a temporary file using os.CreateTemp.
// It is the equivalent of calling os.CreateTemp using Tmp and pattern.
func (gp *generatedPaths) TempFile(pattern string) (*os.File, error) {
if err := gp.EnsureTmpDir(); err != nil {
logger.Warnf("Could not ensure existence of a temporary directory: %v", err)
}
return os.CreateTemp(gp.Tmp, pattern)
}

View File

@@ -178,6 +178,8 @@ type PerformerFilterType struct {
DeathYear *IntCriterionInput `json:"death_year"`
// Filter by studios where performer appears in scene/image/gallery
Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter by groups where performer appears in scene
Groups *HierarchicalMultiCriterionInput `json:"groups"`
// Filter by performers where performer appears with another performer in scene/image/gallery
Performers *MultiCriterionInput `json:"performers"`
// Filter by autotag ignore value

View File

@@ -5,7 +5,7 @@ type QueryOptions struct {
Count bool
}
type QueryResult struct {
IDs []int
type QueryResult[T comparable] struct {
IDs []T
Count int
}

View File

@@ -5,6 +5,7 @@ import "context"
// FolderGetter provides methods to get folders by ID.
type FolderGetter interface {
Find(ctx context.Context, id FolderID) (*Folder, error)
FindMany(ctx context.Context, id []FolderID) ([]*Folder, error)
}
// FolderFinder provides methods to find folders.
@@ -16,6 +17,10 @@ type FolderFinder interface {
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
}
type FolderQueryer interface {
Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error)
}
type FolderCounter interface {
CountAllInPaths(ctx context.Context, p []string) (int, error)
}
@@ -47,6 +52,7 @@ type FolderFinderDestroyer interface {
// FolderReader provides all methods to read folders.
type FolderReader interface {
FolderFinder
FolderQueryer
FolderCounter
}

View File

@@ -10,6 +10,9 @@ type SceneGetter interface {
// TODO - rename this to Find and remove existing method
FindMany(ctx context.Context, ids []int) ([]*Scene, error)
Find(ctx context.Context, id int) (*Scene, error)
// FindByIDs works the same way as FindMany, but it ignores any scenes not found
// Scenes are not guaranteed to be in the same order as the input
FindByIDs(ctx context.Context, ids []int) ([]*Scene, error)
}
// SceneFinder provides methods to find scenes.

View File

@@ -126,7 +126,7 @@ type SceneQueryOptions struct {
}
type SceneQueryResult struct {
QueryResult
QueryResult[int]
TotalDuration float64
TotalSize float64

View File

@@ -19,6 +19,18 @@ func CountByStudioID(ctx context.Context, r models.PerformerQueryer, id int, dep
return r.QueryCount(ctx, filter, nil)
}
func CountByGroupID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
filter := &models.PerformerFilterType{
Groups: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}
func CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
filter := &models.PerformerFilterType{
Tags: &models.HierarchicalMultiCriterionInput{

View File

@@ -192,7 +192,7 @@ interface IPluginApi {
);
};
PluginApi.register.route("/plugin/test-react", TestPage);
PluginApi.register.route("/plugins/test-react", TestPage);
PluginApi.patch.before("SettingsToolsSection", function (props: any) {
const {
@@ -206,7 +206,7 @@ interface IPluginApi {
{props.children}
<Setting
heading={
<Link to="/plugin/test-react">
<Link to="/plugins/test-react">
<Button>
Test page
</Button>
@@ -232,7 +232,7 @@ interface IPluginApi {
<NavLink
className="nav-utility"
exact
to="/plugin/test-react"
to="/plugins/test-react"
>
<Button
className="minimal d-flex align-items-center h-100"

View File

@@ -33,7 +33,31 @@ func LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) e
return nil
}
// FindMany retrieves multiple scenes by their IDs.
// FindByIDs retrieves multiple scenes by their IDs.
// Missing scenes will be ignored, and the returned scenes are unsorted.
// This method will load the specified relationships for each scene.
func (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {
var scenes []*models.Scene
qb := s.Repository
var err error
scenes, err = qb.FindByIDs(ctx, ids)
if err != nil {
return nil, err
}
// TODO - we should bulk load these relationships
for _, scene := range scenes {
if err := s.LoadRelationships(ctx, scene, load...); err != nil {
return nil, err
}
}
return scenes, nil
}
// FindMany retrieves multiple scenes by their IDs. Return value is guaranteed to be in the same order as the input.
// Missing scenes will return an error.
// This method will load the specified relationships for each scene.
func (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {
var scenes []*models.Scene

View File

@@ -378,6 +378,11 @@ func (c config) matchesURL(url string, ty ScrapeContentType) bool {
}
}
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
for _, scraper := range c.GroupByURL {
if scraper.matchesURL(url) {
return true
}
}
for _, scraper := range c.MovieByURL {
if scraper.matchesURL(url) {
return true

View File

@@ -108,7 +108,11 @@ xPathScrapers:
Image:
selector: //div[contains(@class,'image-container')]//a/img/@src
Gender:
fixed: "Female"
selector: //h1/*[1]/*[1]/text()Add commentMore actions
postProcess:
- replace:
- regex: .+ identifies as (.+)
with: $1
DeathDate:
selector: //div[contains(text(),'Passed away on')]
postProcess:
@@ -124,7 +128,7 @@ xPathScrapers:
- regex: \skg
with: ""
# Last Updated January 2, 2024
# Last Updated June 22, 2025
`
func getFreeonesScraper(globalConfig GlobalConfig) scraper {

View File

@@ -851,7 +851,10 @@ type mappedScraper struct {
Gallery *mappedGalleryScraperConfig `yaml:"gallery"`
Image *mappedImageScraperConfig `yaml:"image"`
Performer *mappedPerformerScraperConfig `yaml:"performer"`
Movie *mappedMovieScraperConfig `yaml:"movie"`
Group *mappedMovieScraperConfig `yaml:"group"`
// deprecated
Movie *mappedMovieScraperConfig `yaml:"movie"`
}
type mappedResult map[string]interface{}
@@ -1247,24 +1250,29 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model
return &ret, nil
}
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedMovie, error) {
var ret models.ScrapedMovie
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedGroup, error) {
var ret models.ScrapedGroup
movieScraperConfig := s.Movie
if movieScraperConfig == nil {
// try group scraper first, falling back to movie
groupScraperConfig := s.Group
if groupScraperConfig == nil {
groupScraperConfig = s.Movie
}
if groupScraperConfig == nil {
return nil, nil
}
movieMap := movieScraperConfig.mappedConfig
groupMap := groupScraperConfig.mappedConfig
movieStudioMap := movieScraperConfig.Studio
movieTagsMap := movieScraperConfig.Tags
groupStudioMap := groupScraperConfig.Studio
groupTagsMap := groupScraperConfig.Tags
results := movieMap.process(ctx, q, s.Common, urlsIsMulti)
results := groupMap.process(ctx, q, s.Common, urlsIsMulti)
if movieStudioMap != nil {
logger.Debug(`Processing movie studio:`)
studioResults := movieStudioMap.process(ctx, q, s.Common, nil)
if groupStudioMap != nil {
logger.Debug(`Processing group studio:`)
studioResults := groupStudioMap.process(ctx, q, s.Common, nil)
if len(studioResults) > 0 {
studio := &models.ScrapedStudio{}
@@ -1274,9 +1282,9 @@ func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.
}
// now apply the tags
if movieTagsMap != nil {
logger.Debug(`Processing movie tags:`)
tagResults := movieTagsMap.process(ctx, q, s.Common, nil)
if groupTagsMap != nil {
logger.Debug(`Processing group tags:`)
tagResults := groupTagsMap.process(ctx, q, s.Common, nil)
for _, p := range tagResults {
tag := &models.ScrapedTag{}

View File

@@ -32,6 +32,8 @@ func FilterTags(excludeRegexps []*regexp.Regexp, tags []*models.ScrapedTag) (new
return tags, nil
}
newTags = make([]*models.ScrapedTag, 0, len(tags))
for _, t := range tags {
ignore := false
for _, reg := range excludeRegexps {

View File

@@ -81,6 +81,6 @@ func LogExternalAccessError(err ExternalAccessError) {
"You probably forwarded a port from your router. At the very least, add a password to stash in the settings. \n"+
"Stash will not serve requests until you edit config.yml, remove the security_tripwire_accessed_from_public_internet key and restart stash. \n"+
"This behaviour can be overridden (but not recommended) by setting dangerous_allow_public_without_auth to true in config.yml. \n"+
"More information is available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet \n"+
"More information is available at https://discourse.stashapp.cc/t/-/1658 \n"+
"Stash is not answering any other requests to protect your privacy.", net.IP(err).String())
}

View File

@@ -3,7 +3,7 @@ package sqlite
const defaultBatchSize = 1000
// batchExec executes the provided function in batches of the provided size.
func batchExec(ids []int, batchSize int, fn func(batch []int) error) error {
func batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error {
for i := 0; i < len(ids); i += batchSize {
end := i + batchSize
if end > len(ids) {

View File

@@ -70,6 +70,17 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
}
}
func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
if addJoinFn != nil {
addJoinFn(f)
}
stringCriterionHandler(c, column)(ctx, f)
}
}
}
func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if modifier.IsValid() {

View File

@@ -275,6 +275,43 @@ func (r fileQueryRows) resolve() []models.File {
return ret
}
type fileRepositoryType struct {
repository
scenes joinRepository
images joinRepository
galleries joinRepository
}
var (
fileRepository = fileRepositoryType{
repository: repository{
tableName: sceneTable,
idColumn: idColumn,
},
scenes: joinRepository{
repository: repository{
tableName: scenesFilesTable,
idColumn: fileIDColumn,
},
fkColumn: sceneIDColumn,
},
images: joinRepository{
repository: repository{
tableName: imagesFilesTable,
idColumn: fileIDColumn,
},
fkColumn: imageIDColumn,
},
galleries: joinRepository{
repository: repository{
tableName: galleriesFilesTable,
idColumn: fileIDColumn,
},
fkColumn: galleryIDColumn,
},
}
)
type FileStore struct {
repository
@@ -284,7 +321,7 @@ type FileStore struct {
func NewFileStore() *FileStore {
return &FileStore{
repository: repository{
tableName: sceneTable,
tableName: fileTable,
idColumn: idColumn,
},
@@ -830,9 +867,11 @@ func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilt
query.not(qb.makeFilter(ctx, fileFilter.Not))
}
query.handleCriterion(ctx, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil))
filter := filterBuilderFromHandler(ctx, &fileFilterHandler{
fileFilter: fileFilter,
})
return query
return filter
}
func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {
@@ -890,7 +929,7 @@ func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions)
}
func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) {
if !options.Count {
if !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize {
// nothing to do - return empty result
return models.NewFileQueryResult(qb), nil
}
@@ -898,14 +937,43 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
aggregateQuery := qb.newQuery()
if options.Count {
aggregateQuery.addColumn("COUNT(temp.id) as total")
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
}
if options.TotalDuration {
query.addJoins(
join{
table: videoFileTable,
onClause: "files.id = video_files.file_id",
},
)
query.addColumn("COALESCE(video_files.duration, 0) as duration")
aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration")
}
if options.Megapixels {
query.addJoins(
join{
table: imageFileTable,
onClause: "files.id = image_files.file_id",
},
)
query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels")
aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels")
}
if options.TotalSize {
query.addColumn("COALESCE(files.size, 0) as size")
aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size")
}
const includeSortPagination = false
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
out := struct {
Total int
Total int
Duration float64
Megapixels float64
Size int64
}{}
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
return nil, err
@@ -913,6 +981,9 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
ret := models.NewFileQueryResult(qb)
ret.Count = out.Total
ret.Megapixels = out.Megapixels
ret.TotalDuration = out.Duration
ret.TotalSize = out.Size
return ret, nil
}

340
pkg/sqlite/file_filter.go Normal file
View File

@@ -0,0 +1,340 @@
package sqlite
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
type fileFilterHandler struct {
fileFilter *models.FileFilterType
}
func (qb *fileFilterHandler) validate() error {
fileFilter := qb.fileFilter
if fileFilter == nil {
return nil
}
if err := validateFilterCombination(fileFilter.OperatorFilter); err != nil {
return err
}
if subFilter := fileFilter.SubFilter(); subFilter != nil {
sqb := &fileFilterHandler{fileFilter: subFilter}
if err := sqb.validate(); err != nil {
return err
}
}
return nil
}
func (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
fileFilter := qb.fileFilter
if fileFilter == nil {
return
}
if err := qb.validate(); err != nil {
f.setError(err)
return
}
sf := fileFilter.SubFilter()
if sf != nil {
sub := &fileFilterHandler{sf}
handleSubFilter(ctx, sub, f, fileFilter.OperatorFilter)
}
f.handleCriterion(ctx, qb.criterionHandler())
}
func (qb *fileFilterHandler) criterionHandler() criterionHandler {
fileFilter := qb.fileFilter
return compoundHandler{
&videoFileFilterHandler{
filter: fileFilter.VideoFileFilter,
},
&imageFileFilterHandler{
filter: fileFilter.ImageFileFilter,
},
pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil),
stringCriterionHandler(fileFilter.Basename, "files.basename"),
stringCriterionHandler(fileFilter.Dir, "folders.path"),
&timestampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil},
qb.parentFolderCriterionHandler(fileFilter.ParentFolder),
qb.zipFileCriterionHandler(fileFilter.ZipFile),
qb.sceneCountCriterionHandler(fileFilter.SceneCount),
qb.imageCountCriterionHandler(fileFilter.ImageCount),
qb.galleryCountCriterionHandler(fileFilter.GalleryCount),
qb.hashesCriterionHandler(fileFilter.Hashes),
qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated),
&timestampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
&timestampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil},
&relatedFilterHandler{
relatedIDCol: "scenes_files.scene_id",
relatedRepo: sceneRepository.repository,
relatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter},
joinFn: func(f *filterBuilder) {
fileRepository.scenes.innerJoin(f, "", "files.id")
},
},
&relatedFilterHandler{
relatedIDCol: "images_files.image_id",
relatedRepo: imageRepository.repository,
relatedHandler: &imageFilterHandler{fileFilter.ImagesFilter},
joinFn: func(f *filterBuilder) {
fileRepository.images.innerJoin(f, "", "files.id")
},
},
&relatedFilterHandler{
relatedIDCol: "galleries_files.gallery_id",
relatedRepo: galleryRepository.repository,
relatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter},
joinFn: func(f *filterBuilder) {
fileRepository.galleries.innerJoin(f, "", "files.id")
},
},
}
}
func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if criterion != nil {
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
var notClause string
if criterion.Modifier == models.CriterionModifierNotNull {
notClause = "NOT"
}
f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause))
return
}
if len(criterion.Value) == 0 {
return
}
var args []interface{}
for _, tagID := range criterion.Value {
args = append(args, tagID)
}
whereClause := ""
havingClause := ""
switch criterion.Modifier {
case models.CriterionModifierIncludes:
whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value))
case models.CriterionModifierExcludes:
whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
}
f.addWhere(whereClause, args...)
f.addHaving(havingClause)
}
}
}
func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if folder == nil {
return
}
folderCopy := *folder
switch folderCopy.Modifier {
case models.CriterionModifierEquals:
folderCopy.Modifier = models.CriterionModifierIncludesAll
case models.CriterionModifierNotEquals:
folderCopy.Modifier = models.CriterionModifierExcludes
}
hh := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: fileTable,
foreignTable: folderTable,
foreignFK: "parent_folder_id",
parentFK: "parent_folder_id",
}
hh.handler(&folderCopy)(ctx, f)
}
}
func (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: fileTable,
joinTable: scenesFilesTable,
primaryFK: fileIDColumn,
}
return h.handler(c)
}
func (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: fileTable,
joinTable: imagesFilesTable,
primaryFK: fileIDColumn,
}
return h.handler(c)
}
func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: fileTable,
joinTable: galleriesFilesTable,
primaryFK: fileIDColumn,
}
return h.handler(c)
}
func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
// TODO: Wishlist item: Implement Distance matching
if duplicatedFilter != nil {
var v string
if *duplicatedFilter.Duplicated {
v = ">"
} else {
v = "="
}
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id")
}
}
}
func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
// TODO - this won't work for AND/OR combinations
for i, hash := range hashes {
t := fmt.Sprintf("file_fingerprints_%d", i)
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
value, _ := utils.StringToPhash(hash.Value)
distance := 0
if hash.Distance != nil {
distance = *hash.Distance
}
if distance > 0 {
// needed to avoid a type mismatch
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
} else {
// use the default handler
intCriterionHandler(&models.IntCriterionInput{
Value: int(value),
Modifier: models.CriterionModifierEquals,
}, t+".fingerprint", nil)(ctx, f)
}
}
}
}
type videoFileFilterHandler struct {
filter *models.VideoFileFilterInput
}
func (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
videoFileFilter := qb.filter
if videoFileFilter == nil {
return
}
f.handleCriterion(ctx, qb.criterionHandler())
}
func (qb *videoFileFilterHandler) criterionHandler() criterionHandler {
videoFileFilter := qb.filter
return compoundHandler{
joinedStringCriterionHandler(videoFileFilter.Format, "video_files.format", qb.addVideoFilesTable),
floatIntCriterionHandler(videoFileFilter.Duration, "video_files.duration", qb.addVideoFilesTable),
resolutionCriterionHandler(videoFileFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable),
orientationCriterionHandler(videoFileFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable),
floatIntCriterionHandler(videoFileFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable),
intCriterionHandler(videoFileFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable),
qb.codecCriterionHandler(videoFileFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable),
qb.codecCriterionHandler(videoFileFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable),
boolCriterionHandler(videoFileFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable),
intCriterionHandler(videoFileFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable),
qb.captionCriterionHandler(videoFileFilter.Captions),
}
}
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) {
f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id")
}
func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if codec != nil {
if addJoinFn != nil {
addJoinFn(f)
}
stringCriterionHandler(codec, codecColumn)(ctx, f)
}
}
}
func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{
primaryTable: sceneTable,
primaryFK: sceneIDColumn,
joinTable: videoCaptionsTable,
stringColumn: captionCodeColumn,
addJoinTable: func(f *filterBuilder) {
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id")
},
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
excludeClause := `files.id NOT IN (
SELECT files.id from files
INNER JOIN video_captions on video_captions.file_id = files.id
WHERE video_captions.language_code LIKE ?
)`
f.addWhere(excludeClause, criterion.Value)
// TODO - should we also exclude null values?
},
}
return h.handler(captions)
}
type imageFileFilterHandler struct {
filter *models.ImageFileFilterInput
}
func (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
ff := qb.filter
if ff == nil {
return
}
f.handleCriterion(ctx, qb.criterionHandler())
}
func (qb *imageFileFilterHandler) criterionHandler() criterionHandler {
ff := qb.filter
return compoundHandler{
joinedStringCriterionHandler(ff.Format, "image_files.format", qb.addImageFilesTable),
resolutionCriterionHandler(ff.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable),
orientationCriterionHandler(ff.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable),
}
}
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) {
f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id")
}

View File

@@ -0,0 +1,117 @@
//go:build integration
// +build integration
package sqlite_test
import (
"context"
"strconv"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stretchr/testify/assert"
)
func TestFileQuery(t *testing.T) {
tests := []struct {
name string
findFilter *models.FindFilterType
filter *models.FileFilterType
includeIdxs []int
includeIDs []models.FileID
excludeIdxs []int
wantErr bool
}{
{
name: "path",
filter: &models.FileFilterType{
Path: &models.StringCriterionInput{
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
Modifier: models.CriterionModifierIncludes,
},
},
includeIdxs: []int{fileIdxStartVideoFiles},
excludeIdxs: []int{fileIdxStartImageFiles},
},
{
name: "basename",
filter: &models.FileFilterType{
Basename: &models.StringCriterionInput{
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
Modifier: models.CriterionModifierIncludes,
},
},
includeIdxs: []int{fileIdxStartVideoFiles},
excludeIdxs: []int{fileIdxStartImageFiles},
},
{
name: "dir",
filter: &models.FileFilterType{
Path: &models.StringCriterionInput{
Value: folderPaths[folderIdxWithSceneFiles],
Modifier: models.CriterionModifierIncludes,
},
},
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
excludeIdxs: []int{fileIdxStartImageFiles},
},
{
name: "parent folder",
filter: &models.FileFilterType{
ParentFolder: &models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])),
},
Modifier: models.CriterionModifierIncludes,
},
},
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
excludeIdxs: []int{fileIdxStartImageFiles},
},
{
name: "zip file",
filter: &models.FileFilterType{
ZipFile: &models.MultiCriterionInput{
Value: []string{
strconv.Itoa(int(fileIDs[fileIdxZip])),
},
Modifier: models.CriterionModifierIncludes,
},
},
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
excludeIdxs: []int{fileIdxStartImageFiles},
},
// TODO - add more tests for other file filters
}
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
results, err := db.File.Query(ctx, models.FileQueryOptions{
FileFilter: tt.filter,
QueryOptions: models.QueryOptions{
FindFilter: tt.findFilter,
},
})
if (err != nil) != tt.wantErr {
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
include := indexesToIDPtrs(fileIDs, tt.includeIdxs)
for _, id := range tt.includeIDs {
v := id
include = append(include, &v)
}
exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs)
for _, i := range include {
assert.Contains(results.IDs, models.FileID(*i))
}
for _, e := range exclude {
assert.NotContains(results.IDs, models.FileID(*e))
}
})
}
}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"path/filepath"
"slices"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
@@ -15,6 +16,7 @@ import (
)
const folderTable = "folders"
const folderIDColumn = "folder_id"
type folderRow struct {
ID models.FolderID `db:"id" goqu:"skipinsert"`
@@ -82,6 +84,25 @@ func (r folderQueryRows) resolve() []*models.Folder {
return ret
}
type folderRepositoryType struct {
repository
galleries repository
}
var (
folderRepository = folderRepositoryType{
repository: repository{
tableName: folderTable,
idColumn: idColumn,
},
galleries: repository{
tableName: galleryTable,
idColumn: folderIDColumn,
},
}
)
type FolderStore struct {
repository
@@ -91,7 +112,7 @@ type FolderStore struct {
func NewFolderStore() *FolderStore {
return &FolderStore{
repository: repository{
tableName: sceneTable,
tableName: folderTable,
idColumn: idColumn,
},
@@ -225,6 +246,52 @@ func (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Fo
return ret, nil
}
// FindByIDs finds multiple folders by their IDs.
// No check is made to see if the folders exist, and the order of the returned folders
// is not guaranteed to be the same as the order of the input IDs.
func (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
folders := make([]*models.Folder, 0, len(ids))
table := qb.table()
if err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error {
q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))
unsorted, err := qb.getMany(ctx, q)
if err != nil {
return err
}
folders = append(folders, unsorted...)
return nil
}); err != nil {
return nil, err
}
return folders, nil
}
func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
folders := make([]*models.Folder, len(ids))
unsorted, err := qb.FindByIDs(ctx, ids)
if err != nil {
return nil, err
}
for _, s := range unsorted {
i := slices.Index(ids, s.ID)
folders[i] = s
}
for i := range folders {
if folders[i] == nil {
return nil, fmt.Errorf("folder with id %d not found", ids[i])
}
}
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))
@@ -313,3 +380,162 @@ func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.Fil
return qb.getMany(ctx, q)
}
func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error {
const and = "AND"
const or = "OR"
const not = "NOT"
if fileFilter.And != nil {
if fileFilter.Or != nil {
return illegalFilterCombination(and, or)
}
if fileFilter.Not != nil {
return illegalFilterCombination(and, not)
}
return qb.validateFilter(fileFilter.And)
}
if fileFilter.Or != nil {
if fileFilter.Not != nil {
return illegalFilterCombination(or, not)
}
return qb.validateFilter(fileFilter.Or)
}
if fileFilter.Not != nil {
return qb.validateFilter(fileFilter.Not)
}
return nil
}
func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder {
query := &filterBuilder{}
if folderFilter.And != nil {
query.and(qb.makeFilter(ctx, folderFilter.And))
}
if folderFilter.Or != nil {
query.or(qb.makeFilter(ctx, folderFilter.Or))
}
if folderFilter.Not != nil {
query.not(qb.makeFilter(ctx, folderFilter.Not))
}
filter := filterBuilderFromHandler(ctx, &folderFilterHandler{
folderFilter: folderFilter,
})
return filter
}
func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
folderFilter := options.FolderFilter
findFilter := options.FindFilter
if folderFilter == nil {
folderFilter = &models.FolderFilterType{}
}
if findFilter == nil {
findFilter = &models.FindFilterType{}
}
query := qb.newQuery()
distinctIDs(&query, folderTable)
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"folders.path"}
query.parseQueryString(searchColumns, *q)
}
if err := qb.validateFilter(folderFilter); err != nil {
return nil, err
}
filter := qb.makeFilter(ctx, folderFilter)
if err := query.addFilter(filter); err != nil {
return nil, err
}
if err := qb.setQuerySort(&query, findFilter); err != nil {
return nil, err
}
query.sortAndPagination += getPagination(findFilter)
result, err := qb.queryGroupedFields(ctx, options, query)
if err != nil {
return nil, fmt.Errorf("error querying aggregate fields: %w", err)
}
idsResult, err := query.findIDs(ctx)
if err != nil {
return nil, fmt.Errorf("error finding IDs: %w", err)
}
result.IDs = make([]models.FolderID, len(idsResult))
for i, id := range idsResult {
result.IDs[i] = models.FolderID(id)
}
return result, nil
}
func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) {
if !options.Count {
// nothing to do - return empty result
return models.NewFolderQueryResult(qb), nil
}
aggregateQuery := qb.newQuery()
if options.Count {
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
}
const includeSortPagination = false
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
out := struct {
Total int
Duration float64
Megapixels float64
Size int64
}{}
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
return nil, err
}
ret := models.NewFolderQueryResult(qb)
ret.Count = out.Total
return ret, nil
}
var folderSortOptions = sortOptions{
"created_at",
"id",
"path",
"random",
"updated_at",
}
func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error {
if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
return nil
}
sort := findFilter.GetSort("path")
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
if err := folderSortOptions.validateSort(sort); err != nil {
return err
}
direction := findFilter.GetDirection()
query.sortAndPagination += getSort(sort, direction, "folders")
return nil
}

150
pkg/sqlite/folder_filter.go Normal file
View File

@@ -0,0 +1,150 @@
package sqlite
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
type folderFilterHandler struct {
folderFilter *models.FolderFilterType
}
func (qb *folderFilterHandler) validate() error {
folderFilter := qb.folderFilter
if folderFilter == nil {
return nil
}
if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil {
return err
}
if subFilter := folderFilter.SubFilter(); subFilter != nil {
sqb := &folderFilterHandler{folderFilter: subFilter}
if err := sqb.validate(); err != nil {
return err
}
}
return nil
}
func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) {
folderFilter := qb.folderFilter
if folderFilter == nil {
return
}
if err := qb.validate(); err != nil {
f.setError(err)
return
}
sf := folderFilter.SubFilter()
if sf != nil {
sub := &folderFilterHandler{sf}
handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter)
}
f.handleCriterion(ctx, qb.criterionHandler())
}
func (qb *folderFilterHandler) criterionHandler() criterionHandler {
folderFilter := qb.folderFilter
return compoundHandler{
stringCriterionHandler(folderFilter.Path, "folders.path"),
&timestampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil},
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
qb.zipFileCriterionHandler(folderFilter.ZipFile),
qb.galleryCountCriterionHandler(folderFilter.GalleryCount),
&timestampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil},
&timestampCriterionHandler{folderFilter.UpdatedAt, "folders.updated_at", nil},
&relatedFilterHandler{
relatedIDCol: "galleries.id",
relatedRepo: galleryRepository.repository,
relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter},
joinFn: func(f *filterBuilder) {
folderRepository.galleries.innerJoin(f, "", "folders.id")
},
},
}
}
func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if criterion != nil {
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
var notClause string
if criterion.Modifier == models.CriterionModifierNotNull {
notClause = "NOT"
}
f.addWhere(fmt.Sprintf("folders.zip_file_id IS %s NULL", notClause))
return
}
if len(criterion.Value) == 0 {
return
}
var args []interface{}
for _, tagID := range criterion.Value {
args = append(args, tagID)
}
whereClause := ""
havingClause := ""
switch criterion.Modifier {
case models.CriterionModifierIncludes:
whereClause = "folders.zip_file_id IN " + getInBinding(len(criterion.Value))
case models.CriterionModifierExcludes:
whereClause = "folders.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
}
f.addWhere(whereClause, args...)
f.addHaving(havingClause)
}
}
}
func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if folder == nil {
return
}
folderCopy := *folder
switch folderCopy.Modifier {
case models.CriterionModifierEquals:
folderCopy.Modifier = models.CriterionModifierIncludesAll
case models.CriterionModifierNotEquals:
folderCopy.Modifier = models.CriterionModifierExcludes
}
hh := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: folderTable,
foreignTable: folderTable,
foreignFK: "parent_folder_id",
parentFK: "parent_folder_id",
}
hh.handler(&folderCopy)(ctx, f)
}
}
func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if galleryCount != nil {
f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id")
clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount)
f.addHaving(clause, args...)
}
}
}

View File

@@ -0,0 +1,95 @@
//go:build integration
// +build integration
package sqlite_test
import (
"context"
"strconv"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stretchr/testify/assert"
)
func TestFolderQuery(t *testing.T) {
tests := []struct {
name string
findFilter *models.FindFilterType
filter *models.FolderFilterType
includeIdxs []int
includeIDs []models.FolderID
excludeIdxs []int
wantErr bool
}{
{
name: "path",
filter: &models.FolderFilterType{
Path: &models.StringCriterionInput{
Value: getFolderPath(folderIdxWithSubFolder, nil),
Modifier: models.CriterionModifierIncludes,
},
},
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
excludeIdxs: []int{folderIdxInZip},
},
{
name: "parent folder",
filter: &models.FolderFilterType{
ParentFolder: &models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])),
},
Modifier: models.CriterionModifierIncludes,
},
},
includeIdxs: []int{folderIdxWithParentFolder},
excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip},
},
{
name: "zip file",
filter: &models.FolderFilterType{
ZipFile: &models.MultiCriterionInput{
Value: []string{
strconv.Itoa(int(fileIDs[fileIdxZip])),
},
Modifier: models.CriterionModifierIncludes,
},
},
includeIdxs: []int{folderIdxInZip},
excludeIdxs: []int{folderIdxForObjectFiles},
},
// TODO - add more tests for other folder filters
}
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
results, err := db.Folder.Query(ctx, models.FolderQueryOptions{
FolderFilter: tt.filter,
QueryOptions: models.QueryOptions{
FindFilter: tt.findFilter,
},
})
if (err != nil) != tt.wantErr {
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
include := indexesToIDPtrs(folderIDs, tt.includeIdxs)
for _, id := range tt.includeIDs {
v := id
include = append(include, &v)
}
exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs)
for _, i := range include {
assert.Contains(results.IDs, models.FolderID(*i))
}
for _, e := range exclude {
assert.NotContains(results.IDs, models.FolderID(*e))
}
})
}
}

View File

@@ -155,6 +155,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
qb.studiosCriterionHandler(filter.Studios),
qb.groupsCriterionHandler(filter.Groups),
qb.appearsWithCriterionHandler(filter.Performers),
qb.tagCountCriterionHandler(filter.TagCount),
@@ -487,6 +489,119 @@ func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.Hierar
}
}
func (qb *performerFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if groups != nil {
if groups.Modifier == models.CriterionModifierIsNull || groups.Modifier == models.CriterionModifierNotNull {
var notClause string
if groups.Modifier == models.CriterionModifierNotNull {
notClause = "NOT"
}
f.addLeftJoin(performersScenesTable, "", "performers_scenes.performer_id = performers.id")
f.addLeftJoin(groupsScenesTable, "", "performers_scenes.scene_id = groups_scenes.scene_id")
f.addWhere(fmt.Sprintf("%s groups_scenes.group_id IS NULL", notClause))
return
}
if len(groups.Value) == 0 {
return
}
var clauseCondition string
switch groups.Modifier {
case models.CriterionModifierIncludes:
// return performers who appear in scenes with any of the given groups
clauseCondition = "NOT"
case models.CriterionModifierExcludes:
// exclude performers who appear in scenes with any of the given groups
clauseCondition = ""
default:
return
}
const derivedPerformerGroupTable = "performer_group"
// Simplified approach: direct group-scene-performer relationship without hierarchy
var args []interface{}
for _, val := range groups.Value {
args = append(args, val)
}
// If depth is specified and not 0, we need hierarchy, otherwise use simple approach
depthVal := 0
if groups.Depth != nil {
depthVal = *groups.Depth
}
if depthVal == 0 {
// Simple case: no hierarchy, direct group relationship
f.addWith(fmt.Sprintf("group_values(id) AS (VALUES %s)", strings.Repeat("(?),", len(groups.Value)-1)+"(?)"), args...)
templStr := `SELECT performer_id FROM {joinTable}
INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id
INNER JOIN group_values ON {primaryTable}.{groupFK} = group_values.id`
formatMaps := []utils.StrFormatMap{
{
"primaryTable": groupsScenesTable,
"joinTable": performersScenesTable,
"primaryFK": sceneIDColumn,
"groupFK": groupIDColumn,
},
}
var unions []string
for _, c := range formatMaps {
unions = append(unions, utils.StrFormat(templStr, c))
}
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION ")))
} else {
// Complex case: with hierarchy
var depthCondition string
if depthVal != -1 {
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
}
// Build recursive CTE for group hierarchy
hierarchyQuery := fmt.Sprintf(`group_hierarchy AS (
SELECT sub_id AS root_id, sub_id AS item_id, 0 AS depth FROM groups_relations WHERE sub_id IN%s
UNION
SELECT root_id, sub_id, depth + 1 FROM groups_relations INNER JOIN group_hierarchy ON item_id = containing_id %s
)`, getInBinding(len(groups.Value)), depthCondition)
f.addRecursiveWith(hierarchyQuery, args...)
templStr := `SELECT performer_id FROM {joinTable}
INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id
INNER JOIN group_hierarchy ON {primaryTable}.{groupFK} = group_hierarchy.item_id`
formatMaps := []utils.StrFormatMap{
{
"primaryTable": groupsScenesTable,
"joinTable": performersScenesTable,
"primaryFK": sceneIDColumn,
"groupFK": groupIDColumn,
},
}
var unions []string
for _, c := range formatMaps {
unions = append(unions, utils.StrFormat(templStr, c))
}
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION ")))
}
f.addLeftJoin(derivedPerformerGroupTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerGroupTable))
f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerGroupTable, clauseCondition))
}
}
}
func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if performers != nil {

View File

@@ -493,8 +493,11 @@ func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {
return ret, err
}
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
scenes := make([]*models.Scene, len(ids))
// FindByIDs finds multiple scenes by their IDs.
// No check is made to see if the scenes exist, and the order of the returned scenes
// is not guaranteed to be the same as the order of the input IDs.
func (qb *SceneStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
scenes := make([]*models.Scene, 0, len(ids))
table := qb.table()
if err := batchExec(ids, defaultBatchSize, func(batch []int) error {
@@ -504,16 +507,29 @@ func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene,
return err
}
for _, s := range unsorted {
i := slices.Index(ids, s.ID)
scenes[i] = s
}
scenes = append(scenes, unsorted...)
return nil
}); err != nil {
return nil, err
}
return scenes, nil
}
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
scenes := make([]*models.Scene, len(ids))
unsorted, err := qb.FindByIDs(ctx, ids)
if err != nil {
return nil, err
}
for _, s := range unsorted {
i := slices.Index(ids, s.ID)
scenes[i] = s
}
for i := range scenes {
if scenes[i] == nil {
return nil, fmt.Errorf("scene with id %d not found", ids[i])

View File

@@ -578,6 +578,22 @@ func indexToID(ids []int, idx int) int {
return ids[idx]
}
func indexesToIDPtrs[T any](ids []T, indexes []int) []*T {
ret := make([]*T, len(indexes))
for i, idx := range indexes {
ret[i] = indexToIDPtr(ids, idx)
}
return ret
}
func indexToIDPtr[T any](ids []T, idx int) *T {
if idx < 0 {
return nil
}
return &ids[idx]
}
func indexFromID(ids []int, id int) int {
for i, v := range ids {
if v == id {
@@ -675,7 +691,9 @@ func populateDB() error {
return fmt.Errorf("creating files: %w", err)
}
// TODO - link folders to zip files
if err := linkFoldersToZip(ctx); err != nil {
return fmt.Errorf("linking folders to zip files: %w", err)
}
if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {
return fmt.Errorf("error creating tags: %s", err.Error())
@@ -798,6 +816,27 @@ func createFolders(ctx context.Context) error {
return nil
}
func linkFoldersToZip(ctx context.Context) error {
// link folders to zip files
for folderIdx, fileIdx := range folderZipFiles {
folderID := folderIDs[folderIdx]
fileID := fileIDs[fileIdx]
f, err := db.Folder.Find(ctx, folderID)
if err != nil {
return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID)
}
f.ZipFileID = &fileID
if err := db.Folder.Update(ctx, f); err != nil {
return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error())
}
}
return nil
}
func getFileBaseName(index int) string {
return getPrefixedStringValue("file", index, "basename")
}

View File

@@ -74,7 +74,9 @@ var (
table: imagesTagsJoinTable,
idColumn: imagesTagsJoinTable.Col(imageIDColumn),
},
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
imagesPerformersTableMgr = &joinTable{
@@ -112,7 +114,9 @@ var (
table: galleriesTagsJoinTable,
idColumn: galleriesTagsJoinTable.Col(galleryIDColumn),
},
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
galleriesPerformersTableMgr = &joinTable{
@@ -168,7 +172,9 @@ var (
table: scenesTagsJoinTable,
idColumn: scenesTagsJoinTable.Col(sceneIDColumn),
},
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
scenesPerformersTableMgr = &joinTable{
@@ -274,7 +280,9 @@ var (
table: performersTagsJoinTable,
idColumn: performersTagsJoinTable.Col(performerIDColumn),
},
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
performersStashIDsTableMgr = &stashIDTable{
@@ -304,7 +312,9 @@ var (
table: studiosTagsJoinTable,
idColumn: studiosTagsJoinTable.Col(studioIDColumn),
},
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
studiosStashIDsTableMgr = &stashIDTable{
@@ -336,7 +346,7 @@ var (
},
fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn),
foreignTable: tagTableMgr,
orderBy: tagTableMgr.table.Col("name").Asc(),
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
@@ -363,7 +373,7 @@ var (
},
fkColumn: groupsTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: tagTableMgr.table.Col("name").Asc(),
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
}
groupRelationshipTableMgr = &table{

View File

@@ -562,7 +562,7 @@ func (qb *TagStore) All(ctx context.Context) ([]*models.Tag, error) {
table := qb.table()
return qb.getMany(ctx, qb.selectDataset().Order(
table.Col("name").Asc(),
goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc(),
table.Col(idColumn).Asc(),
))
}
@@ -607,7 +607,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType,
if q := findFilter.Q; q != nil && *q != "" {
query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id")
searchColumns := []string{"tags.name", "tag_aliases.alias"}
searchColumns := []string{"tags.name", "tag_aliases.alias", "tags.sort_name"}
query.parseQueryString(searchColumns, *q)
}

View File

@@ -197,13 +197,13 @@ func TestConcurrentExclusiveAndReadTxn(t *testing.T) {
_ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error {
// wait for first thread
if err := waitForOtherThread(c); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
return err
}
defer func() {
if err := signalOtherThread(c); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
}()

View File

@@ -19,8 +19,9 @@ const DefaultMaxRequestsPerMinute = 240
// Client represents the client interface to a stash-box server instance.
type Client struct {
client *graphql.Client
box models.StashBox
client *graphql.Client
httpClient *http.Client
box models.StashBox
maxRequestsPerMinute int
@@ -70,6 +71,7 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
ret := &Client{
box: box,
maxRequestsPerMinute: DefaultMaxRequestsPerMinute,
httpClient: http.DefaultClient,
}
if box.MaxRequestsPerMinute > 0 {
@@ -84,7 +86,7 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
limitRequests := rateLimit(ret.maxRequestsPerMinute)
client := &graphql.Client{
Client: clientv2.NewClient(http.DefaultClient, box.Endpoint, nil, authHeader, limitRequests),
Client: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, limitRequests),
}
ret.client = client
@@ -92,10 +94,6 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
return ret
}
func (c Client) getHTTPClient() *http.Client {
return c.client.Client.Client
}
func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {
return c.client.Me(ctx)
}

View File

@@ -4,7 +4,6 @@ package graphql
import (
"context"
"net/http"
"github.com/Yamashou/gqlgenc/clientv2"
)
@@ -28,7 +27,7 @@ type Client struct {
Client *clientv2.Client
}
func NewClient(cli *http.Client, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {
func NewClient(cli clientv2.HttpClient, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {
return &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)}
}
@@ -493,148 +492,148 @@ func (t *SceneFragment) GetFingerprints() []*FingerprintFragment {
}
type StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *StudioFragment_Parent) GetName() string {
if t == nil {
t = &StudioFragment_Parent{}
}
return t.Name
}
func (t *StudioFragment_Parent) GetID() string {
if t == nil {
t = &StudioFragment_Parent{}
}
return t.ID
}
type SceneFragment_Studio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
func (t *StudioFragment_Parent) GetName() string {
if t == nil {
t = &SceneFragment_Studio_StudioFragment_Parent{}
t = &StudioFragment_Parent{}
}
return t.Name
}
type SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *SceneFragment_Studio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &SceneFragment_Studio_StudioFragment_Parent{}
}
return t.ID
}
type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
t = &SceneFragment_Studio_StudioFragment_Parent{}
}
return t.Name
}
type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.ID
}
type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.Name
}
type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.ID
}
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.Name
}
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.ID
}
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.Name
}
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.ID
}
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.Name
}
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.ID
}
type FindStudio_FindStudio_StudioFragment_Parent struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
}
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &FindStudio_FindStudio_StudioFragment_Parent{}
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
}
return t.Name
}
type FindStudio_FindStudio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetID() string {
if t == nil {
t = &FindStudio_FindStudio_StudioFragment_Parent{}
}
return t.ID
}
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
if t == nil {
t = &FindStudio_FindStudio_StudioFragment_Parent{}
}
return t.Name
}
type Me_Me struct {
Name string "json:\"name\" graphql:\"name\""

View File

@@ -3,6 +3,7 @@
package graphql
import (
"bytes"
"fmt"
"io"
"strconv"
@@ -870,6 +871,23 @@ type SceneDraft struct {
func (SceneDraft) IsDraftData() {}
type SceneDraftInput struct {
ID *string `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Code *string `json:"code,omitempty"`
Details *string `json:"details,omitempty"`
Director *string `json:"director,omitempty"`
URL *string `json:"url,omitempty"`
Urls []string `json:"urls,omitempty"`
Date *string `json:"date,omitempty"`
ProductionDate *string `json:"production_date,omitempty"`
Studio *DraftEntityInput `json:"studio,omitempty"`
Performers []*DraftEntityInput `json:"performers"`
Tags []*DraftEntityInput `json:"tags,omitempty"`
Image *graphql.Upload `json:"image,omitempty"`
Fingerprints []*FingerprintInput `json:"fingerprints"`
}
type SceneEdit struct {
Title *string `json:"title,omitempty"`
Details *string `json:"details,omitempty"`
@@ -1018,6 +1036,7 @@ type StashBoxConfig struct {
GuidelinesURL string `json:"guidelines_url"`
RequireSceneDraft bool `json:"require_scene_draft"`
EditUpdateLimit int `json:"edit_update_limit"`
RequireTagRole bool `json:"require_tag_role"`
}
type StringCriterionInput struct {
@@ -1360,7 +1379,7 @@ func (e BreastTypeEnum) String() string {
return string(e)
}
func (e *BreastTypeEnum) UnmarshalGQL(v interface{}) error {
func (e *BreastTypeEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1377,6 +1396,20 @@ func (e BreastTypeEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *BreastTypeEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e BreastTypeEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type CriterionModifier string
const (
@@ -1422,7 +1455,7 @@ func (e CriterionModifier) String() string {
return string(e)
}
func (e *CriterionModifier) UnmarshalGQL(v interface{}) error {
func (e *CriterionModifier) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1439,6 +1472,20 @@ func (e CriterionModifier) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *CriterionModifier) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e CriterionModifier) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type DateAccuracyEnum string
const (
@@ -1465,7 +1512,7 @@ func (e DateAccuracyEnum) String() string {
return string(e)
}
func (e *DateAccuracyEnum) UnmarshalGQL(v interface{}) error {
func (e *DateAccuracyEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1482,6 +1529,20 @@ func (e DateAccuracyEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *DateAccuracyEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e DateAccuracyEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type EditSortEnum string
const (
@@ -1508,7 +1569,7 @@ func (e EditSortEnum) String() string {
return string(e)
}
func (e *EditSortEnum) UnmarshalGQL(v interface{}) error {
func (e *EditSortEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1525,6 +1586,20 @@ func (e EditSortEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *EditSortEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e EditSortEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type EthnicityEnum string
const (
@@ -1561,7 +1636,7 @@ func (e EthnicityEnum) String() string {
return string(e)
}
func (e *EthnicityEnum) UnmarshalGQL(v interface{}) error {
func (e *EthnicityEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1578,6 +1653,20 @@ func (e EthnicityEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *EthnicityEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e EthnicityEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type EthnicityFilterEnum string
const (
@@ -1616,7 +1705,7 @@ func (e EthnicityFilterEnum) String() string {
return string(e)
}
func (e *EthnicityFilterEnum) UnmarshalGQL(v interface{}) error {
func (e *EthnicityFilterEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1633,6 +1722,20 @@ func (e EthnicityFilterEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *EthnicityFilterEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e EthnicityFilterEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type EyeColorEnum string
const (
@@ -1665,7 +1768,7 @@ func (e EyeColorEnum) String() string {
return string(e)
}
func (e *EyeColorEnum) UnmarshalGQL(v interface{}) error {
func (e *EyeColorEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1682,6 +1785,20 @@ func (e EyeColorEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *EyeColorEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e EyeColorEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type FavoriteFilter string
const (
@@ -1708,7 +1825,7 @@ func (e FavoriteFilter) String() string {
return string(e)
}
func (e *FavoriteFilter) UnmarshalGQL(v interface{}) error {
func (e *FavoriteFilter) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1725,6 +1842,20 @@ func (e FavoriteFilter) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *FavoriteFilter) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e FavoriteFilter) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type FingerprintAlgorithm string
const (
@@ -1751,7 +1882,7 @@ func (e FingerprintAlgorithm) String() string {
return string(e)
}
func (e *FingerprintAlgorithm) UnmarshalGQL(v interface{}) error {
func (e *FingerprintAlgorithm) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1768,6 +1899,20 @@ func (e FingerprintAlgorithm) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *FingerprintAlgorithm) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e FingerprintAlgorithm) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type FingerprintSubmissionType string
const (
@@ -1797,7 +1942,7 @@ func (e FingerprintSubmissionType) String() string {
return string(e)
}
func (e *FingerprintSubmissionType) UnmarshalGQL(v interface{}) error {
func (e *FingerprintSubmissionType) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1814,6 +1959,20 @@ func (e FingerprintSubmissionType) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *FingerprintSubmissionType) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e FingerprintSubmissionType) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type GenderEnum string
const (
@@ -1846,7 +2005,7 @@ func (e GenderEnum) String() string {
return string(e)
}
func (e *GenderEnum) UnmarshalGQL(v interface{}) error {
func (e *GenderEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1863,6 +2022,20 @@ func (e GenderEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *GenderEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e GenderEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type GenderFilterEnum string
const (
@@ -1897,7 +2070,7 @@ func (e GenderFilterEnum) String() string {
return string(e)
}
func (e *GenderFilterEnum) UnmarshalGQL(v interface{}) error {
func (e *GenderFilterEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1914,6 +2087,20 @@ func (e GenderFilterEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *GenderFilterEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e GenderFilterEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type HairColorEnum string
const (
@@ -1954,7 +2141,7 @@ func (e HairColorEnum) String() string {
return string(e)
}
func (e *HairColorEnum) UnmarshalGQL(v interface{}) error {
func (e *HairColorEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -1971,6 +2158,20 @@ func (e HairColorEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *HairColorEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e HairColorEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type NotificationEnum string
const (
@@ -2013,7 +2214,7 @@ func (e NotificationEnum) String() string {
return string(e)
}
func (e *NotificationEnum) UnmarshalGQL(v interface{}) error {
func (e *NotificationEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2030,6 +2231,20 @@ func (e NotificationEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *NotificationEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e NotificationEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type OperationEnum string
const (
@@ -2058,7 +2273,7 @@ func (e OperationEnum) String() string {
return string(e)
}
func (e *OperationEnum) UnmarshalGQL(v interface{}) error {
func (e *OperationEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2075,6 +2290,20 @@ func (e OperationEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *OperationEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e OperationEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type PerformerSortEnum string
const (
@@ -2113,7 +2342,7 @@ func (e PerformerSortEnum) String() string {
return string(e)
}
func (e *PerformerSortEnum) UnmarshalGQL(v interface{}) error {
func (e *PerformerSortEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2130,6 +2359,20 @@ func (e PerformerSortEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *PerformerSortEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e PerformerSortEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type RoleEnum string
const (
@@ -2143,6 +2386,8 @@ const (
// May grant and rescind invite tokens and resind invite keys
RoleEnumManageInvites RoleEnum = "MANAGE_INVITES"
RoleEnumBot RoleEnum = "BOT"
RoleEnumReadOnly RoleEnum = "READ_ONLY"
RoleEnumEditTags RoleEnum = "EDIT_TAGS"
)
var AllRoleEnum = []RoleEnum{
@@ -2154,11 +2399,13 @@ var AllRoleEnum = []RoleEnum{
RoleEnumInvite,
RoleEnumManageInvites,
RoleEnumBot,
RoleEnumReadOnly,
RoleEnumEditTags,
}
func (e RoleEnum) IsValid() bool {
switch e {
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot:
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags:
return true
}
return false
@@ -2168,7 +2415,7 @@ func (e RoleEnum) String() string {
return string(e)
}
func (e *RoleEnum) UnmarshalGQL(v interface{}) error {
func (e *RoleEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2185,6 +2432,20 @@ func (e RoleEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *RoleEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e RoleEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type SceneSortEnum string
const (
@@ -2215,7 +2476,7 @@ func (e SceneSortEnum) String() string {
return string(e)
}
func (e *SceneSortEnum) UnmarshalGQL(v interface{}) error {
func (e *SceneSortEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2232,6 +2493,20 @@ func (e SceneSortEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *SceneSortEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e SceneSortEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type SortDirectionEnum string
const (
@@ -2256,7 +2531,7 @@ func (e SortDirectionEnum) String() string {
return string(e)
}
func (e *SortDirectionEnum) UnmarshalGQL(v interface{}) error {
func (e *SortDirectionEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2273,6 +2548,20 @@ func (e SortDirectionEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *SortDirectionEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e SortDirectionEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type StudioSortEnum string
const (
@@ -2299,7 +2588,7 @@ func (e StudioSortEnum) String() string {
return string(e)
}
func (e *StudioSortEnum) UnmarshalGQL(v interface{}) error {
func (e *StudioSortEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2316,6 +2605,20 @@ func (e StudioSortEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *StudioSortEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e StudioSortEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type TagGroupEnum string
const (
@@ -2342,7 +2645,7 @@ func (e TagGroupEnum) String() string {
return string(e)
}
func (e *TagGroupEnum) UnmarshalGQL(v interface{}) error {
func (e *TagGroupEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2359,6 +2662,20 @@ func (e TagGroupEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *TagGroupEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e TagGroupEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type TagSortEnum string
const (
@@ -2385,7 +2702,7 @@ func (e TagSortEnum) String() string {
return string(e)
}
func (e *TagSortEnum) UnmarshalGQL(v interface{}) error {
func (e *TagSortEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2402,6 +2719,20 @@ func (e TagSortEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *TagSortEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e TagSortEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type TargetTypeEnum string
const (
@@ -2430,7 +2761,7 @@ func (e TargetTypeEnum) String() string {
return string(e)
}
func (e *TargetTypeEnum) UnmarshalGQL(v interface{}) error {
func (e *TargetTypeEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2447,6 +2778,20 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *TargetTypeEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e TargetTypeEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type UserChangeEmailStatus string
const (
@@ -2479,7 +2824,7 @@ func (e UserChangeEmailStatus) String() string {
return string(e)
}
func (e *UserChangeEmailStatus) UnmarshalGQL(v interface{}) error {
func (e *UserChangeEmailStatus) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2496,6 +2841,20 @@ func (e UserChangeEmailStatus) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *UserChangeEmailStatus) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e UserChangeEmailStatus) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type UserVotedFilterEnum string
const (
@@ -2524,7 +2883,7 @@ func (e UserVotedFilterEnum) String() string {
return string(e)
}
func (e *UserVotedFilterEnum) UnmarshalGQL(v interface{}) error {
func (e *UserVotedFilterEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2541,6 +2900,20 @@ func (e UserVotedFilterEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *UserVotedFilterEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e UserVotedFilterEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type ValidSiteTypeEnum string
const (
@@ -2567,7 +2940,7 @@ func (e ValidSiteTypeEnum) String() string {
return string(e)
}
func (e *ValidSiteTypeEnum) UnmarshalGQL(v interface{}) error {
func (e *ValidSiteTypeEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2584,6 +2957,20 @@ func (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *ValidSiteTypeEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e ValidSiteTypeEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type VoteStatusEnum string
const (
@@ -2618,7 +3005,7 @@ func (e VoteStatusEnum) String() string {
return string(e)
}
func (e *VoteStatusEnum) UnmarshalGQL(v interface{}) error {
func (e *VoteStatusEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2635,6 +3022,20 @@ func (e VoteStatusEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *VoteStatusEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e VoteStatusEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type VoteTypeEnum string
const (
@@ -2667,7 +3068,7 @@ func (e VoteTypeEnum) String() string {
return string(e)
}
func (e *VoteTypeEnum) UnmarshalGQL(v interface{}) error {
func (e *VoteTypeEnum) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
@@ -2683,3 +3084,17 @@ func (e *VoteTypeEnum) UnmarshalGQL(v interface{}) error {
func (e VoteTypeEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *VoteTypeEnum) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e VoteTypeEnum) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}

View File

@@ -1,20 +0,0 @@
package graphql
import "github.com/99designs/gqlgen/graphql"
// Override for generated struct due to mistaken omitempty
// https://github.com/Yamashou/gqlgenc/issues/77
type SceneDraftInput struct {
ID *string `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Code *string `json:"code,omitempty"`
Details *string `json:"details,omitempty"`
Director *string `json:"director,omitempty"`
URL *string `json:"url,omitempty"`
Date *string `json:"date,omitempty"`
Studio *DraftEntityInput `json:"studio,omitempty"`
Performers []*DraftEntityInput `json:"performers"`
Tags []*DraftEntityInput `json:"tags,omitempty"`
Image *graphql.Upload `json:"image,omitempty"`
Fingerprints []*FingerprintInput `json:"fingerprints"`
}

View File

@@ -6,7 +6,6 @@ import (
"errors"
"io"
"net/http"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -183,7 +182,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
if len(s.Images) > 0 {
// TODO - #454 code sorts images by aspect ratio according to a wanted
// orientation. I'm just grabbing the first for now
ss.Image = getFirstImage(ctx, c.getHTTPClient(), s.Images)
ss.Image = getFirstImage(ctx, c.httpClient, s.Images)
}
ss.URLs = make([]string, len(s.Urls))
@@ -288,11 +287,8 @@ func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput {
if scene.Director != "" {
draft.Director = &scene.Director
}
// TODO - draft does not accept multiple URLs. Use single URL for now.
if len(scene.URLs.List()) > 0 {
url := strings.TrimSpace(scene.URLs.List()[0])
draft.URL = &url
}
draft.Urls = scene.URLs.List()
if scene.Date != nil {
v := scene.Date.String()
draft.Date = &v

View File

@@ -8,6 +8,7 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
)
@@ -55,6 +56,28 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag)
return &newTagJSON, nil
}
// GetDependentTagIDs returns a slice of unique tag IDs that this tag references.
func GetDependentTagIDs(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) ([]int, error) {
var ret []int
parents, err := reader.FindByChildTagID(ctx, tag.ID)
if err != nil {
return nil, fmt.Errorf("error getting parents: %v", err)
}
for _, tt := range parents {
toAdd, err := GetDependentTagIDs(ctx, reader, tt)
if err != nil {
return nil, fmt.Errorf("error getting dependent tag IDs: %v", err)
}
ret = sliceutil.AppendUniques(ret, toAdd)
ret = sliceutil.AppendUnique(ret, tt.ID)
}
return ret, nil
}
func GetIDs(tags []*models.Tag) []int {
var results []int
for _, tag := range tags {

View File

@@ -28,6 +28,8 @@ fragment GroupData on Group {
back_image_path
scene_count
scene_count_all: scene_count(depth: -1)
performer_count
performer_count_all: performer_count(depth: -1)
sub_group_count
sub_group_count_all: sub_group_count(depth: -1)

View File

@@ -128,7 +128,7 @@
"terser": "^5.9.0",
"ts-node": "^10.9.1",
"typescript": "~4.8.4",
"vite": "^4.5.6",
"vite": "^4.5.14",
"vite-plugin-compression": "^0.5.1",
"vite-tsconfig-paths": "^4.0.5"
}

View File

@@ -6,7 +6,7 @@ import {
useLocation,
useRouteMatch,
} from "react-router-dom";
import { IntlProvider, CustomFormats } from "react-intl";
import { IntlProvider, CustomFormats, FormattedMessage } from "react-intl";
import { Helmet } from "react-helmet";
import cloneDeep from "lodash-es/cloneDeep";
import mergeWith from "lodash-es/mergeWith";
@@ -49,6 +49,7 @@ import { ConnectionMonitor } from "./ConnectionMonitor";
import { PatchFunction } from "./patch";
import moment from "moment/min/moment-with-locales";
import { ErrorMessage } from "./components/Shared/ErrorMessage";
const Performers = lazyComponent(
() => import("./components/Performers/Performers")
@@ -102,6 +103,14 @@ const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
}
) as React.FC;
const MainContainer: React.FC = ({ children }) => {
return (
<div className={`main container-fluid ${appleRendering ? "apple" : ""}`}>
{children}
</div>
);
};
function translateLanguageLocale(l: string) {
// intl doesn't support all locales, so we need to map some to supported ones
switch (l) {
@@ -287,14 +296,40 @@ export const App: React.FC = () => {
const titleProps = makeTitleProps();
if (!messages) {
return null;
}
if (config.error) {
return (
<IntlProvider
locale={intlLanguage}
messages={messages}
formats={intlFormats}
>
<MainContainer>
<ErrorMessage
message={
<FormattedMessage
id="errors.loading_type"
values={{ type: "configuration" }}
/>
}
error={config.error.message}
/>
</MainContainer>
</IntlProvider>
);
}
return (
<ErrorBoundary>
{messages ? (
<IntlProvider
locale={intlLanguage}
messages={messages}
formats={intlFormats}
>
<IntlProvider
locale={intlLanguage}
messages={messages}
formats={intlFormats}
>
<ToastProvider>
<PluginsLoader>
<AppContainer>
<ConfigurationProvider
@@ -302,31 +337,23 @@ export const App: React.FC = () => {
loading={config.loading}
>
{maybeRenderReleaseNotes()}
<ToastProvider>
<ConnectionMonitor />
<Suspense fallback={<LoadingIndicator />}>
<LightboxProvider>
<ManualProvider>
<InteractiveProvider>
<Helmet {...titleProps} />
{maybeRenderNavbar()}
<div
className={`main container-fluid ${
appleRendering ? "apple" : ""
}`}
>
{renderContent()}
</div>
</InteractiveProvider>
</ManualProvider>
</LightboxProvider>
</Suspense>
</ToastProvider>
<ConnectionMonitor />
<Suspense fallback={<LoadingIndicator />}>
<LightboxProvider>
<ManualProvider>
<InteractiveProvider>
<Helmet {...titleProps} />
{maybeRenderNavbar()}
<MainContainer>{renderContent()}</MainContainer>
</InteractiveProvider>
</ManualProvider>
</LightboxProvider>
</Suspense>
</ConfigurationProvider>
</AppContainer>
</PluginsLoader>
</IntlProvider>
) : null}
</ToastProvider>
</IntlProvider>
</ErrorBoundary>
);
};

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