Compare commits

...

117 Commits

Author SHA1 Message Date
WithoutPants
3acb21d4e1 Merge pull request #1390 from stashapp/develop
Merge to master for 0.7
2021-05-15 16:17:08 +10:00
WithoutPants
e0623eb302 Fix initial setup issue issues (#1380)
* Refactor initial setup behaviour
* Adjust wizard
2021-05-13 22:15:21 +10:00
InfiniteTF
5a37e6cf52 Add search modal for stash-box performer scraper (#1373)
* Cache tagger fingerprint lookups between renders
* Show search modal for stash-box performer scraper
2021-05-10 09:33:08 +10:00
WithoutPants
3f97b3a1cb Remove unnecessary graphql fields (#1370)
* Remove unnecessary graphql fields
* Optimise joined queries
* Tag resolver query optimisation
2021-05-09 19:25:57 +10:00
InfiniteTF
81cf3d3337 Fix sorting of tagger fingerprint matches (#1369) 2021-05-07 13:00:29 +10:00
WithoutPants
bdac352250 Update demo video link 2021-05-05 14:14:39 +10:00
InfiniteTF
31981d4116 Add in-memory screenshot generation for sprites and phash (#1316) 2021-05-05 13:22:05 +10:00
WithoutPants
08c294414d Fix initial plugins/scrapers paths when initialising in home directory (#1358)
* Only set default values once config file present
* Fix configLocation presentation when using home
2021-05-04 07:42:33 +10:00
bnkai
2ab42e9cd3 Populate image/gallery title during scan (#1359) 2021-05-03 14:21:51 +10:00
InfiniteTF
896c3874af Stash-Box Performer Tagger (#1277)
* Add bulk stash-box performer task
* Add stash-box performer scraper to scrape with menu
2021-05-03 14:21:20 +10:00
WithoutPants
a3609079bb Autotag support for images and galleries (#1345)
* Add compound queries for images and galleries
* Implement image and gallery auto tagging
2021-05-03 13:09:46 +10:00
Jeremy Meyers
2c52fd711b Several syntactical and content changes (#1347) 2021-05-03 12:49:15 +10:00
WithoutPants
d7a04ced00 Revert always show preview videos on small devices (#1340) 2021-05-03 08:23:19 +10:00
WithoutPants
3f0c965400 Fix development releases 2021-05-01 11:19:21 +10:00
InfiniteTF
4a04dfe4a2 Fix scene tagger bugs (#1357) 2021-04-30 14:55:18 +10:00
bnkai
597576f5e6 Get distinct values from scraper (#1338)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-04-29 11:38:55 +10:00
julien0221
502d99de1b Added new filters (date and title) to galleries (#1344)
* Added new filters (date and title) to galleries
* Added image_count on filter for galleries
2021-04-29 11:31:51 +10:00
julien0221
4d13e8d7f7 Fixed rating filter on studios (#1342) 2021-04-29 11:20:59 +10:00
WithoutPants
210feb4034 Apply scene queuing for all scene listviews (#1332)
* Apply queue population for all scene list views
* Add missing localisation strings
2021-04-28 09:27:47 +10:00
WithoutPants
fe0c5615a6 Memo-ise list hook functions (#1329) 2021-04-28 09:12:35 +10:00
julien0221
70b66d91a0 Added rating to performers and studios (#1308) 2021-04-26 13:48:32 +10:00
stg-annon
eefc628cf0 update docs to match current functionality (#1339)
was wondering why `per_page:0` was not working it seems to have been updated to `per_page:-1` to return all results
2021-04-26 13:37:31 +10:00
bnkai
aedadc3857 Add lbToKg pp action to the scraper (#1337) 2021-04-26 13:31:25 +10:00
WithoutPants
2eb2d865dc Auto tag rewrite (#1324) 2021-04-26 12:51:31 +10:00
julien0221
f66010a367 Fixed 0 for weight when a new performer is created and fixed the search is null (#1336) 2021-04-26 12:13:50 +10:00
bnkai
7836a37d6e Fix various generate issues (#1322)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-04-22 13:51:51 +10:00
WithoutPants
bf3f658091 Movie scene sort (#1325)
* Add movie_scene_number sort order
* Sort movie scenes by scene number by default
2021-04-22 12:22:51 +10:00
peolic
1767390e0d Overwrite new performer image after clearing current image (#1321) 2021-04-21 14:19:40 +10:00
Jeremy Meyers
79a180ba73 Fix README formatting (#1328) 2021-04-21 10:33:33 +10:00
WithoutPants
8705f78591 Duplicate checker UI improvements (#1309)
* Add tools settings page
* Add tools and move dupe checker
* Make negative number get all
* Show missing phashes
* Add multi-edit button
* Show scene details
2021-04-20 18:58:28 +10:00
WithoutPants
39512e1452 Separate UI (#1299)
* Add custom_ui_location to serve UI from filesystem
2021-04-20 17:12:40 +10:00
peolic
9200f167bf Add studio *_count filters and sort options (#1307) 2021-04-20 16:48:36 +10:00
peolic
1759a99f65 Fix creating performer from gallery scrape dialog (#1320) 2021-04-18 13:22:02 +02:00
InfiniteTF
cd0a9a1d62 Fix performer scraping (#1314) 2021-04-17 08:52:18 +10:00
InfiniteTF
e3fa8f7b24 Fix fingerprint search when scene only has phash match (#1312) 2021-04-16 19:15:47 +02:00
bnkai
a5e9e7abce Update README with currently used ffmpeg URLs (#1304) 2021-04-16 16:20:20 +10:00
julien0221
d673c4ce03 added details, deathdate, hair color, weight to performers and added details to studios (#1274)
* added details to performers and studios
* added deathdate, hair_color and weight to performers
* Simplify performer/studio create mutations
* Add changelog and recategorised

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-04-16 16:06:35 +10:00
bnkai
cd6b6b74eb Add http headers support to scraper (#1273) 2021-04-16 15:42:56 +10:00
WithoutPants
0b40017b09 Sort performers in popover and card views (#1294) 2021-04-15 11:33:20 +10:00
InfiniteTF
e59018acfb Skip validation of existing paths when adding new paths (#1301) 2021-04-15 11:01:31 +10:00
WithoutPants
ea54a67798 Add scene/image/gallery popover count buttons for performer/studio/tag cards (#1293)
* Add counts to graphql schema
* Add count resolvers and query refactor
* Add count popover buttons
2021-04-15 10:46:31 +10:00
InfiniteTF
e6aaa196f3 Load settings panels on demand (#1302) 2021-04-15 10:01:44 +10:00
stashist
34f114faff Simplify GH build pipeline. (#1268)
The toolchain is already bundled in the stashapp/compiler image.
Rather than introducing a second one via GH actions standardize on that
one instead.

Also
* Clear up what "Cross Compile" actually does
* Still pull stashapp/compiler separately for easier debugability.
2021-04-13 16:11:19 +10:00
Elad Lachmi
f443223d16 [Feature] Added slideshow to gallery in wall display mode (#1224) 2021-04-13 14:59:37 +10:00
julien0221
6a4421f8e1 Whitespace is not trimmed from the end of query strings (#1263)
* fixed whitespace not trimmed query string
* fixed whitespace trimming on backend
* added query trim tests and fixed double space
2021-04-13 10:32:52 +10:00
WithoutPants
f5dc654f6b Support streaming via API key (#1279)
* Support api key via url query parameter
* Add api key to stream URL
2021-04-12 11:05:49 +10:00
WithoutPants
f6ffda7504 Setup and migration UI refactor (#1190)
* Make config instance-based
* Remove config dependency in paths
* Refactor config init
* Allow startup without database
* Get system status at UI initialise
* Add setup wizard
* Cache and Metadata optional. Database mandatory
* Handle metadata not set during full import/export
* Add links
* Remove config check middleware
* Stash not mandatory
* Panic on missing mandatory config fields
* Redirect setup to main page if setup not required
* Add migration UI
* Remove unused stuff
* Move UI initialisation into App
* Don't create metadata paths on RefreshConfig
* Add folder selector for generated in setup
* Env variable to set and create config file.
Make docker images use a fixed config file.
* Set config file during setup
2021-04-12 09:31:33 +10:00
InfiniteTF
c38660d209 Add phash generation and dupe checking (#1158) 2021-04-12 09:04:40 +10:00
WithoutPants
a2582047ca Join count filter criteria (#1254)
Co-authored-by: mrbrdo <mrbrdo@gmail.com>
Co-authored-by: peolic <66393006+peolic@users.noreply.github.com>
2021-04-09 18:46:00 +10:00
peolic
6a0c73b3a1 Remove external resource from Login page (#1275) 2021-04-09 16:42:52 +10:00
julien0221
d042ec42ee Added Auto scroll user back to the top when page navigation is clicked (#1270) 2021-04-09 15:27:48 +10:00
julien0221
25311247ed added an url filter option in scenes (#1266)
* added an url filter option in scenes
* added url filter on gallery, movies, performers and studios
* Add empty string filter to stringCriterionHandler
* Add unit tests

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-04-09 15:05:11 +10:00
peolic
60af076fff Fix "Clear Image" button (#1249)
* Fix "Clear Image" button (Performer Edit)
* Fix "Clear Image" button (New Performer)
* Fix "Clear Image" button (Edit Studio)
* Fix "Clear Image" button (Edit Tag)
2021-04-09 14:41:28 +10:00
stashist
4462b3cc8e Handle /healthz for liveness checks. (#1264) 2021-04-09 10:06:02 +10:00
bnkai
2edcdeaeb9 Support today, yesterday when using parseDate in scrapers (#1261) 2021-04-07 09:09:04 +10:00
peolic
d8ba4a08c0 Update various GQL image fields' comments (#1271) 2021-04-07 08:58:41 +10:00
julien0221
72b027a887 Added random for studios, movies and tags (#1250) 2021-04-07 08:32:20 +10:00
peolic
7671465334 Fix performer age timezone issues (#1251)
* Parse date string manually
2021-04-02 10:09:10 +11:00
bnkai
2c1300cae0 Upgrade x/image (#1248) 2021-04-01 16:43:42 +11:00
peolic
35718ce59a Disable sounds on scene/marker wall previews by default (#1247) 2021-04-01 16:10:56 +11:00
WithoutPants
1412b554a0 Api key (#1241) 2021-03-31 16:08:52 +11:00
UncleRoger33
2c2e56d33a Add format to performer field placeholder (#1232)
* Update README.md

Extra letter "p" in the title removed and "(FAQ)" suffix added.

Co-authored-by: peolic <66393006+peolic@users.noreply.github.com>
2021-03-31 15:55:15 +11:00
WithoutPants
ccb96c3795 Movie UI refresh (#1227)
* Improve movie UI
* Return nil when no back image set
2021-03-31 14:54:58 +11:00
WithoutPants
d5e9030768 Scene queuing (#1214)
* Add missing localisation strings
* Ignore container error in scene streams
* Implement missing FindScenes by ID
2021-03-31 14:36:11 +11:00
peolic
496900df42 Fix inaccurate age calculation (#1237) 2021-03-30 14:25:56 +11:00
peolic
7acae34ed4 Fix performer search columns (#1236)
* Fix performer search columns
* Update changelog
* Move changelog to new version

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-03-30 14:04:57 +11:00
WithoutPants
d30a68567e Update libraries and regenerate yarn.lock (#1231)
* Update and regenerate yarn.lock
* Remove eslint check for react function order
2021-03-30 12:33:57 +11:00
WithoutPants
8a3d2e8e06 Merge pull request #1240 from stashapp/master
Merge master back to develop for tag
2021-03-29 14:15:24 +11:00
WithoutPants
cad96b7872 Update build.yml 2021-03-29 12:59:03 +11:00
WithoutPants
de538be79c Merge pull request #1239 from stashapp/develop
Merge to master for 0.6
2021-03-29 12:05:59 +11:00
bnkai
4299f113e0 Fix Freeones search (#1230) 2021-03-25 10:01:56 +11:00
WithoutPants
b39fe3ed2b Correct tag link for gallery and images tags (#1221) 2021-03-24 11:03:52 +11:00
bnkai
68d4a4fe42 Add User Agent to image download reqs (#1222) 2021-03-24 08:12:11 +11:00
WithoutPants
73a8bad1bc Add missing tag writer for performer importer (#1213) 2021-03-18 21:45:31 +11:00
WithoutPants
960f843259 Fix filter building with sub-filters (#1212)
* Fix bracketing on sub-filters
* Add vscode to gitignore
2021-03-18 21:45:18 +11:00
WithoutPants
d93011a828 Add write mutex and max connection lifetime (#1211) 2021-03-18 21:45:01 +11:00
bnkai
215737d6c5 Add configFilePath and scrapersPath to configuration query (#1205) 2021-03-18 11:07:56 +11:00
InfiniteTF
6369a500b3 Update go-sqlite3 to 1.14.6 (#1209) 2021-03-17 11:17:27 +11:00
InfiniteTF
58243cded0 Remove slim graphql endpoints (#1207) 2021-03-17 11:17:01 +11:00
WithoutPants
7e6127975d Handle NULL in regex criteria (#1208) 2021-03-16 11:13:14 +11:00
WithoutPants
f7cd9cb00d Performer edit UI further updates (#1206)
* Make performer edit single column
* Make aliases textarea
* Add textareas to performer scrape dialog
2021-03-16 11:10:49 +11:00
InfiniteTF
ecac7a8013 Add timestamp suffix to all image urls (#1200) 2021-03-13 11:49:20 +11:00
InfiniteTF
a619b9dd48 Improve performer scrape search modal (#1198) 2021-03-13 11:48:04 +11:00
WithoutPants
b63e8ef929 Add Galleries tab to Tag details page (#1195) 2021-03-12 08:27:59 +11:00
WithoutPants
23d85655a8 Refactor tag query (#1194) 2021-03-11 22:17:37 +11:00
Jeremy Meyers
53cb9a1b7b Documentation Updates to README.md (#1179) 2021-03-11 15:56:22 +11:00
WithoutPants
a3a531d122 Fix IsPathInDir (#1192) 2021-03-11 13:37:13 +11:00
WithoutPants
55aee21cff Upload Image from url (#1193) 2021-03-11 12:56:34 +11:00
WithoutPants
b3966b3c76 Remove streaming resolutions over max configured (#1187) 2021-03-11 12:51:42 +11:00
SpedNSFW
b647a75151 Toggle visibility of unmatched scenes in Tagger (#1176) 2021-03-10 14:26:48 +11:00
gitgiggety
baeeb2d649 Hide create option when searching in filters (#1169)
Hide the "Create '<term>'" option when using filters. This as it doesn't make sense to create a new performer/tag/studio in the context of searching for one. As obviously there won't be any results after searching as it has just been created and not assigned to anything yet.
2021-03-10 14:08:45 +11:00
gitgiggety
f794c6ae45 Add scrape gallery from fragment to UI (#1166) 2021-03-10 13:47:22 +11:00
WithoutPants
a0676d5c30 Performer tags (#1132)
* Add scraping support for performer tags
* Add performer count to tag cards
* Refactor sqlite test setup
* Add performer tag filtering in gallery and image
* Add bulk update performer
* Add Performers tab to tag page
* Add count filters and sort bys for tags
* Move scene count to icon in performer card #1148
2021-03-10 12:25:51 +11:00
InfiniteTF
698e21a04f Fix scene form layout bugs (#1177) 2021-03-07 09:19:30 +11:00
WithoutPants
9d1b716f48 Performer UI improvements (#1168)
* Refactor performer edit page with Formik
* Upgrade react-scripts
* Make eslint errors warnings in dev environment
* Refactor performer details
* Prompt if leaving dirty performer edit page
2021-03-05 15:46:20 +11:00
InfiniteTF
c2c06d8f8d Fix tagger parsing bugs (#1172) 2021-03-05 15:03:08 +11:00
WithoutPants
e5c5cde974 Detect cover images in subdirectories (#1144) 2021-03-04 10:52:45 +11:00
WithoutPants
16da483674 Change gallery icon to images (#1167) 2021-03-04 08:27:42 +11:00
SpedNSFW
bde5d07afb Find correct python executable (#1156)
* find correct python executable
For script scrapers using python, both python and python3 are valid depending on the OS and running environment. To save users from having any issues, this change will find the correct executable for them.

Co-authored-by: bnkai <bnkai@users.noreply.github.com>
2021-03-03 08:01:01 +11:00
WithoutPants
1850a2b533 Add sqlite filter builder. Add AND, OR, NOT filters to scene filter (#1115)
* Add resolution enum extension
* Add filter builder
* Use filterBuilder for scene query
* Optimise joins
* Add binary operators to scene query
* Use Query for auto-tag
2021-03-02 11:27:36 +11:00
bnkai
117e6326db Expose url for URLReplace in JSON scrapeByURL and scrapeByFragment (#1150)
* Expose url for URLReplace in JSON scrapeByURL and scrapeByFragment
* Apply queryURLReplace to xpath scrapers

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-03-02 09:19:56 +11:00
bnkai
fe990e00c1 Check if gallery is already associated during scanning (#1154) 2021-03-01 16:37:55 +11:00
gitgiggety
4825de7d35 Fix SQL error in 8K resolution filter (#1159) 2021-03-01 16:06:57 +11:00
WithoutPants
7cfff46d02 Gallery filter fix (#1147)
* Fix gallery performer and tags filters
* Add unit tests
2021-03-01 13:30:40 +11:00
bnkai
44ea777019 Add check version support for armv7, arm64 (#1142) 2021-03-01 12:37:46 +11:00
bnkai
711496e9f4 Add full timestamp for console/file logging (#1130) 2021-03-01 12:28:09 +11:00
InfiniteTF
01da28010d Gallery view persistence (#1105)
* Persist gallery image view separately from primary image view
2021-03-01 12:10:05 +11:00
InfiniteTF
7e0db2aad4 Change NULL filters to filter empty strings as well (#1137) 2021-03-01 11:48:25 +11:00
bnkai
144cd6e4f2 Skip insecure certificates check when scraping (#1120)
* Ignore insecure certificates when scraping
* add ScraperCertCheck to scraper config options
2021-03-01 11:47:39 +11:00
WithoutPants
a9ac176e91 Mobile UI improvements (#1104)
* Use dropdown for o-counter instead of hover
* Always show previews on non-hoverable device
* Add IntersectionObserver polyfill
* Prevent video previews playing fullscreen
2021-02-26 16:13:34 +11:00
WithoutPants
af6b21a428 Update latest_develop tag 2021-02-24 14:36:39 +11:00
WithoutPants
8ec25ef161 Add commitish for dev release 2021-02-24 13:48:56 +11:00
WithoutPants
777956f0ab GitHub actions (#1146) 2021-02-24 11:26:48 +11:00
SpedNSFW
acbdee76de Random strings for cookie values (#1122) 2021-02-23 13:40:43 +11:00
InfiniteTF
14230d7b52 Enable keepalive for websocket connection (#1134) 2021-02-23 13:03:02 +11:00
WithoutPants
f7a8899d90 Add rescan option to overflow dropdown (#1119)
* Make scan options optional
* Add scene rescan
* Add image rescan
* Add gallery rescan
* Add changelog
2021-02-23 12:56:01 +11:00
WithoutPants
7fbb92d071 Merge master back to develop for tag 2021-02-23 10:46:28 +11:00
472 changed files with 56383 additions and 25281 deletions

144
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,144 @@
name: Build
on:
push:
branches: [ develop, master ]
pull_request:
branches: [ develop ]
release:
types: [ published ]
env:
COMPILER_IMAGE: stashapp/compiler:4
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Checkout
run: git fetch --prune --unshallow --tags
- name: Pull compiler image
run: docker pull $COMPILER_IMAGE
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node_modules
with:
path: ui/v2.5/node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/yarn.lock') }}
- name: Pre-install
run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make pre-ui"
- name: Generate
run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make generate"
# TODO: Replace with `make validate` once `revive` is bundled in COMPILER_IMAGE
- name: Validate
run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make ui-validate fmt-check vet it"
- name: Build UI
run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make ui-only"
- name: Compile for all supported platforms
run: ./scripts/cross-compile.sh
- name: Generate checksums
run: |
git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1
sha1sum dist/stash-* | sed 's/dist\///g' | tee -a CHECKSUMS_SHA1
echo "STASH_VERSION=$(git describe --tags --exclude latest_develop)" >> $GITHUB_ENV
echo "RELEASE_DATE=$(date +'%Y-%m-%d %H:%M:%S %Z')" >> $GITHUB_ENV
- name: Upload Windows binary
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v2
with:
name: stash-win.exe
path: dist/stash-win.exe
- name: Upload OSX binary
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v2
with:
name: stash-osx
path: dist/stash-osx
- name: Upload Linux binary
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v2
with:
name: stash-linux
path: dist/stash-linux
- name: Update latest_develop tag
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
run : git tag -f latest_develop; git push -f --tags
- name: Development Release
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
uses: marvinpinto/action-automatic-releases@v1.1.2
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: true
automatic_release_tag: latest_develop
title: "${{ env.STASH_VERSION }}: Latest development build"
files: |
dist/stash-osx
dist/stash-win.exe
dist/stash-linux
dist/stash-linux-arm64v8
dist/stash-linux-arm32v7
dist/stash-pi
CHECKSUMS_SHA1
- name: Master release
if: ${{ github.event_name == 'release' && github.ref != 'refs/tags/latest_develop' }}
uses: meeDamian/github-release@2.0
with:
token: "${{ secrets.GITHUB_TOKEN }}"
allow_override: true
files: |
dist/stash-osx
dist/stash-win.exe
dist/stash-linux
dist/stash-linux-arm64v8
dist/stash-linux-arm32v7
dist/stash-pi
CHECKSUMS_SHA1
gzip: false
- name: Development Docker
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
env:
DOCKER_CLI_EXPERIMENTAL: enabled
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
docker info
docker buildx create --name builder --use
docker buildx inspect --bootstrap
docker buildx ls
bash ./docker/ci/x86_64/docker_push.sh development
- name: Release Docker
if: ${{ github.event_name == 'release' && github.ref != 'refs/tags/latest_develop' }}
env:
DOCKER_CLI_EXPERIMENTAL: enabled
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
docker info
docker buildx create --name builder --use
docker buildx inspect --bootstrap
docker buildx ls
bash ./docker/ci/x86_64/docker_push.sh latest

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ ui/v2.5/src/core/generated-*.tsx
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
.vscode
# Generated files
.idea/**/contentModel.xml

View File

@@ -6,45 +6,61 @@
https://stashapp.cc
**Stash is a Go app which organizes and serves your porn.**
**Stash is a locally hosted web-based app written in Go which organizes and serves your porn.**
See a demo [here](https://vimeo.com/275537038) (password is stashapp).
* It can gather information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers.
* It supports a wide variety of both video and image formats.
* You can tag videos and find them later.
* It provides statistics about performers, tags, studios and other things.
An in-app manual is available, and the manual pages can be viewed [here](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en).
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
# Docker install
For further information you can [read the in-app manual](ui/v2.5/src/docs/en).
# Installing stash
## via Docker
Follow [this README.md in the docker directory.](docker/production/README.md)
# Bare-metal Install
## Pre-Compiled Binaries
Stash supports macOS, Windows, and Linux. Download the [latest release here](https://github.com/stashapp/stash/releases).
The Stash server runs on macOS, Windows, and Linux. Download the [latest release here](https://github.com/stashapp/stash/releases).
Run the executable (double click the exe on windows or run `./stash-osx` / `./stash-linux` from the terminal on macOS / Linux) and navigate to either https://localhost:9999 or http://localhost:9999 to get started.
*Note for Windows users:* Running the app might present a security prompt since the binary isn't signed yet. Just click more info and then the "run anyway" button.
*Note for Windows users:* Running the app might present a security prompt since the binary isn't yet signed. Bypass this by clicking "more info" and then the "run anyway" button.
#### FFMPEG
If stash is unable to find or download FFMPEG then download it yourself from the link for your platform:
* [macOS](https://ffmpeg.zeranoe.com/builds/macos64/static/ffmpeg-4.0-macos64-static.zip)
* [Windows](https://ffmpeg.zeranoe.com/builds/win64/static/ffmpeg-4.0-win64-static.zip)
* [Linux](https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz)
* [macOS ffmpeg](https://evermeet.cx/ffmpeg/ffmpeg-4.3.1.zip), [macOS ffprobe](https://evermeet.cx/ffmpeg/ffprobe-4.3.1.zip)
* [Windows](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip)
* [Linux](https://www.johnvansickle.com/ffmpeg/)
The `ffmpeg(.exe)` and `ffprobe(.exe)` files should be placed in `~/.stash` on macOS / Linux or `C:\Users\YourUsername\.stash` on Windows.
# Usage
## Quickstart Guide
1) Download and install Stash and its dependencies
2) Run Stash. It will prompt you for some configuration options and a directory to index (you can also do this step afterward)
3) After configuration, launch your web browser and navigate to the URL shown within the Stash app.
**Note that Stash does not currently retrieve and organize information about your entire library automatically.** You will need to help it along through the use of [scrapers](blob/develop/ui/v2.5/src/docs/en/Scraping.md). The Stash community has developed scrapers for many popular data sources which can be downloaded and installed from [this repository](https://github.com/stashapp/CommunityScrapers).
The simplest way to tag a large number of files is by using the [Tagger](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Tagger.md) which uses filename keywords to help identify the file and pull in scene and performer information from our stash-box database. Note that this data source is not comprehensive and you may need to use the scrapers to identify some of your media.
## CLI
Stash provides some command line options. See what is currently available by running `stash --help`.
Stash runs as a command-line app and local web server. There are some command-line options available, which you can see by running `stash --help`.
For example, to run stash locally on port 80 run it like this (OSX / Linux) `stash --host 127.0.0.1 --port 80`
## SSL (HTTPS)
Stash supports HTTPS with some additional work. First you must generate a SSL certificate and key combo. Here is an example using openssl:
Stash can run over HTTPS with some additional work. First you must generate a SSL certificate and key combo. Here is an example using openssl:
`openssl req -x509 -newkey rsa:4096 -sha256 -days 7300 -nodes -keyout stash.key -out stash.crt -extensions san -config <(echo "[req]"; echo distinguished_name=req; echo "[san]"; echo subjectAltName=DNS:stash.server,IP:127.0.0.1) -subj /CN=stash.server`
@@ -52,25 +68,32 @@ This command would need customizing for your environment. [This link](https://s
Once you have a certificate and key file name them `stash.crt` and `stash.key` and place them in the `~/.stash` directory. Stash detects these and starts up using HTTPS rather than HTTP.
# FAQ
# Customization
> I'm unable to run the app on OSX or Linux
## Themes and CSS Customization
There is a [directory of community-created themes](https://github.com/stashapp/stash/wiki/Themes) on our Wiki, along with instructions on how to install them.
Try running `chmod u+x stash-osx` or `chmod u+x stash-linux` to make the file executable.
You can also make Stash interface fit your desired style with [Custom CSS snippets](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets) and [CSS Tweaks](https://github.com/stashapp/stash/wiki/CSS-Tweaks).
> I have a question not answered here.
# Support (FAQ)
Join the [Discord server](https://discord.gg/2TsNFKt).
Answers to other Frequently Asked Questions can be found [on our Wiki](https://github.com/stashapp/stash/wiki/FAQ)
# Development
For issues not addressed there, there are a few options.
## Install
* Read the [Wiki](https://github.com/stashapp/stash/wiki)
* Check the in-app documentation (also available [here](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en)
* Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support.
# Compiling From Source Code
## Pre-requisites
* [Go](https://golang.org/dl/)
* [Revive](https://github.com/mgechev/revive) - Configurable linter
* Go Install: `go get github.com/mgechev/revive`
* [Packr2](https://github.com/gobuffalo/packr/tree/v2.0.2/v2) - Static asset bundler
* Go Install: `go get github.com/gobuffalo/packr/v2/packr2@v2.0.2`
* [Packr2](https://github.com/gobuffalo/packr/) - Static asset bundler
* Go Install: `go get github.com/gobuffalo/packr/v2/packr2`
* [Binary Download](https://github.com/gobuffalo/packr/releases)
* [Yarn](https://yarnpkg.com/en/docs/install) - Yarn package manager
* Run `yarn install --frozen-lockfile` in the `stash/ui/v2.5` folder (before running make generate for first time).
@@ -117,15 +140,8 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MingW.
## Cross compiling
This project uses a modification of [this](https://github.com/bep/dockerfiles/tree/master/ci-goreleaser) docker container to create an environment
This project uses a modification of the [CI-GoReleaser](https://github.com/bep/dockerfiles/tree/master/ci-goreleaser) docker container to create an environment
where the app can be cross-compiled. This process is kicked off by CI via the `scripts/cross-compile.sh` script. Run the following
command to open a bash shell to the container to poke around:
`docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -i -t stashappdev/compiler:latest /bin/bash`
## Customization
You can make Stash interface fit your desired style with [Custom CSS snippets](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets) and [CSS Tweaks](https://github.com/stashapp/stash/wiki/CSS-Tweaks).
[Stash Plex Theme](https://github.com/stashapp/stash/wiki/Stash-Plex-Theme) is a community created theme inspired by popular Plex Interface.

View File

@@ -53,6 +53,8 @@ FROM ubuntu:20.04 as app
RUN apt-get update && apt-get -y install ca-certificates
COPY --from=compiler /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999
CMD ["stash"]

View File

@@ -12,6 +12,8 @@ FROM ubuntu:20.04 as app
run apt update && apt install -y python3 python3 python-is-python3 python3-requests ffmpeg && rm -rf /var/lib/apt/lists/*
COPY --from=prep /stash /usr/bin/
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999
CMD ["stash"]

View File

@@ -20,5 +20,8 @@ RUN curl --http1.1 -o /ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/f
FROM ubuntu:20.04 as app
RUN apt-get update && apt-get -y install ca-certificates
COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999
CMD ["stash"]

View File

@@ -20,5 +20,8 @@ RUN curl --http1.1 -o /ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/f
FROM ubuntu:20.04 as app
RUN apt-get update && apt-get -y install ca-certificates
COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999
CMD ["stash"]

7
go.mod
View File

@@ -6,6 +6,8 @@ require (
github.com/antchfx/htmlquery v1.2.3
github.com/chromedp/cdproto v0.0.0-20200608134039-8a80cdaf865c
github.com/chromedp/chromedp v0.5.3
github.com/corona10/goimagehash v1.0.3
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/disintegration/imaging v1.6.0
github.com/fvbommel/sortorder v1.0.2
github.com/go-chi/chi v4.0.2+incompatible
@@ -18,7 +20,7 @@ require (
github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
github.com/jmoiron/sqlx v1.2.0
github.com/json-iterator/go v1.1.9
github.com/mattn/go-sqlite3 v1.13.0
github.com/mattn/go-sqlite3 v1.14.6
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007
github.com/remeh/sizedwaitgroup v1.0.0
github.com/rs/cors v1.6.0
@@ -31,8 +33,9 @@ require (
github.com/vektah/gqlparser/v2 v2.0.1
github.com/vektra/mockery/v2 v2.2.1
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/image v0.0.0-20190802002840-cff245a6509b
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
golang.org/x/net v0.0.0-20200822124328-c89045814202
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd
golang.org/x/tools v0.0.0-20200915031644-64986481280e // indirect
gopkg.in/yaml.v2 v2.3.0
)

11
go.sum
View File

@@ -83,6 +83,8 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/corona10/goimagehash v1.0.3 h1:NZM518aKLmoNluluhfHGxT3LGOnrojrxhGn63DR/CZA=
github.com/corona10/goimagehash v1.0.3/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -99,6 +101,7 @@ github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKX
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
@@ -538,8 +541,8 @@ github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.13.0 h1:LnJI81JidiW9r7pS/hXe6cFeO5EXNq7KbfvoJLRI69c=
github.com/mattn/go-sqlite3 v1.13.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
@@ -572,6 +575,8 @@ github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 h1:Ohgj9L0EYOgXxkDp+
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007/go.mod h1:wKCOWMb6iNlvKiOToY2cNuaovSXvIiv1zDi9QDR7aGQ=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -802,6 +807,8 @@ golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86h
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=

View File

@@ -2,7 +2,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
stashes {
path
excludeVideo
excludeImage
excludeImage
}
databasePath
generatedPath
@@ -17,6 +17,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
previewPreset
maxTranscodeSize
maxStreamingTranscodeSize
apiKey
username
password
maxSessionAge
@@ -31,6 +32,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
excludes
imageExcludes
scraperUserAgent
scraperCertCheck
scraperCDPPath
stashBoxes {
name
@@ -50,6 +52,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
css
cssEnabled
language
slideshowDelay
}
fragment ConfigData on ConfigResult {

View File

@@ -1,4 +1,4 @@
fragment GallerySlimData on Gallery {
fragment SlimGalleryData on Gallery {
id
checksum
path
@@ -10,16 +10,31 @@ fragment GallerySlimData on Gallery {
organized
image_count
cover {
...SlimImageData
file {
size
width
height
}
paths {
thumbnail
}
}
studio {
...StudioData
id
name
image_path
}
tags {
...TagData
id
name
}
performers {
...PerformerData
id
name
gender
favorite
image_path
}
scenes {
id

View File

@@ -15,16 +15,16 @@ fragment GalleryData on Gallery {
...SlimImageData
}
studio {
...StudioData
...SlimStudioData
}
tags {
...TagData
...SlimTagData
}
performers {
...PerformerData
}
scenes {
...SceneData
...SlimSceneData
}
}

View File

@@ -38,6 +38,7 @@ fragment SlimImageData on Image {
performers {
id
name
gender
favorite
image_path
}

View File

@@ -23,11 +23,11 @@ fragment ImageData on Image {
}
studio {
...StudioData
...SlimStudioData
}
tags {
...TagData
...SlimTagData
}
performers {

View File

@@ -9,7 +9,7 @@ fragment MovieData on Movie {
director
studio {
...StudioData
...SlimStudioData
}
synopsis

View File

@@ -3,8 +3,14 @@ fragment SlimPerformerData on Performer {
name
gender
image_path
favorite
tags {
id
name
}
stash_ids {
endpoint
stash_id
}
rating
}

View File

@@ -20,8 +20,20 @@ fragment PerformerData on Performer {
favorite
image_path
scene_count
image_count
gallery_count
tags {
...SlimTagData
}
stash_ids {
stash_id
endpoint
}
rating
details
death_date
hair_color
weight
}

View File

@@ -10,6 +10,7 @@ fragment SlimSceneData on Scene {
o_counter
organized
path
phash
file {
size
@@ -29,6 +30,7 @@ fragment SlimSceneData on Scene {
webp
vtt
chapters_vtt
sprite
}
scene_markers {
@@ -66,6 +68,7 @@ fragment SlimSceneData on Scene {
performers {
id
name
gender
favorite
image_path
}

View File

@@ -10,6 +10,7 @@ fragment SceneData on Scene {
o_counter
organized
path
phash
file {
size
@@ -36,11 +37,11 @@ fragment SceneData on Scene {
}
galleries {
...GallerySlimData
...SlimGalleryData
}
studio {
...StudioData
...SlimStudioData
}
movies {
@@ -51,7 +52,7 @@ fragment SceneData on Scene {
}
tags {
...TagData
...SlimTagData
}
performers {

View File

@@ -15,7 +15,14 @@ fragment ScrapedPerformerData on ScrapedPerformer {
tattoos
piercings
aliases
tags {
...ScrapedSceneTagData
}
image
details
death_date
hair_color
weight
}
fragment ScrapedScenePerformerData on ScrapedScenePerformer {
@@ -36,8 +43,15 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer {
tattoos
piercings
aliases
tags {
...ScrapedSceneTagData
}
remote_site_id
images
details
death_date
hair_color
weight
}
fragment ScrapedMovieStudioData on ScrapedMovieStudio {
@@ -183,3 +197,10 @@ fragment ScrapedStashBoxSceneData on ScrapedScene {
...ScrapedSceneMovieData
}
}
fragment ScrapedStashBoxPerformerData on StashBoxPerformerQueryResult {
query
results {
...ScrapedScenePerformerData
}
}

View File

@@ -9,4 +9,6 @@ fragment SlimStudioData on Studio {
parent_studio {
id
}
details
rating
}

View File

@@ -10,6 +10,8 @@ fragment StudioData on Studio {
url
image_path
scene_count
image_count
gallery_count
}
child_studios {
id
@@ -18,11 +20,17 @@ fragment StudioData on Studio {
url
image_path
scene_count
image_count
gallery_count
}
image_path
scene_count
image_count
gallery_count
stash_ids {
stash_id
endpoint
}
details
rating
}

View File

@@ -0,0 +1,5 @@
fragment SlimTagData on Tag {
id
name
image_path
}

View File

@@ -4,4 +4,7 @@ fragment TagData on Tag {
image_path
scene_count
scene_marker_count
image_count
gallery_count
performer_count
}

View File

@@ -1,3 +1,11 @@
mutation Setup($input: SetupInput!) {
setup(input: $input)
}
mutation Migrate($input: MigrateInput!) {
migrate(input: $input)
}
mutation ConfigureGeneral($input: ConfigGeneralInput!) {
configureGeneral(input: $input) {
...ConfigGeneralData
@@ -8,4 +16,8 @@ mutation ConfigureInterface($input: ConfigInterfaceInput!) {
configureInterface(input: $input) {
...ConfigInterfaceData
}
}
}
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
generateAPIKey(input: $input)
}

View File

@@ -1,45 +1,7 @@
mutation PerformerCreate(
$name: String!,
$url: String,
$gender: GenderEnum,
$birthdate: String,
$ethnicity: String,
$country: String,
$eye_color: String,
$height: String,
$measurements: String,
$fake_tits: String,
$career_length: String,
$tattoos: String,
$piercings: String,
$aliases: String,
$twitter: String,
$instagram: String,
$favorite: Boolean,
$stash_ids: [StashIDInput!],
$image: String) {
$input: PerformerCreateInput!) {
performerCreate(input: {
name: $name,
url: $url,
gender: $gender,
birthdate: $birthdate,
ethnicity: $ethnicity,
country: $country,
eye_color: $eye_color,
height: $height,
measurements: $measurements,
fake_tits: $fake_tits,
career_length: $career_length,
tattoos: $tattoos,
piercings: $piercings,
aliases: $aliases,
twitter: $twitter,
instagram: $instagram,
favorite: $favorite,
stash_ids: $stash_ids,
image: $image
}) {
performerCreate(input: $input) {
...PerformerData
}
}
@@ -52,6 +14,14 @@ mutation PerformerUpdate(
}
}
mutation BulkPerformerUpdate(
$input: BulkPerformerUpdateInput!) {
bulkPerformerUpdate(input: $input) {
...PerformerData
}
}
mutation PerformerDestroy($id: ID!) {
performerDestroy(input: { id: $id })
}

View File

@@ -1,3 +1,7 @@
mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) {
submitStashBoxFingerprints(input: $input)
}
mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) {
stashBoxBatchPerformerTag(input: $input)
}

View File

@@ -1,18 +1,10 @@
mutation StudioCreate(
$name: String!,
$url: String,
$image: String,
$stash_ids: [StashIDInput!],
$parent_id: ID) {
studioCreate(input: { name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) {
mutation StudioCreate($input: StudioCreateInput!) {
studioCreate(input: $input) {
...StudioData
}
}
mutation StudioUpdate(
$input: StudioUpdateInput!) {
mutation StudioUpdate($input: StudioUpdateInput!) {
studioUpdate(input: $input) {
...StudioData
}

View File

@@ -2,7 +2,7 @@ query FindGalleries($filter: FindFilterType, $gallery_filter: GalleryFilterType)
findGalleries(gallery_filter: $gallery_filter, filter: $filter) {
count
galleries {
...GallerySlimData
...SlimGalleryData
}
}
}

View File

@@ -13,24 +13,24 @@ query AllTags {
}
query AllPerformersForFilter {
allPerformersSlim {
allPerformers {
...SlimPerformerData
}
}
query AllStudiosForFilter {
allStudiosSlim {
allStudios {
...SlimStudioData
}
}
query AllMoviesForFilter {
allMoviesSlim {
allMovies {
...SlimMovieData
}
}
query AllTagsForFilter {
allTagsSlim {
allTags {
id
name
}

View File

@@ -16,6 +16,12 @@ query FindScenesByPathRegex($filter: FindFilterType) {
}
}
query FindDuplicateScenes($distance: Int) {
findDuplicateScenes(distance: $distance) {
...SlimSceneData
}
}
query FindScene($id: ID!, $checksum: String) {
findScene(id: $id, checksum: $checksum) {
...SceneData

View File

@@ -15,6 +15,10 @@ query ScrapeFreeones($performer_name: String!) {
tattoos
piercings
aliases
details
death_date
hair_color
weight
}
}

View File

@@ -90,8 +90,14 @@ query ScrapeMovieURL($url: String!) {
}
}
query QueryStashBoxScene($input: StashBoxQueryInput!) {
query QueryStashBoxScene($input: StashBoxSceneQueryInput!) {
queryStashBoxScene(input: $input) {
...ScrapedStashBoxSceneData
}
}
query QueryStashBoxPerformer($input: StashBoxPerformerQueryInput!) {
queryStashBoxPerformer(input: $input) {
...ScrapedStashBoxPerformerData
}
}

View File

@@ -5,3 +5,13 @@ query JobStatus {
message
}
}
query SystemStatus {
systemStatus {
databaseSchema
databasePath
appSchema
status
configPath
}
}

View File

@@ -9,6 +9,9 @@ type Query {
findScenesByPathRegex(filter: FindFilterType): FindScenesResultType!
""" Returns any groups of scenes that are perceptual duplicates within the queried distance """
findDuplicateScenes(distance: Int): [[Scene!]!]!
"""Return valid stream paths"""
sceneStreams(id: ID): [SceneStreamEndpoint!]!
@@ -88,7 +91,8 @@ type Query {
scrapeFreeonesPerformerList(query: String!): [String!]!
"""Query StashBox for scenes"""
queryStashBoxScene(input: StashBoxQueryInput!): [ScrapedScene!]!
queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]!
queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]!
# Plugins
"""List loaded plugins"""
@@ -103,7 +107,7 @@ type Query {
directory(path: String): Directory!
# Metadata
systemStatus: SystemStatus!
jobStatus: MetadataUpdateStatus!
# Get everything
@@ -115,11 +119,6 @@ type Query {
# Get everything with minimal metadata
allPerformersSlim: [Performer!]!
allStudiosSlim: [Studio!]!
allMoviesSlim: [Movie!]!
allTagsSlim: [Tag!]!
# Version
version: Version!
@@ -128,6 +127,9 @@ type Query {
}
type Mutation {
setup(input: SetupInput!): Boolean!
migrate(input: MigrateInput!): Boolean!
sceneUpdate(input: SceneUpdateInput!): Scene
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
sceneDestroy(input: SceneDestroyInput!): Boolean!
@@ -174,6 +176,7 @@ type Mutation {
performerUpdate(input: PerformerUpdateInput!): Performer
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio
@@ -194,6 +197,9 @@ type Mutation {
configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!
configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult!
"""Generate and set (or clear) API key"""
generateAPIKey(input: GenerateAPIKeyInput!): String!
"""Returns a link to download the result"""
exportObjects(input: ExportObjectsInput!): String
@@ -229,6 +235,9 @@ type Mutation {
"""Backup the database. Optionally returns a link to download the database file"""
backupDatabase(input: BackupDatabaseInput!): String
"""Run batch performer tag task. Returns the job ID."""
stashBoxBatchPerformerTag(input: StashBoxBatchPerformerTagInput!): String!
}
type Subscription {

View File

@@ -1,3 +1,13 @@
input SetupInput {
"""Empty to indicate $HOME/.stash/config.yml default"""
configLocation: String!
stashes: [StashConfigInput!]!
"""Empty to indicate default"""
databaseFile: String!
"""Empty to indicate default"""
generatedLocation: String!
}
enum StreamingResolutionEnum {
"240p", LOW
"480p", STANDARD
@@ -81,6 +91,8 @@ input ConfigGeneralInput {
scraperUserAgent: String
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
"""Stash-box instances used for tagging"""
stashBoxes: [StashBoxInput!]!
}
@@ -92,6 +104,10 @@ type ConfigGeneralResult {
databasePath: String!
"""Path to generated files"""
generatedPath: String!
"""Path to the config file used"""
configFilePath: String!
"""Path to scrapers"""
scrapersPath: String!
"""Path to cache"""
cachePath: String!
"""Whether to calculate MD5 checksums for scene video files"""
@@ -114,6 +130,8 @@ type ConfigGeneralResult {
maxTranscodeSize: StreamingResolutionEnum
"""Max streaming transcode size"""
maxStreamingTranscodeSize: StreamingResolutionEnum
"""API Key"""
apiKey: String!
"""Username"""
username: String!
"""Password"""
@@ -144,6 +162,8 @@ type ConfigGeneralResult {
scraperUserAgent: String
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
"""Stash-box instances used for tagging"""
stashBoxes: [StashBox!]!
}
@@ -168,6 +188,8 @@ input ConfigInterfaceInput {
cssEnabled: Boolean
"""Interface language"""
language: String
"""Slideshow Delay"""
slideshowDelay: Int
}
type ConfigInterfaceResult {
@@ -190,6 +212,8 @@ type ConfigInterfaceResult {
cssEnabled: Boolean
"""Interface language"""
language: String
"""Slideshow Delay"""
slideshowDelay: Int
}
"""All configuration settings"""
@@ -217,3 +241,7 @@ type StashConfig {
excludeVideo: Boolean!
excludeImage: Boolean!
}
input GenerateAPIKeyInput {
clear: Boolean
}

View File

@@ -6,7 +6,7 @@ enum SortDirectionEnum {
input FindFilterType {
q: String
page: Int
"""use per_page = 0 to indicate all results. Defaults to 25."""
"""use per_page = -1 to indicate all results. Defaults to 25."""
per_page: Int
sort: String
direction: SortDirectionEnum
@@ -47,7 +47,7 @@ input PerformerFilterType {
measurements: StringCriterionInput
"""Filter by fake tits value"""
fake_tits: StringCriterionInput
"""Filter by career length"""
"""Filter by career length"""
career_length: StringCriterionInput
"""Filter by tattoos"""
tattoos: StringCriterionInput
@@ -59,8 +59,28 @@ input PerformerFilterType {
gender: GenderCriterionInput
"""Filter to only include performers missing this property"""
is_missing: String
"""Filter to only include performers with these tags"""
tags: MultiCriterionInput
"""Filter by tag count"""
tag_count: IntCriterionInput
"""Filter by scene count"""
scene_count: IntCriterionInput
"""Filter by image count"""
image_count: IntCriterionInput
"""Filter by gallery count"""
gallery_count: IntCriterionInput
"""Filter by StashID"""
stash_id: String
stash_id: StringCriterionInput
"""Filter by rating"""
rating: IntCriterionInput
"""Filter by url"""
url: StringCriterionInput
"""Filter by hair color"""
hair_color: StringCriterionInput
"""Filter by weight"""
weight: IntCriterionInput
"""Filter by death year"""
death_year: IntCriterionInput
}
input SceneMarkerFilterType {
@@ -75,6 +95,10 @@ input SceneMarkerFilterType {
}
input SceneFilterType {
AND: SceneFilterType
OR: SceneFilterType
NOT: SceneFilterType
"""Filter by path"""
path: StringCriterionInput
"""Filter by rating"""
@@ -97,10 +121,18 @@ input SceneFilterType {
movies: MultiCriterionInput
"""Filter to only include scenes with these tags"""
tags: MultiCriterionInput
"""Filter by tag count"""
tag_count: IntCriterionInput
"""Filter to only include scenes with performers with these tags"""
performer_tags: MultiCriterionInput
"""Filter to only include scenes with these performers"""
performers: MultiCriterionInput
"""Filter by performer count"""
performer_count: IntCriterionInput
"""Filter by StashID"""
stash_id: String
stash_id: StringCriterionInput
"""Filter by url"""
url: StringCriterionInput
}
input MovieFilterType {
@@ -108,18 +140,34 @@ input MovieFilterType {
studios: MultiCriterionInput
"""Filter to only include movies missing this property"""
is_missing: String
"""Filter by url"""
url: StringCriterionInput
}
input StudioFilterType {
"""Filter to only include studios with this parent studio"""
parents: MultiCriterionInput
"""Filter by StashID"""
stash_id: String
stash_id: StringCriterionInput
"""Filter to only include studios missing this property"""
is_missing: String
"""Filter by rating"""
rating: IntCriterionInput
"""Filter by scene count"""
scene_count: IntCriterionInput
"""Filter by image count"""
image_count: IntCriterionInput
"""Filter by gallery count"""
gallery_count: IntCriterionInput
"""Filter by url"""
url: StringCriterionInput
}
input GalleryFilterType {
AND: GalleryFilterType
OR: GalleryFilterType
NOT: GalleryFilterType
"""Filter by path"""
path: StringCriterionInput
"""Filter to only include galleries missing this property"""
@@ -132,28 +180,53 @@ input GalleryFilterType {
organized: Boolean
"""Filter by average image resolution"""
average_resolution: ResolutionEnum
"""Filter to only include scenes with this studio"""
"""Filter to only include galleries with this studio"""
studios: MultiCriterionInput
"""Filter to only include scenes with these tags"""
"""Filter to only include galleries with these tags"""
tags: MultiCriterionInput
"""Filter to only include scenes with these performers"""
"""Filter by tag count"""
tag_count: IntCriterionInput
"""Filter to only include galleries with performers with these tags"""
performer_tags: MultiCriterionInput
"""Filter to only include galleries with these performers"""
performers: MultiCriterionInput
"""Filter by performer count"""
performer_count: IntCriterionInput
"""Filter by number of images in this gallery"""
image_count: IntCriterionInput
"""Filter by url"""
url: StringCriterionInput
}
input TagFilterType {
AND: TagFilterType
OR: TagFilterType
NOT: TagFilterType
"""Filter to only include tags missing this property"""
is_missing: String
"""Filter by number of scenes with this tag"""
scene_count: IntCriterionInput
"""Filter by number of images with this tag"""
image_count: IntCriterionInput
"""Filter by number of galleries with this tag"""
gallery_count: IntCriterionInput
"""Filter by number of performers with this tag"""
performer_count: IntCriterionInput
"""Filter by number of markers with this tag"""
marker_count: IntCriterionInput
}
input ImageFilterType {
AND: ImageFilterType
OR: ImageFilterType
NOT: ImageFilterType
"""Filter by path"""
path: StringCriterionInput
"""Filter by rating"""
@@ -170,8 +243,14 @@ input ImageFilterType {
studios: MultiCriterionInput
"""Filter to only include images with these tags"""
tags: MultiCriterionInput
"""Filter by tag count"""
tag_count: IntCriterionInput
"""Filter to only include images with performers with these tags"""
performer_tags: MultiCriterionInput
"""Filter to only include images with these performers"""
performers: MultiCriterionInput
"""Filter by performer count"""
performer_count: IntCriterionInput
"""Filter to only include images with these galleries"""
galleries: MultiCriterionInput
}

View File

@@ -7,6 +7,7 @@ input GenerateMetadataInput {
previewOptions: GeneratePreviewOptionsInput
markers: Boolean!
transcodes: Boolean!
phashes: Boolean!
"""scene ids to generate for"""
sceneIDs: [ID!]
@@ -33,15 +34,17 @@ input GeneratePreviewOptionsInput {
input ScanMetadataInput {
paths: [String!]
"""Set name, date, details from metadata (if present)"""
useFileMetadata: Boolean!
useFileMetadata: Boolean
"""Strip file extension from title"""
stripFileExtension: Boolean!
stripFileExtension: Boolean
"""Generate previews during scan"""
scanGeneratePreviews: Boolean!
scanGeneratePreviews: Boolean
"""Generate image previews during scan"""
scanGenerateImagePreviews: Boolean!
scanGenerateImagePreviews: Boolean
"""Generate sprites during scan"""
scanGenerateSprites: Boolean!
scanGenerateSprites: Boolean
"""Generate phashes during scan"""
scanGeneratePhashes: Boolean
}
input CleanMetadataInput {
@@ -103,3 +106,21 @@ input ImportObjectsInput {
input BackupDatabaseInput {
download: Boolean
}
enum SystemStatusEnum {
SETUP
NEEDS_MIGRATION
OK
}
type SystemStatus {
databaseSchema: Int
databasePath: String
configPath: String
appSchema: Int!
status: SystemStatusEnum!
}
input MigrateInput {
backupPath: String!
}

View File

@@ -28,8 +28,9 @@ input MovieCreateInput {
director: String
synopsis: String
url: String
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
front_image: String
"""This should be a URL or a base64 encoded data URL"""
back_image: String
}
@@ -44,8 +45,9 @@ input MovieUpdateInput {
director: String
synopsis: String
url: String
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
front_image: String
"""This should be a URL or a base64 encoded data URL"""
back_image: String
}

View File

@@ -27,11 +27,19 @@ type Performer {
piercings: String
aliases: String
favorite: Boolean!
tags: [Tag!]!
image_path: String # Resolver
scene_count: Int # Resolver
image_count: Int # Resolver
gallery_count: Int # Resolver
scenes: [Scene!]!
stash_ids: [StashID!]!
rating: Int
details: String
death_date: String
hair_color: String
weight: Int
}
input PerformerCreateInput {
@@ -52,9 +60,15 @@ input PerformerCreateInput {
twitter: String
instagram: String
favorite: Boolean
"""This should be base64 encoded"""
tag_ids: [ID!]
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
details: String
death_date: String
hair_color: String
weight: Int
}
input PerformerUpdateInput {
@@ -76,9 +90,42 @@ input PerformerUpdateInput {
twitter: String
instagram: String
favorite: Boolean
"""This should be base64 encoded"""
tag_ids: [ID!]
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
details: String
death_date: String
hair_color: String
weight: Int
}
input BulkPerformerUpdateInput {
clientMutationId: String
ids: [ID!]
url: String
gender: GenderEnum
birthdate: String
ethnicity: String
country: String
eye_color: String
height: String
measurements: String
fake_tits: String
career_length: String
tattoos: String
piercings: String
aliases: String
twitter: String
instagram: String
favorite: Boolean
tag_ids: BulkUpdateIds
rating: Int
details: String
death_date: String
hair_color: String
weight: Int
}
input PerformerDestroyInput {

View File

@@ -16,6 +16,7 @@ type ScenePathsType {
webp: String # Resolver
vtt: String # Resolver
chapters_vtt: String # Resolver
sprite: String # Resolver
}
type SceneMovie {
@@ -35,6 +36,7 @@ type Scene {
organized: Boolean!
o_counter: Int
path: String!
phash: String
file: SceneFileType! # Resolver
paths: ScenePathsType! # Resolver
@@ -67,7 +69,7 @@ input SceneUpdateInput {
performer_ids: [ID!]
movies: [SceneMovieInput!]
tag_ids: [ID!]
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
cover_image: String
stash_ids: [StashIDInput!]
}

View File

@@ -17,8 +17,9 @@ type ScrapedMovie {
synopsis: String
studio: ScrapedMovieStudio
"""This should be base64 encoded"""
"""This should be a base64 encoded data URL"""
front_image: String
"""This should be a base64 encoded data URL"""
back_image: String
}

View File

@@ -16,9 +16,15 @@ type ScrapedPerformer {
tattoos: String
piercings: String
aliases: String
# Should be ScrapedPerformerTag - but would be identical types
tags: [ScrapedSceneTag!]
"""This should be base64 encoded"""
"""This should be a base64 encoded data URL"""
image: String
details: String
death_date: String
hair_color: String
weight: String
}
input ScrapedPerformerInput {
@@ -39,5 +45,10 @@ input ScrapedPerformerInput {
piercings: String
aliases: String
# not including tags for the input
# not including image for the input
details: String
death_date: String
hair_color: String
weight: String
}

View File

@@ -45,9 +45,14 @@ type ScrapedScenePerformer {
tattoos: String
piercings: String
aliases: String
tags: [ScrapedSceneTag!]
remote_site_id: String
images: [String!]
details: String
death_date: String
hair_color: String
weight: String
}
type ScrapedSceneMovie {
@@ -84,7 +89,7 @@ type ScrapedScene {
url: String
date: String
"""This should be base64 encoded"""
"""This should be a base64 encoded data URL"""
image: String
file: SceneFileType # Resolver
@@ -110,7 +115,7 @@ type ScrapedGallery {
performers: [ScrapedScenePerformer!]
}
input StashBoxQueryInput {
input StashBoxSceneQueryInput {
"""Index of the configured stash-box instance to use"""
stash_box_index: Int!
"""Instructs query by scene fingerprints"""
@@ -119,8 +124,30 @@ input StashBoxQueryInput {
q: String
}
input StashBoxPerformerQueryInput {
"""Index of the configured stash-box instance to use"""
stash_box_index: Int!
"""Instructs query by scene fingerprints"""
performer_ids: [ID!]
"""Query by query string"""
q: String
}
type StashBoxPerformerQueryResult {
query: String!
results: [ScrapedScenePerformer!]!
}
type StashBoxFingerprint {
algorithm: String!
hash: String!
duration: Int!
}
input StashBoxBatchPerformerTagInput {
endpoint: Int!
exclude_fields: [String!]
refresh: Boolean!
performer_ids: [ID!]
performer_names: [String!]
}

View File

@@ -8,16 +8,22 @@ type Studio {
image_path: String # Resolver
scene_count: Int # Resolver
image_count: Int # Resolver
gallery_count: Int # Resolver
stash_ids: [StashID!]!
rating: Int
details: String
}
input StudioCreateInput {
name: String!
url: String
parent_id: ID
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
details: String
}
input StudioUpdateInput {
@@ -25,9 +31,11 @@ input StudioUpdateInput {
name: String
url: String
parent_id: ID,
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
details: String
}
input StudioDestroyInput {

View File

@@ -5,12 +5,15 @@ type Tag {
image_path: String # Resolver
scene_count: Int # Resolver
scene_marker_count: Int # Resolver
image_count: Int # Resolver
gallery_count: Int # Resolver
performer_count: Int
}
input TagCreateInput {
name: String!
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
image: String
}
@@ -18,7 +21,7 @@ input TagUpdateInput {
id: ID!
name: String!
"""This should be base64 encoded"""
"""This should be a URL or a base64 encoded data URL"""
image: String
}

View File

@@ -75,6 +75,11 @@ fragment PerformerFragment on Performer {
piercings {
...BodyModificationFragment
}
details
death_date {
...FuzzyDateFragment
}
weight
}
fragment PerformerAppearanceFragment on PerformerAppearance {
@@ -134,6 +139,18 @@ query SearchScene($term: String!) {
}
}
query SearchPerformer($term: String!) {
searchPerformer(term: $term) {
...PerformerFragment
}
}
query FindPerformerByID($id: ID!) {
findPerformer(id: $id) {
...PerformerFragment
}
}
mutation SubmitFingerprint($input: FingerprintSubmission!) {
submitFingerprint(input: $input)
}

View File

@@ -3,9 +3,7 @@ package main
import (
"github.com/stashapp/stash/pkg/api"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
@@ -13,12 +11,6 @@ import (
func main() {
manager.Initialize()
// perform the post-migration for new databases
if database.Initialize(config.GetDatabasePath()) {
manager.GetInstance().PostMigrate()
}
api.Start()
blockForever()
}

View File

@@ -10,6 +10,8 @@ import (
"runtime"
"time"
"golang.org/x/sys/cpu"
"github.com/stashapp/stash/pkg/logger"
)
@@ -26,10 +28,12 @@ var ErrNoVersion = errors.New("no stash version")
var stashReleases = func() map[string]string {
return map[string]string{
"windows/amd64": "stash-win.exe",
"linux/amd64": "stash-linux",
"darwin/amd64": "stash-osx",
"linux/amd64": "stash-linux",
"windows/amd64": "stash-win.exe",
"linux/arm": "stash-pi",
"linux/arm64": "stash-linux-arm64v8",
"linux/armv7": "stash-linux-arm32v7",
}
}
@@ -141,7 +145,13 @@ func makeGithubRequest(url string, output interface{}) error {
// which is the latest pre-release build.
func GetLatestVersion(shortHash bool) (latestVersion string, latestRelease string, err error) {
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
arch := runtime.GOARCH // https://en.wikipedia.org/wiki/Comparison_of_ARM_cores
isARMv7 := cpu.ARM.HasNEON || cpu.ARM.HasVFPv3 || cpu.ARM.HasVFPv3D16 || cpu.ARM.HasVFPv4 // armv6 doesn't support any of these features
if arch == "arm" && isARMv7 {
arch = "armv7"
}
platform := fmt.Sprintf("%s/%s", runtime.GOOS, arch)
wantedRelease := stashReleases()[platform]
version, _, _ := GetVersion()

View File

@@ -1,96 +0,0 @@
package api
import (
"fmt"
"html/template"
"net/http"
"os"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
)
type migrateData struct {
ExistingVersion uint
MigrateVersion uint
BackupPath string
}
func getMigrateData() migrateData {
return migrateData{
ExistingVersion: database.Version(),
MigrateVersion: database.AppSchemaVersion(),
BackupPath: database.DatabaseBackupPath(),
}
}
func getMigrateHandler(w http.ResponseWriter, r *http.Request) {
if !database.NeedsMigration() {
http.Redirect(w, r, "/", 301)
return
}
data, _ := setupUIBox.Find("migrate.html")
templ, err := template.New("Migrate").Parse(string(data))
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), 500)
return
}
err = templ.Execute(w, getMigrateData())
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), 500)
}
}
func doMigrateHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), 500)
}
formBackupPath := r.Form.Get("backuppath")
// always backup so that we can roll back to the previous version if
// migration fails
backupPath := formBackupPath
if formBackupPath == "" {
backupPath = database.DatabaseBackupPath()
}
// perform database backup
if err = database.Backup(database.DB, backupPath); err != nil {
http.Error(w, fmt.Sprintf("error backing up database: %s", err), 500)
return
}
err = database.RunMigrations()
if err != nil {
errStr := fmt.Sprintf("error performing migration: %s", err)
// roll back to the backed up version
restoreErr := database.RestoreFromBackup(backupPath)
if restoreErr != nil {
errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr)
} else {
errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr
}
http.Error(w, errStr, 500)
return
}
// perform post-migration operations
manager.GetInstance().PostMigrate()
// if no backup path was provided, then delete the created backup
if formBackupPath == "" {
err = os.Remove(backupPath)
if err != nil {
logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error())
}
}
http.Redirect(w, r, "/", 301)
}

View File

@@ -34,7 +34,7 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*models.Im
func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*models.ImagePathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewImageURLBuilder(baseURL, obj.ID)
builder := urlbuilders.NewImageURLBuilder(baseURL, obj)
thumbnailPath := builder.GetThumbnailURL()
imagePath := builder.GetImageURL()
return &models.ImagePathsType{

View File

@@ -84,13 +84,31 @@ func (r *movieResolver) Synopsis(ctx context.Context, obj *models.Movie) (*strin
func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
frontimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj.ID).GetMovieFrontImageURL()
frontimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL()
return &frontimagePath, nil
}
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
// don't return any thing if there is no back image
var img []byte
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
var err error
img, err = repo.Movie().GetBackImage(obj.ID)
if err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
if img == nil {
return nil, nil
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj.ID).GetMovieBackImageURL()
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
return &backimagePath, nil
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
)
@@ -134,10 +136,21 @@ func (r *performerResolver) Favorite(ctx context.Context, obj *models.Performer)
func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj.ID).GetPerformerImageURL()
imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL()
return &imagePath, nil
}
func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Tag().FindByPerformerID(obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
@@ -150,6 +163,30 @@ func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performe
return &res, nil
}
func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = image.CountByPerformerID(repo.Image(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = gallery.CountByPerformerID(repo.Gallery(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Scene().FindByPerformerID(obj.ID)
@@ -171,3 +208,40 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer)
return ret, nil
}
func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) {
if obj.Rating.Valid {
rating := int(obj.Rating.Int64)
return &rating, nil
}
return nil, nil
}
func (r *performerResolver) Details(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.Details.Valid {
return &obj.Details.String, nil
}
return nil, nil
}
func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.DeathDate.Valid {
return &obj.DeathDate.String, nil
}
return nil, nil
}
func (r *performerResolver) HairColor(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.HairColor.Valid {
return &obj.HairColor.String, nil
}
return nil, nil
}
func (r *performerResolver) Weight(ctx context.Context, obj *models.Performer) (*int, error) {
if obj.Weight.Valid {
weight := int(obj.Weight.Int64)
return &weight, nil
}
return nil, nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
@@ -78,11 +79,13 @@ func (r *sceneResolver) File(ctx context.Context, obj *models.Scene) (*models.Sc
func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.ScenePathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
builder.APIKey = config.GetInstance().GetAPIKey()
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt.Timestamp)
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL()
webpPath := builder.GetStreamPreviewImageURL()
vttPath := builder.GetSpriteVTTURL()
spritePath := builder.GetSpriteURL()
chaptersVttPath := builder.GetChaptersVTTURL()
return &models.ScenePathsType{
Screenshot: &screenshotPath,
@@ -91,6 +94,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
Webp: &webpPath,
Vtt: &vttPath,
ChaptersVtt: &chaptersVttPath,
Sprite: &spritePath,
}, nil
}
@@ -200,3 +204,11 @@ func (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) (ret []
return ret, nil
}
func (r *sceneResolver) Phash(ctx context.Context, obj *models.Scene) (*string, error) {
if obj.Phash.Valid {
hexval := utils.PhashToString(obj.Phash.Int64)
return &hexval, nil
}
return nil, nil
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
)
@@ -23,7 +25,7 @@ func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string,
func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj.ID).GetStudioImageURL()
imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL()
var hasImage bool
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
@@ -54,6 +56,30 @@ func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (re
return &res, err
}
func (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = image.CountByStudioID(repo.Image(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = gallery.CountByStudioID(repo.Gallery(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {
if !obj.ParentID.Valid {
return nil, nil
@@ -90,3 +116,18 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) (ret
return ret, nil
}
func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) {
if obj.Rating.Valid {
rating := int(obj.Rating.Int64)
return &rating, nil
}
return nil, nil
}
func (r *studioResolver) Details(ctx context.Context, obj *models.Studio) (*string, error) {
if obj.Details.Valid {
return &obj.Details.String, nil
}
return nil, nil
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
)
@@ -31,8 +33,44 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (re
return &count, err
}
func (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = image.CountByTagID(repo.Image(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = gallery.CountByTagID(repo.Gallery(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var count int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
count, err = repo.Performer().CountByTagID(obj.ID)
return err
}); err != nil {
return nil, err
}
return &count, err
}
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj.ID).GetTagImageURL()
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL()
return &imagePath, nil
}

View File

@@ -13,15 +13,37 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
func (r *mutationResolver) Setup(ctx context.Context, input models.SetupInput) (bool, error) {
err := manager.GetInstance().Setup(input)
return err == nil, err
}
func (r *mutationResolver) Migrate(ctx context.Context, input models.MigrateInput) (bool, error) {
err := manager.GetInstance().Migrate(input)
return err == nil, err
}
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.ConfigGeneralInput) (*models.ConfigGeneralResult, error) {
c := config.GetInstance()
existingPaths := c.GetStashPaths()
if len(input.Stashes) > 0 {
for _, s := range input.Stashes {
exists, err := utils.DirExists(s.Path)
if !exists {
return makeConfigGeneralResult(), err
// Only validate existence of new paths
isNew := true
for _, path := range existingPaths {
if path.Path == s.Path {
isNew = false
break
}
}
if isNew {
exists, err := utils.DirExists(s.Path)
if !exists {
return makeConfigGeneralResult(), err
}
}
}
config.Set(config.Stash, input.Stashes)
c.Set(config.Stash, input.Stashes)
}
if input.DatabasePath != nil {
@@ -29,136 +51,140 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" {
return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or sqlite3")
}
config.Set(config.Database, input.DatabasePath)
c.Set(config.Database, input.DatabasePath)
}
if input.GeneratedPath != nil {
if err := utils.EnsureDir(*input.GeneratedPath); err != nil {
return makeConfigGeneralResult(), err
}
config.Set(config.Generated, input.GeneratedPath)
c.Set(config.Generated, input.GeneratedPath)
}
if input.CachePath != nil {
if err := utils.EnsureDir(*input.CachePath); err != nil {
return makeConfigGeneralResult(), err
if *input.CachePath != "" {
if err := utils.EnsureDir(*input.CachePath); err != nil {
return makeConfigGeneralResult(), err
}
}
config.Set(config.Cache, input.CachePath)
c.Set(config.Cache, input.CachePath)
}
if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
}
if input.VideoFileNamingAlgorithm != config.GetVideoFileNamingAlgorithm() {
if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
// validate changing VideoFileNamingAlgorithm
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil {
return makeConfigGeneralResult(), err
}
config.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm)
c.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm)
}
config.Set(config.CalculateMD5, input.CalculateMd5)
c.Set(config.CalculateMD5, input.CalculateMd5)
if input.ParallelTasks != nil {
config.Set(config.ParallelTasks, *input.ParallelTasks)
c.Set(config.ParallelTasks, *input.ParallelTasks)
}
if input.PreviewSegments != nil {
config.Set(config.PreviewSegments, *input.PreviewSegments)
c.Set(config.PreviewSegments, *input.PreviewSegments)
}
if input.PreviewSegmentDuration != nil {
config.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration)
c.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration)
}
if input.PreviewExcludeStart != nil {
config.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart)
c.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart)
}
if input.PreviewExcludeEnd != nil {
config.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd)
c.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd)
}
if input.PreviewPreset != nil {
config.Set(config.PreviewPreset, input.PreviewPreset.String())
c.Set(config.PreviewPreset, input.PreviewPreset.String())
}
if input.MaxTranscodeSize != nil {
config.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
}
if input.MaxStreamingTranscodeSize != nil {
config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
}
if input.Username != nil {
config.Set(config.Username, input.Username)
c.Set(config.Username, input.Username)
}
if input.Password != nil {
// bit of a hack - check if the passed in password is the same as the stored hash
// and only set if they are different
currentPWHash := config.GetPasswordHash()
currentPWHash := c.GetPasswordHash()
if *input.Password != currentPWHash {
config.SetPassword(*input.Password)
c.SetPassword(*input.Password)
}
}
if input.MaxSessionAge != nil {
config.Set(config.MaxSessionAge, *input.MaxSessionAge)
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
}
if input.LogFile != nil {
config.Set(config.LogFile, input.LogFile)
c.Set(config.LogFile, input.LogFile)
}
config.Set(config.LogOut, input.LogOut)
config.Set(config.LogAccess, input.LogAccess)
c.Set(config.LogOut, input.LogOut)
c.Set(config.LogAccess, input.LogAccess)
if input.LogLevel != config.GetLogLevel() {
config.Set(config.LogLevel, input.LogLevel)
if input.LogLevel != c.GetLogLevel() {
c.Set(config.LogLevel, input.LogLevel)
logger.SetLogLevel(input.LogLevel)
}
if input.Excludes != nil {
config.Set(config.Exclude, input.Excludes)
c.Set(config.Exclude, input.Excludes)
}
if input.ImageExcludes != nil {
config.Set(config.ImageExclude, input.ImageExcludes)
c.Set(config.ImageExclude, input.ImageExcludes)
}
if input.VideoExtensions != nil {
config.Set(config.VideoExtensions, input.VideoExtensions)
c.Set(config.VideoExtensions, input.VideoExtensions)
}
if input.ImageExtensions != nil {
config.Set(config.ImageExtensions, input.ImageExtensions)
c.Set(config.ImageExtensions, input.ImageExtensions)
}
if input.GalleryExtensions != nil {
config.Set(config.GalleryExtensions, input.GalleryExtensions)
c.Set(config.GalleryExtensions, input.GalleryExtensions)
}
config.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
refreshScraperCache := false
if input.ScraperUserAgent != nil {
config.Set(config.ScraperUserAgent, input.ScraperUserAgent)
c.Set(config.ScraperUserAgent, input.ScraperUserAgent)
refreshScraperCache = true
}
if input.ScraperCDPPath != nil {
config.Set(config.ScraperCDPPath, input.ScraperCDPPath)
c.Set(config.ScraperCDPPath, input.ScraperCDPPath)
refreshScraperCache = true
}
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
if input.StashBoxes != nil {
if err := config.ValidateStashBoxes(input.StashBoxes); err != nil {
if err := c.ValidateStashBoxes(input.StashBoxes); err != nil {
return nil, err
}
config.Set(config.StashBoxes, input.StashBoxes)
c.Set(config.StashBoxes, input.StashBoxes)
}
if err := config.Write(); err != nil {
if err := c.Write(); err != nil {
return makeConfigGeneralResult(), err
}
@@ -171,36 +197,41 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
}
func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) {
c := config.GetInstance()
if input.MenuItems != nil {
config.Set(config.MenuItems, input.MenuItems)
c.Set(config.MenuItems, input.MenuItems)
}
if input.SoundOnPreview != nil {
config.Set(config.SoundOnPreview, *input.SoundOnPreview)
c.Set(config.SoundOnPreview, *input.SoundOnPreview)
}
if input.WallShowTitle != nil {
config.Set(config.WallShowTitle, *input.WallShowTitle)
c.Set(config.WallShowTitle, *input.WallShowTitle)
}
if input.WallPlayback != nil {
config.Set(config.WallPlayback, *input.WallPlayback)
c.Set(config.WallPlayback, *input.WallPlayback)
}
if input.MaximumLoopDuration != nil {
config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
c.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
}
if input.AutostartVideo != nil {
config.Set(config.AutostartVideo, *input.AutostartVideo)
c.Set(config.AutostartVideo, *input.AutostartVideo)
}
if input.ShowStudioAsText != nil {
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
c.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
}
if input.Language != nil {
config.Set(config.Language, *input.Language)
c.Set(config.Language, *input.Language)
}
if input.SlideshowDelay != nil {
c.Set(config.SlideshowDelay, *input.SlideshowDelay)
}
css := ""
@@ -209,15 +240,38 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
css = *input.CSS
}
config.SetCSS(css)
c.SetCSS(css)
if input.CSSEnabled != nil {
config.Set(config.CSSEnabled, *input.CSSEnabled)
c.Set(config.CSSEnabled, *input.CSSEnabled)
}
if err := config.Write(); err != nil {
if err := c.Write(); err != nil {
return makeConfigInterfaceResult(), err
}
return makeConfigInterfaceResult(), nil
}
func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) {
c := config.GetInstance()
var newAPIKey string
if input.Clear == nil || !*input.Clear {
username := c.GetUsername()
if username != "" {
var err error
newAPIKey, err = manager.GenerateAPIKey(username)
if err != nil {
return "", err
}
}
}
c.Set(config.ApiKey, newAPIKey)
if err := c.Write(); err != nil {
return newAPIKey, err
}
return newAPIKey, nil
}

View File

@@ -20,12 +20,15 @@ func (r *mutationResolver) MetadataScan(ctx context.Context, input models.ScanMe
}
func (r *mutationResolver) MetadataImport(ctx context.Context) (string, error) {
manager.GetInstance().Import()
if err := manager.GetInstance().Import(); err != nil {
return "", err
}
return "todo", nil
}
func (r *mutationResolver) ImportObjects(ctx context.Context, input models.ImportObjectsInput) (string, error) {
t, err := manager.CreateImportTask(config.GetVideoFileNamingAlgorithm(), input)
t, err := manager.CreateImportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input)
if err != nil {
return "", err
}
@@ -39,12 +42,15 @@ func (r *mutationResolver) ImportObjects(ctx context.Context, input models.Impor
}
func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) {
manager.GetInstance().Export()
if err := manager.GetInstance().Export(); err != nil {
return "", err
}
return "todo", nil
}
func (r *mutationResolver) ExportObjects(ctx context.Context, input models.ExportObjectsInput) (*string, error) {
t := manager.CreateExportTask(config.GetVideoFileNamingAlgorithm(), input)
t := manager.CreateExportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input)
wg, err := manager.GetInstance().RunSingleTask(t)
if err != nil {
return nil, err

View File

@@ -26,7 +26,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
// Process the base 64 encoded image string
if input.FrontImage != nil {
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage)
frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
if err != nil {
return nil, err
}
@@ -34,7 +34,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
// Process the base 64 encoded image string
if input.BackImage != nil {
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage)
backimageData, err = utils.ProcessImageInput(*input.BackImage)
if err != nil {
return nil, err
}
@@ -126,7 +126,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
var frontimageData []byte
frontImageIncluded := translator.hasField("front_image")
if input.FrontImage != nil {
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage)
frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
if err != nil {
return nil, err
}
@@ -134,7 +134,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
backImageIncluded := translator.hasField("back_image")
var backimageData []byte
if input.BackImage != nil {
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage)
backimageData, err = utils.ProcessImageInput(*input.BackImage)
if err != nil {
return nil, err
}
@@ -189,7 +189,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
// HACK - if front image is null and back image is not null, then set the front image
// to the default image since we can't have a null front image and a non-null back image
if frontimageData == nil && backimageData != nil {
_, frontimageData, _ = utils.ProcessBase64Image(models.DefaultMovieImage)
frontimageData, _ = utils.ProcessImageInput(models.DefaultMovieImage)
}
if err := qb.UpdateImages(movie.ID, frontimageData, backimageData); err != nil {

View File

@@ -3,10 +3,12 @@ package api
import (
"context"
"database/sql"
"fmt"
"strconv"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/utils"
)
@@ -18,7 +20,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
var err error
if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image)
imageData, err = utils.ProcessImageInput(*input.Image)
}
if err != nil {
@@ -83,6 +85,30 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
} else {
newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true}
}
if input.Rating != nil {
newPerformer.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
} else {
newPerformer.Rating = sql.NullInt64{Valid: false}
}
if input.Details != nil {
newPerformer.Details = sql.NullString{String: *input.Details, Valid: true}
}
if input.DeathDate != nil {
newPerformer.DeathDate = models.SQLiteDate{String: *input.DeathDate, Valid: true}
}
if input.HairColor != nil {
newPerformer.HairColor = sql.NullString{String: *input.HairColor, Valid: true}
}
if input.Weight != nil {
weight := int64(*input.Weight)
newPerformer.Weight = sql.NullInt64{Int64: weight, Valid: true}
}
if err := performer.ValidateDeathDate(nil, input.Birthdate, input.DeathDate); err != nil {
if err != nil {
return nil, err
}
}
// Start the transaction and save the performer
var performer *models.Performer
@@ -94,6 +120,12 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return err
}
if len(input.TagIds) > 0 {
if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil {
return err
}
}
// update image table
if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil {
@@ -133,7 +165,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
var err error
imageIncluded := translator.hasField("image")
if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image)
imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil {
return nil, err
}
@@ -171,26 +203,53 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.nullInt64(input.Rating, "rating")
updatedPerformer.Details = translator.nullString(input.Details, "details")
updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color")
updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight")
// Start the transaction and save the performer
var performer *models.Performer
// Start the transaction and save the p
var p *models.Performer
if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Performer()
var err error
performer, err = qb.Update(updatedPerformer)
// need to get existing performer
existing, err := qb.Find(updatedPerformer.ID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("performer with id %d not found", updatedPerformer.ID)
}
if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil {
if err != nil {
return err
}
}
p, err = qb.Update(updatedPerformer)
if err != nil {
return err
}
// Save the tags
if translator.hasField("tag_ids") {
if err := r.updatePerformerTags(qb, p.ID, input.TagIds); err != nil {
return err
}
}
// update image table
if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil {
if err := qb.UpdateImage(p.ID, imageData); err != nil {
return err
}
} else if imageIncluded {
// must be unsetting
if err := qb.DestroyImage(performer.ID); err != nil {
if err := qb.DestroyImage(p.ID); err != nil {
return err
}
}
@@ -208,7 +267,112 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, err
}
return performer, nil
return p, nil
}
func (r *mutationResolver) updatePerformerTags(qb models.PerformerReaderWriter, performerID int, tagsIDs []string) error {
ids, err := utils.StringSliceToIntSlice(tagsIDs)
if err != nil {
return err
}
return qb.UpdateTags(performerID, ids)
}
func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models.BulkPerformerUpdateInput) ([]*models.Performer, error) {
performerIDs, err := utils.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, err
}
// Populate performer from the input
updatedTime := time.Now()
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedPerformer := models.PerformerPartial{
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
updatedPerformer.URL = translator.nullString(input.URL, "url")
updatedPerformer.Birthdate = translator.sqliteDate(input.Birthdate, "birthdate")
updatedPerformer.Ethnicity = translator.nullString(input.Ethnicity, "ethnicity")
updatedPerformer.Country = translator.nullString(input.Country, "country")
updatedPerformer.EyeColor = translator.nullString(input.EyeColor, "eye_color")
updatedPerformer.Height = translator.nullString(input.Height, "height")
updatedPerformer.Measurements = translator.nullString(input.Measurements, "measurements")
updatedPerformer.FakeTits = translator.nullString(input.FakeTits, "fake_tits")
updatedPerformer.CareerLength = translator.nullString(input.CareerLength, "career_length")
updatedPerformer.Tattoos = translator.nullString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.nullString(input.Piercings, "piercings")
updatedPerformer.Aliases = translator.nullString(input.Aliases, "aliases")
updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.nullInt64(input.Rating, "rating")
updatedPerformer.Details = translator.nullString(input.Details, "details")
updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color")
updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight")
if translator.hasField("gender") {
if input.Gender != nil {
updatedPerformer.Gender = &sql.NullString{String: input.Gender.String(), Valid: true}
} else {
updatedPerformer.Gender = &sql.NullString{String: "", Valid: false}
}
}
ret := []*models.Performer{}
// Start the transaction and save the scene marker
if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Performer()
for _, performerID := range performerIDs {
updatedPerformer.ID = performerID
// need to get existing performer
existing, err := qb.Find(performerID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("performer with id %d not found", performerID)
}
if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil {
return err
}
performer, err := qb.Update(updatedPerformer)
if err != nil {
return err
}
ret = append(ret, performer)
// Save the tags
if translator.hasField("tag_ids") {
tagIDs, err := adjustTagIDs(qb, performerID, *input.TagIds)
if err != nil {
return err
}
if err := qb.UpdateTags(performerID, tagIDs); err != nil {
return err
}
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *mutationResolver) PerformerDestroy(ctx context.Context, input models.PerformerDestroyInput) (bool, error) {

View File

@@ -23,6 +23,7 @@ func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, t
}
}
config := config.GetInstance()
serverConnection := common.StashServerConnection{
Scheme: "http",
Port: config.GetPort(),

View File

@@ -80,7 +80,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
if input.CoverImage != nil && *input.CoverImage != "" {
var err error
_, coverImageData, err = utils.ProcessBase64Image(*input.CoverImage)
coverImageData, err = utils.ProcessImageInput(*input.CoverImage)
if err != nil {
return nil, err
}
@@ -139,7 +139,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
// only update the cover image if provided and everything else was successful
if coverImageData != nil {
err = manager.SetSceneScreenshot(scene.GetHash(config.GetVideoFileNamingAlgorithm()), coverImageData)
err = manager.SetSceneScreenshot(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
if err != nil {
return nil, err
}
@@ -253,7 +253,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
// Save the tags
if translator.hasField("tag_ids") {
tagIDs, err := adjustSceneTagIDs(qb, sceneID, *input.TagIds)
tagIDs, err := adjustTagIDs(qb, sceneID, *input.TagIds)
if err != nil {
return err
}
@@ -330,7 +330,11 @@ func adjustScenePerformerIDs(qb models.SceneReader, sceneID int, ids models.Bulk
return adjustIDs(ret, ids), nil
}
func adjustSceneTagIDs(qb models.SceneReader, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) {
type tagIDsGetter interface {
GetTagIDs(id int) ([]int, error)
}
func adjustTagIDs(qb tagIDsGetter, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) {
ret, err = qb.GetTagIDs(sceneID)
if err != nil {
return nil, err
@@ -380,7 +384,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
// if delete generated is true, then delete the generated files
// for the scene
if input.DeleteGenerated != nil && *input.DeleteGenerated {
manager.DeleteGeneratedSceneFiles(scene, config.GetVideoFileNamingAlgorithm())
manager.DeleteGeneratedSceneFiles(scene, config.GetInstance().GetVideoFileNamingAlgorithm())
}
// if delete file is true, then delete the file as well
@@ -422,7 +426,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
f()
}
fileNamingAlgo := config.GetVideoFileNamingAlgorithm()
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
for _, scene := range scenes {
// if delete generated is true, then delete the generated files
// for the scene
@@ -582,7 +586,7 @@ func (r *mutationResolver) changeMarker(ctx context.Context, changeType int, cha
// remove the marker preview if the timestamp was changed
if scene != nil && existingMarker != nil && existingMarker.Seconds != changedMarker.Seconds {
seconds := int(existingMarker.Seconds)
manager.DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm())
manager.DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm())
}
return sceneMarker, nil

View File

@@ -4,13 +4,14 @@ import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox"
)
func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input models.StashBoxFingerprintSubmissionInput) (bool, error) {
boxes := config.GetStashBoxes()
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
@@ -20,3 +21,8 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
return client.SubmitStashBoxFingerprints(input.SceneIds, boxes[input.StashBoxIndex].Endpoint)
}
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input models.StashBoxBatchPerformerTagInput) (string, error) {
manager.GetInstance().StashBoxBatchPerformerTag(input)
return "todo", nil
}

View File

@@ -20,7 +20,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
// Process the base 64 encoded image string
if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image)
imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil {
return nil, err
}
@@ -42,6 +42,15 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true}
}
if input.Rating != nil {
newStudio.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
} else {
newStudio.Rating = sql.NullInt64{Valid: false}
}
if input.Details != nil {
newStudio.Details = sql.NullString{String: *input.Details, Valid: true}
}
// Start the transaction and save the studio
var studio *models.Studio
if err := r.withTxn(ctx, func(repo models.Repository) error {
@@ -96,7 +105,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
imageIncluded := translator.hasField("image")
if input.Image != nil {
var err error
_, imageData, err = utils.ProcessBase64Image(*input.Image)
imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil {
return nil, err
}
@@ -109,7 +118,9 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
}
updatedStudio.URL = translator.nullString(input.URL, "url")
updatedStudio.Details = translator.nullString(input.Details, "details")
updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id")
updatedStudio.Rating = translator.nullInt64(input.Rating, "rating")
// Start the transaction and save the studio
var studio *models.Studio

View File

@@ -24,7 +24,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
var err error
if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image)
imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil {
return nil, err
@@ -82,7 +82,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
imageIncluded := translator.hasField("image")
if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image)
imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil {
return nil, err

View File

@@ -34,6 +34,7 @@ func makeConfigResult() *models.ConfigResult {
}
func makeConfigGeneralResult() *models.ConfigGeneralResult {
config := config.GetInstance()
logFile := config.GetLogFile()
maxTranscodeSize := config.GetMaxTranscodeSize()
@@ -46,6 +47,8 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
Stashes: config.GetStashPaths(),
DatabasePath: config.GetDatabasePath(),
GeneratedPath: config.GetGeneratedPath(),
ConfigFilePath: config.GetConfigFilePath(),
ScrapersPath: config.GetScrapersPath(),
CachePath: config.GetCachePath(),
CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
@@ -57,6 +60,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
PreviewPreset: config.GetPreviewPreset(),
MaxTranscodeSize: &maxTranscodeSize,
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
APIKey: config.GetAPIKey(),
Username: config.GetUsername(),
Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(),
@@ -71,12 +75,14 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
Excludes: config.GetExcludes(),
ImageExcludes: config.GetImageExcludes(),
ScraperUserAgent: &scraperUserAgent,
ScraperCertCheck: config.GetScraperCertCheck(),
ScraperCDPPath: &scraperCDPPath,
StashBoxes: config.GetStashBoxes(),
}
}
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
config := config.GetInstance()
menuItems := config.GetMenuItems()
soundOnPreview := config.GetSoundOnPreview()
wallShowTitle := config.GetWallShowTitle()
@@ -87,6 +93,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
css := config.GetCSS()
cssEnabled := config.GetCSSEnabled()
language := config.GetLanguage()
slideshowDelay := config.GetSlideshowDelay()
return &models.ConfigInterfaceResult{
MenuItems: menuItems,
@@ -99,5 +106,6 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
CSS: &css,
CSSEnabled: &cssEnabled,
Language: &language,
SlideshowDelay: &slideshowDelay,
}
}

View File

@@ -53,14 +53,3 @@ func (r *queryResolver) AllMovies(ctx context.Context) (ret []*models.Movie, err
return ret, nil
}
func (r *queryResolver) AllMoviesSlim(ctx context.Context) (ret []*models.Movie, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Movie().AllSlim()
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -52,14 +52,3 @@ func (r *queryResolver) AllPerformers(ctx context.Context) (ret []*models.Perfor
return ret, nil
}
func (r *queryResolver) AllPerformersSlim(ctx context.Context) (ret []*models.Performer, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Performer().AllSlim()
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -59,12 +59,25 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input models.SceneH
return scene, nil
}
func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIds []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) {
func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
scenes, total, err := repo.Scene().Query(sceneFilter, filter)
var scenes []*models.Scene
var total int
var err error
if len(sceneIDs) > 0 {
scenes, err = repo.Scene().FindMany(sceneIDs)
if err == nil {
total = len(scenes)
}
} else {
scenes, total, err = repo.Scene().Query(sceneFilter, filter)
}
if err != nil {
return err
}
ret = &models.FindScenesResultType{
Count: total,
Scenes: scenes,
@@ -138,3 +151,18 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.
return ret, nil
}
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int) (ret [][]*models.Scene, err error) {
dist := 0
if distance != nil {
dist = *distance
}
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Scene().FindDuplicates(dist)
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -54,14 +54,3 @@ func (r *queryResolver) AllStudios(ctx context.Context) (ret []*models.Studio, e
return ret, nil
}
func (r *queryResolver) AllStudiosSlim(ctx context.Context) (ret []*models.Studio, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Studio().AllSlim()
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -53,14 +53,3 @@ func (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err err
return ret, nil
}
func (r *queryResolver) AllTagsSlim(ctx context.Context) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Tag().AllSlim()
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -17,3 +17,7 @@ func (r *queryResolver) JobStatus(ctx context.Context) (*models.MetadataUpdateSt
return &ret, nil
}
func (r *queryResolver) SystemStatus(ctx context.Context) (*models.SystemStatus, error) {
return manager.GetInstance().GetSystemStatus(), nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
)
@@ -29,5 +30,5 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*models
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID)
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL())
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetInstance().GetMaxStreamingTranscodeSize())
}

View File

@@ -88,8 +88,8 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
return manager.GetInstance().ScraperCache.ScrapeMovieURL(url)
}
func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxQueryInput) ([]*models.ScrapedScene, error) {
boxes := config.GetStashBoxes()
func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxSceneQueryInput) ([]*models.ScrapedScene, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
@@ -107,3 +107,23 @@ func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.Sta
return nil, nil
}
func (r *queryResolver) QueryStashBoxPerformer(ctx context.Context, input models.StashBoxPerformerQueryInput) ([]*models.StashBoxPerformerQueryResult, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
if len(input.PerformerIds) > 0 {
return client.FindStashBoxPerformersByNames(input.PerformerIds)
}
if input.Q != nil {
return client.QueryStashBoxPerformer(*input.Q)
}
return nil, nil
}

View File

@@ -69,7 +69,7 @@ func getSceneFileContainer(scene *models.Scene) ffmpeg.Container {
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
fileNamingAlgo := config.GetVideoFileNamingAlgorithm()
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo))
manager.RegisterStream(filepath, &w)
@@ -158,7 +158,7 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi
options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec)
options.StartTime = startTime
options.MaxTranscodeSize = config.GetMaxStreamingTranscodeSize()
options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize()
if requestedSize != "" {
options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize)
}
@@ -178,7 +178,7 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
// fall back to the scene image blob if the file isn't present
screenshotExists, _ := utils.FileExists(filepath)
@@ -196,13 +196,13 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
utils.ServeFileNoCache(w, r, filepath)
}
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
http.ServeFile(w, r, filepath)
}
@@ -267,14 +267,14 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
w.Header().Set("Content-Type", "text/vtt")
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
http.ServeFile(w, r, filepath)
}
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
w.Header().Set("Content-Type", "image/jpeg")
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
http.ServeFile(w, r, filepath)
}
@@ -291,7 +291,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request)
http.Error(w, http.StatusText(500), 500)
return
}
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
http.ServeFile(w, r, filepath)
}
@@ -308,7 +308,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
http.Error(w, http.StatusText(500), 500)
return
}
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
// If the image doesn't exist, send the placeholder
exists, _ := utils.FileExists(filepath)

View File

@@ -8,12 +8,11 @@ import (
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/99designs/gqlgen/handler"
"github.com/go-chi/chi"
@@ -21,7 +20,6 @@ import (
"github.com/gobuffalo/packr/v2"
"github.com/gorilla/websocket"
"github.com/rs/cors"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
@@ -37,9 +35,13 @@ var githash string
var uiBox *packr.Box
//var legacyUiBox *packr.Box
var setupUIBox *packr.Box
var loginUIBox *packr.Box
const (
ApiKeyHeader = "ApiKey"
ApiKeyParameter = "apikey"
)
func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/login") || r.URL.Path == "/css"
}
@@ -47,14 +49,34 @@ func allowUnauthenticated(r *http.Request) bool {
func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := config.GetInstance()
ctx := r.Context()
// translate api key into current user, if present
userID := ""
apiKey := r.Header.Get(ApiKeyHeader)
var err error
// handle session
userID, err = getSessionUserID(w, r)
// try getting the api key as a query parameter
if apiKey == "" {
apiKey = r.URL.Query().Get(ApiKeyParameter)
}
if apiKey != "" {
// match against configured API and set userID to the
// configured username. In future, we'll want to
// get the username from the key.
if c.GetAPIKey() != apiKey {
w.Header().Add("WWW-Authenticate", `FormBased`)
w.WriteHeader(http.StatusUnauthorized)
return
}
userID = c.GetUsername()
} else {
// handle session
userID, err = getSessionUserID(w, r)
}
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@@ -63,9 +85,7 @@ func authenticateHandler() func(http.Handler) http.Handler {
}
// handle redirect if no user and user is required
if userID == "" && config.HasCredentials() && !allowUnauthenticated(r) {
// always allow
if userID == "" && c.HasCredentials() && !allowUnauthenticated(r) {
// if we don't have a userID, then redirect
// if graphql was requested, we just return a forbidden error
if r.URL.Path == "/graphql" {
@@ -94,14 +114,11 @@ func authenticateHandler() func(http.Handler) http.Handler {
}
}
const setupEndPoint = "/setup"
const migrateEndPoint = "/migrate"
const loginEndPoint = "/login"
func Start() {
uiBox = packr.New("UI Box", "../../ui/v2.5/build")
//legacyUiBox = packr.New("UI Box", "../../ui/v1/dist/stash-frontend")
setupUIBox = packr.New("Setup UI Box", "../../ui/setup")
loginUIBox = packr.New("Login UI Box", "../../ui/login")
initSessionStore()
@@ -109,18 +126,18 @@ func Start() {
r := chi.NewRouter()
r.Use(middleware.Heartbeat("/healthz"))
r.Use(authenticateHandler())
r.Use(middleware.Recoverer)
if config.GetLogAccess() {
c := config.GetInstance()
if c.GetLogAccess() {
r.Use(middleware.Logger)
}
r.Use(middleware.DefaultCompress)
r.Use(middleware.StripSlashes)
r.Use(cors.AllowAll().Handler)
r.Use(BaseURLMiddleware)
r.Use(ConfigCheckMiddleware)
r.Use(DatabaseCheckMiddleware)
recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
logger.Error(err)
@@ -134,13 +151,15 @@ func Start() {
return true
},
})
maxUploadSize := handler.UploadMaxSize(config.GetMaxUploadSize())
maxUploadSize := handler.UploadMaxSize(c.GetMaxUploadSize())
websocketKeepAliveDuration := handler.WebsocketKeepAliveDuration(10 * time.Second)
txnManager := manager.GetInstance().TxnManager
resolver := &Resolver{
txnManager: txnManager,
}
gqlHandler := handler.GraphQL(models.NewExecutableSchema(models.Config{Resolvers: resolver}), recoverFunc, websocketUpgrader, maxUploadSize)
gqlHandler := handler.GraphQL(models.NewExecutableSchema(models.Config{Resolvers: resolver}), recoverFunc, websocketUpgrader, websocketKeepAliveDuration, maxUploadSize)
r.Handle("/graphql", gqlHandler)
r.Handle("/playground", handler.Playground("GraphQL playground", "/graphql"))
@@ -173,12 +192,12 @@ func Start() {
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
if !config.GetCSSEnabled() {
if !c.GetCSSEnabled() {
return
}
// search for custom.css in current directory, then $HOME/.stash
fn := config.GetCSSPath()
fn := c.GetCSSPath()
exists, _ := utils.FileExists(fn)
if !exists {
return
@@ -187,21 +206,6 @@ func Start() {
http.ServeFile(w, r, fn)
})
// Serve the migration UI
r.Get("/migrate", getMigrateHandler)
r.Post("/migrate", doMigrateHandler)
// Serve the setup UI
r.HandleFunc("/setup*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
data, _ := setupUIBox.Find("index.html")
_, _ = w.Write(data)
} else {
r.URL.Path = strings.Replace(r.URL.Path, "/setup", "", 1)
http.FileServer(setupUIBox).ServeHTTP(w, r)
}
})
r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
@@ -212,62 +216,9 @@ func Start() {
http.FileServer(loginUIBox).ServeHTTP(w, r)
}
})
r.Post("/init", func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), 500)
}
stash := filepath.Clean(r.Form.Get("stash"))
generated := filepath.Clean(r.Form.Get("generated"))
metadata := filepath.Clean(r.Form.Get("metadata"))
cache := filepath.Clean(r.Form.Get("cache"))
//downloads := filepath.Clean(r.Form.Get("downloads")) // TODO
downloads := filepath.Join(metadata, "downloads")
exists, _ := utils.DirExists(stash)
if !exists || stash == "." {
http.Error(w, fmt.Sprintf("the stash path either doesn't exist, or is not a directory <%s>. Go back and try again.", stash), 500)
return
}
exists, _ = utils.DirExists(generated)
if !exists || generated == "." {
http.Error(w, fmt.Sprintf("the generated path either doesn't exist, or is not a directory <%s>. Go back and try again.", generated), 500)
return
}
exists, _ = utils.DirExists(metadata)
if !exists || metadata == "." {
http.Error(w, fmt.Sprintf("the metadata path either doesn't exist, or is not a directory <%s> Go back and try again.", metadata), 500)
return
}
exists, _ = utils.DirExists(cache)
if !exists || cache == "." {
http.Error(w, fmt.Sprintf("the cache path either doesn't exist, or is not a directory <%s> Go back and try again.", cache), 500)
return
}
_ = os.Mkdir(downloads, 0755)
// #536 - set stash as slice of strings
config.Set(config.Stash, []string{stash})
config.Set(config.Generated, generated)
config.Set(config.Metadata, metadata)
config.Set(config.Cache, cache)
config.Set(config.Downloads, downloads)
if err := config.Write(); err != nil {
http.Error(w, fmt.Sprintf("there was an error saving the config file: %s", err), 500)
return
}
manager.GetInstance().RefreshConfig()
http.Redirect(w, r, "/", 301)
})
// Serve static folders
customServedFolders := config.GetCustomServedFolders()
customServedFolders := c.GetCustomServedFolders()
if customServedFolders != nil {
r.HandleFunc("/custom/*", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1)
@@ -283,9 +234,21 @@ func Start() {
})
}
customUILocation := c.GetCustomUILocation()
// Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if customUILocation != "" {
if r.URL.Path == "index.html" || ext == "" {
r.URL.Path = "/"
}
http.FileServer(http.Dir(customUILocation)).ServeHTTP(w, r)
return
}
if ext == ".html" || ext == "" {
data, _ := uiBox.Find("index.html")
_, _ = w.Write(data)
@@ -298,13 +261,13 @@ func Start() {
}
})
displayHost := config.GetHost()
displayHost := c.GetHost()
if displayHost == "0.0.0.0" {
displayHost = "localhost"
}
displayAddress := displayHost + ":" + strconv.Itoa(config.GetPort())
displayAddress := displayHost + ":" + strconv.Itoa(c.GetPort())
address := config.GetHost() + ":" + strconv.Itoa(config.GetPort())
address := c.GetHost() + ":" + strconv.Itoa(c.GetPort())
if tlsConfig := makeTLSConfig(); tlsConfig != nil {
httpsServer := &http.Server{
Addr: address,
@@ -399,7 +362,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
}
baseURL := scheme + "://" + r.Host
externalHost := config.GetExternalHost()
externalHost := config.GetInstance().GetExternalHost()
if externalHost != "" {
baseURL = externalHost
}
@@ -410,34 +373,3 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
}
return http.HandlerFunc(fn)
}
func ConfigCheckMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
shouldRedirect := ext == "" && r.Method == "GET"
if !config.IsValid() && shouldRedirect {
// #539 - don't redirect if loading login page
if !strings.HasPrefix(r.URL.Path, setupEndPoint) && !strings.HasPrefix(r.URL.Path, loginEndPoint) {
http.Redirect(w, r, setupEndPoint, http.StatusFound)
return
}
}
next.ServeHTTP(w, r)
})
}
func DatabaseCheckMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
shouldRedirect := ext == "" && r.Method == "GET"
if shouldRedirect && database.NeedsMigration() {
// #451 - don't redirect if loading login page
// #539 - or setup page
if !strings.HasPrefix(r.URL.Path, migrateEndPoint) && !strings.HasPrefix(r.URL.Path, loginEndPoint) && !strings.HasPrefix(r.URL.Path, setupEndPoint) {
http.Redirect(w, r, migrateEndPoint, http.StatusFound)
return
}
}
next.ServeHTTP(w, r)
})
}

View File

@@ -19,7 +19,7 @@ const userIDKey = "userID"
const returnURLParam = "returnURL"
var sessionStore = sessions.NewCookieStore(config.GetSessionStoreKey())
var sessionStore = sessions.NewCookieStore(config.GetInstance().GetSessionStoreKey())
type loginTemplateData struct {
URL string
@@ -27,7 +27,7 @@ type loginTemplateData struct {
}
func initSessionStore() {
sessionStore.MaxAge(config.GetMaxSessionAge())
sessionStore.MaxAge(config.GetInstance().GetMaxSessionAge())
}
func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) {
@@ -45,7 +45,7 @@ func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string)
}
func getLoginHandler(w http.ResponseWriter, r *http.Request) {
if !config.HasCredentials() {
if !config.GetInstance().HasCredentials() {
http.Redirect(w, r, "/", http.StatusFound)
return
}
@@ -66,7 +66,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
// authenticate the user
if !config.ValidateCredentials(username, password) {
if !config.GetInstance().ValidateCredentials(username, password) {
// redirect back to the login page with an error
redirectToLogin(w, url, "Username or password is invalid")
return

View File

@@ -1,25 +1,28 @@
package urlbuilders
import (
"github.com/stashapp/stash/pkg/models"
"strconv"
)
type ImageURLBuilder struct {
BaseURL string
ImageID string
BaseURL string
ImageID string
UpdatedAt string
}
func NewImageURLBuilder(baseURL string, imageID int) ImageURLBuilder {
func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
return ImageURLBuilder{
BaseURL: baseURL,
ImageID: strconv.Itoa(imageID),
BaseURL: baseURL,
ImageID: strconv.Itoa(image.ID),
UpdatedAt: strconv.FormatInt(image.UpdatedAt.Timestamp.Unix(), 10),
}
}
func (b ImageURLBuilder) GetImageURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/image"
return b.BaseURL + "/image/" + b.ImageID + "/image?" + b.UpdatedAt
}
func (b ImageURLBuilder) GetThumbnailURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail"
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?" + b.UpdatedAt
}

View File

@@ -1,23 +1,28 @@
package urlbuilders
import "strconv"
import (
"github.com/stashapp/stash/pkg/models"
"strconv"
)
type MovieURLBuilder struct {
BaseURL string
MovieID string
BaseURL string
MovieID string
UpdatedAt string
}
func NewMovieURLBuilder(baseURL string, movieID int) MovieURLBuilder {
func NewMovieURLBuilder(baseURL string, movie *models.Movie) MovieURLBuilder {
return MovieURLBuilder{
BaseURL: baseURL,
MovieID: strconv.Itoa(movieID),
BaseURL: baseURL,
MovieID: strconv.Itoa(movie.ID),
UpdatedAt: strconv.FormatInt(movie.UpdatedAt.Timestamp.Unix(), 10),
}
}
func (b MovieURLBuilder) GetMovieFrontImageURL() string {
return b.BaseURL + "/movie/" + b.MovieID + "/frontimage"
return b.BaseURL + "/movie/" + b.MovieID + "/frontimage?" + b.UpdatedAt
}
func (b MovieURLBuilder) GetMovieBackImageURL() string {
return b.BaseURL + "/movie/" + b.MovieID + "/backimage"
return b.BaseURL + "/movie/" + b.MovieID + "/backimage?" + b.UpdatedAt
}

View File

@@ -1,19 +1,24 @@
package urlbuilders
import "strconv"
import (
"github.com/stashapp/stash/pkg/models"
"strconv"
)
type PerformerURLBuilder struct {
BaseURL string
PerformerID string
UpdatedAt string
}
func NewPerformerURLBuilder(baseURL string, performerID int) PerformerURLBuilder {
func NewPerformerURLBuilder(baseURL string, performer *models.Performer) PerformerURLBuilder {
return PerformerURLBuilder{
BaseURL: baseURL,
PerformerID: strconv.Itoa(performerID),
PerformerID: strconv.Itoa(performer.ID),
UpdatedAt: strconv.FormatInt(performer.UpdatedAt.Timestamp.Unix(), 10),
}
}
func (b PerformerURLBuilder) GetPerformerImageURL() string {
return b.BaseURL + "/performer/" + b.PerformerID + "/image"
return b.BaseURL + "/performer/" + b.PerformerID + "/image?" + b.UpdatedAt
}

View File

@@ -1,6 +1,7 @@
package urlbuilders
import (
"fmt"
"strconv"
"time"
)
@@ -8,6 +9,7 @@ import (
type SceneURLBuilder struct {
BaseURL string
SceneID string
APIKey string
}
func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
@@ -18,7 +20,11 @@ func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
}
func (b SceneURLBuilder) GetStreamURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/stream"
var apiKeyParam string
if b.APIKey != "" {
apiKeyParam = fmt.Sprintf("?apikey=%s", b.APIKey)
}
return fmt.Sprintf("%s/scene/%s/stream%s", b.BaseURL, b.SceneID, apiKeyParam)
}
func (b SceneURLBuilder) GetStreamPreviewURL() string {
@@ -33,6 +39,10 @@ func (b SceneURLBuilder) GetSpriteVTTURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "_thumbs.vtt"
}
func (b SceneURLBuilder) GetSpriteURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "_sprite.jpg"
}
func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?" + strconv.FormatInt(updateTime.Unix(), 10)
}

View File

@@ -1,19 +1,24 @@
package urlbuilders
import "strconv"
import (
"github.com/stashapp/stash/pkg/models"
"strconv"
)
type StudioURLBuilder struct {
BaseURL string
StudioID string
BaseURL string
StudioID string
UpdatedAt string
}
func NewStudioURLBuilder(baseURL string, studioID int) StudioURLBuilder {
func NewStudioURLBuilder(baseURL string, studio *models.Studio) StudioURLBuilder {
return StudioURLBuilder{
BaseURL: baseURL,
StudioID: strconv.Itoa(studioID),
BaseURL: baseURL,
StudioID: strconv.Itoa(studio.ID),
UpdatedAt: strconv.FormatInt(studio.UpdatedAt.Timestamp.Unix(), 10),
}
}
func (b StudioURLBuilder) GetStudioImageURL() string {
return b.BaseURL + "/studio/" + b.StudioID + "/image"
return b.BaseURL + "/studio/" + b.StudioID + "/image?" + b.UpdatedAt
}

View File

@@ -1,19 +1,24 @@
package urlbuilders
import "strconv"
import (
"github.com/stashapp/stash/pkg/models"
"strconv"
)
type TagURLBuilder struct {
BaseURL string
TagID string
BaseURL string
TagID string
UpdatedAt string
}
func NewTagURLBuilder(baseURL string, tagID int) TagURLBuilder {
func NewTagURLBuilder(baseURL string, tag *models.Tag) TagURLBuilder {
return TagURLBuilder{
BaseURL: baseURL,
TagID: strconv.Itoa(tagID),
BaseURL: baseURL,
TagID: strconv.Itoa(tag.ID),
UpdatedAt: strconv.FormatInt(tag.UpdatedAt.Timestamp.Unix(), 10),
}
}
func (b TagURLBuilder) GetTagImageURL() string {
return b.BaseURL + "/tag/" + b.TagID + "/image"
return b.BaseURL + "/tag/" + b.TagID + "/image?" + b.UpdatedAt
}

117
pkg/autotag/gallery.go Normal file
View File

@@ -0,0 +1,117 @@
package autotag
import (
"fmt"
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/models"
)
func galleryPathsFilter(paths []string) *models.GalleryFilterType {
if paths == nil {
return nil
}
sep := string(filepath.Separator)
var ret *models.GalleryFilterType
var or *models.GalleryFilterType
for _, p := range paths {
newOr := &models.GalleryFilterType{}
if or != nil {
or.Or = newOr
} else {
ret = newOr
}
or = newOr
if !strings.HasSuffix(p, sep) {
p = p + sep
}
or.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}
func getMatchingGalleries(name string, paths []string, galleryReader models.GalleryReader) ([]*models.Gallery, error) {
regex := getPathQueryRegex(name)
organized := false
filter := models.GalleryFilterType{
Path: &models.StringCriterionInput{
Value: "(?i)" + regex,
Modifier: models.CriterionModifierMatchesRegex,
},
Organized: &organized,
}
filter.And = galleryPathsFilter(paths)
pp := models.PerPageAll
gallerys, _, err := galleryReader.Query(&filter, &models.FindFilterType{
PerPage: &pp,
})
if err != nil {
return nil, fmt.Errorf("error querying gallerys with regex '%s': %s", regex, err.Error())
}
var ret []*models.Gallery
for _, p := range gallerys {
if nameMatchesPath(name, p.Path.String) {
ret = append(ret, p)
}
}
return ret, nil
}
func getGalleryFileTagger(s *models.Gallery) tagger {
return tagger{
ID: s.ID,
Type: "gallery",
Name: s.GetTitle(),
Path: s.Path.String,
}
}
// GalleryPerformers tags the provided gallery with performers whose name matches the gallery's path.
func GalleryPerformers(s *models.Gallery, rw models.GalleryReaderWriter, performerReader models.PerformerReader) error {
t := getGalleryFileTagger(s)
return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) {
return gallery.AddPerformer(rw, subjectID, otherID)
})
}
// GalleryStudios tags the provided gallery with the first studio whose name matches the gallery's path.
//
// Gallerys will not be tagged if studio is already set.
func GalleryStudios(s *models.Gallery, rw models.GalleryReaderWriter, studioReader models.StudioReader) error {
if s.StudioID.Valid {
// don't modify
return nil
}
t := getGalleryFileTagger(s)
return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) {
return addGalleryStudio(rw, subjectID, otherID)
})
}
// GalleryTags tags the provided gallery with tags whose name matches the gallery's path.
func GalleryTags(s *models.Gallery, rw models.GalleryReaderWriter, tagReader models.TagReader) error {
t := getGalleryFileTagger(s)
return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) {
return gallery.AddTag(rw, subjectID, otherID)
})
}

145
pkg/autotag/gallery_test.go Normal file
View File

@@ -0,0 +1,145 @@
package autotag
import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const galleryExt = "zip"
func TestGalleryPerformers(t *testing.T) {
const galleryID = 1
const performerName = "performer name"
const performerID = 2
performer := models.Performer{
ID: performerID,
Name: models.NullString(performerName),
}
const reversedPerformerName = "name performer"
const reversedPerformerID = 3
reversedPerformer := models.Performer{
ID: reversedPerformerID,
Name: models.NullString(reversedPerformerName),
}
testTables := generateTestTable(performerName, galleryExt)
assert := assert.New(t)
for _, test := range testTables {
mockPerformerReader := &mocks.PerformerReaderWriter{}
mockGalleryReader := &mocks.GalleryReaderWriter{}
mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once()
if test.Matches {
mockGalleryReader.On("GetPerformerIDs", galleryID).Return(nil, nil).Once()
mockGalleryReader.On("UpdatePerformers", galleryID, []int{performerID}).Return(nil).Once()
}
gallery := models.Gallery{
ID: galleryID,
Path: models.NullString(test.Path),
}
err := GalleryPerformers(&gallery, mockGalleryReader, mockPerformerReader)
assert.Nil(err)
mockPerformerReader.AssertExpectations(t)
mockGalleryReader.AssertExpectations(t)
}
}
func TestGalleryStudios(t *testing.T) {
const galleryID = 1
const studioName = "studio name"
const studioID = 2
studio := models.Studio{
ID: studioID,
Name: models.NullString(studioName),
}
const reversedStudioName = "name studio"
const reversedStudioID = 3
reversedStudio := models.Studio{
ID: reversedStudioID,
Name: models.NullString(reversedStudioName),
}
testTables := generateTestTable(studioName, galleryExt)
assert := assert.New(t)
for _, test := range testTables {
mockStudioReader := &mocks.StudioReaderWriter{}
mockGalleryReader := &mocks.GalleryReaderWriter{}
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
if test.Matches {
mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once()
expectedStudioID := models.NullInt64(studioID)
mockGalleryReader.On("UpdatePartial", models.GalleryPartial{
ID: galleryID,
StudioID: &expectedStudioID,
}).Return(nil, nil).Once()
}
gallery := models.Gallery{
ID: galleryID,
Path: models.NullString(test.Path),
}
err := GalleryStudios(&gallery, mockGalleryReader, mockStudioReader)
assert.Nil(err)
mockStudioReader.AssertExpectations(t)
mockGalleryReader.AssertExpectations(t)
}
}
func TestGalleryTags(t *testing.T) {
const galleryID = 1
const tagName = "tag name"
const tagID = 2
tag := models.Tag{
ID: tagID,
Name: tagName,
}
const reversedTagName = "name tag"
const reversedTagID = 3
reversedTag := models.Tag{
ID: reversedTagID,
Name: reversedTagName,
}
testTables := generateTestTable(tagName, galleryExt)
assert := assert.New(t)
for _, test := range testTables {
mockTagReader := &mocks.TagReaderWriter{}
mockGalleryReader := &mocks.GalleryReaderWriter{}
mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()
if test.Matches {
mockGalleryReader.On("GetTagIDs", galleryID).Return(nil, nil).Once()
mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once()
}
gallery := models.Gallery{
ID: galleryID,
Path: models.NullString(test.Path),
}
err := GalleryTags(&gallery, mockGalleryReader, mockTagReader)
assert.Nil(err)
mockTagReader.AssertExpectations(t)
mockGalleryReader.AssertExpectations(t)
}
}

117
pkg/autotag/image.go Normal file
View File

@@ -0,0 +1,117 @@
package autotag
import (
"fmt"
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
)
func imagePathsFilter(paths []string) *models.ImageFilterType {
if paths == nil {
return nil
}
sep := string(filepath.Separator)
var ret *models.ImageFilterType
var or *models.ImageFilterType
for _, p := range paths {
newOr := &models.ImageFilterType{}
if or != nil {
or.Or = newOr
} else {
ret = newOr
}
or = newOr
if !strings.HasSuffix(p, sep) {
p = p + sep
}
or.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}
func getMatchingImages(name string, paths []string, imageReader models.ImageReader) ([]*models.Image, error) {
regex := getPathQueryRegex(name)
organized := false
filter := models.ImageFilterType{
Path: &models.StringCriterionInput{
Value: "(?i)" + regex,
Modifier: models.CriterionModifierMatchesRegex,
},
Organized: &organized,
}
filter.And = imagePathsFilter(paths)
pp := models.PerPageAll
images, _, err := imageReader.Query(&filter, &models.FindFilterType{
PerPage: &pp,
})
if err != nil {
return nil, fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error())
}
var ret []*models.Image
for _, p := range images {
if nameMatchesPath(name, p.Path) {
ret = append(ret, p)
}
}
return ret, nil
}
func getImageFileTagger(s *models.Image) tagger {
return tagger{
ID: s.ID,
Type: "image",
Name: s.GetTitle(),
Path: s.Path,
}
}
// ImagePerformers tags the provided image with performers whose name matches the image's path.
func ImagePerformers(s *models.Image, rw models.ImageReaderWriter, performerReader models.PerformerReader) error {
t := getImageFileTagger(s)
return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) {
return image.AddPerformer(rw, subjectID, otherID)
})
}
// ImageStudios tags the provided image with the first studio whose name matches the image's path.
//
// Images will not be tagged if studio is already set.
func ImageStudios(s *models.Image, rw models.ImageReaderWriter, studioReader models.StudioReader) error {
if s.StudioID.Valid {
// don't modify
return nil
}
t := getImageFileTagger(s)
return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) {
return addImageStudio(rw, subjectID, otherID)
})
}
// ImageTags tags the provided image with tags whose name matches the image's path.
func ImageTags(s *models.Image, rw models.ImageReaderWriter, tagReader models.TagReader) error {
t := getImageFileTagger(s)
return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) {
return image.AddTag(rw, subjectID, otherID)
})
}

145
pkg/autotag/image_test.go Normal file
View File

@@ -0,0 +1,145 @@
package autotag
import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const imageExt = "jpg"
func TestImagePerformers(t *testing.T) {
const imageID = 1
const performerName = "performer name"
const performerID = 2
performer := models.Performer{
ID: performerID,
Name: models.NullString(performerName),
}
const reversedPerformerName = "name performer"
const reversedPerformerID = 3
reversedPerformer := models.Performer{
ID: reversedPerformerID,
Name: models.NullString(reversedPerformerName),
}
testTables := generateTestTable(performerName, imageExt)
assert := assert.New(t)
for _, test := range testTables {
mockPerformerReader := &mocks.PerformerReaderWriter{}
mockImageReader := &mocks.ImageReaderWriter{}
mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once()
if test.Matches {
mockImageReader.On("GetPerformerIDs", imageID).Return(nil, nil).Once()
mockImageReader.On("UpdatePerformers", imageID, []int{performerID}).Return(nil).Once()
}
image := models.Image{
ID: imageID,
Path: test.Path,
}
err := ImagePerformers(&image, mockImageReader, mockPerformerReader)
assert.Nil(err)
mockPerformerReader.AssertExpectations(t)
mockImageReader.AssertExpectations(t)
}
}
func TestImageStudios(t *testing.T) {
const imageID = 1
const studioName = "studio name"
const studioID = 2
studio := models.Studio{
ID: studioID,
Name: models.NullString(studioName),
}
const reversedStudioName = "name studio"
const reversedStudioID = 3
reversedStudio := models.Studio{
ID: reversedStudioID,
Name: models.NullString(reversedStudioName),
}
testTables := generateTestTable(studioName, imageExt)
assert := assert.New(t)
for _, test := range testTables {
mockStudioReader := &mocks.StudioReaderWriter{}
mockImageReader := &mocks.ImageReaderWriter{}
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
if test.Matches {
mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once()
expectedStudioID := models.NullInt64(studioID)
mockImageReader.On("Update", models.ImagePartial{
ID: imageID,
StudioID: &expectedStudioID,
}).Return(nil, nil).Once()
}
image := models.Image{
ID: imageID,
Path: test.Path,
}
err := ImageStudios(&image, mockImageReader, mockStudioReader)
assert.Nil(err)
mockStudioReader.AssertExpectations(t)
mockImageReader.AssertExpectations(t)
}
}
func TestImageTags(t *testing.T) {
const imageID = 1
const tagName = "tag name"
const tagID = 2
tag := models.Tag{
ID: tagID,
Name: tagName,
}
const reversedTagName = "name tag"
const reversedTagID = 3
reversedTag := models.Tag{
ID: reversedTagID,
Name: reversedTagName,
}
testTables := generateTestTable(tagName, imageExt)
assert := assert.New(t)
for _, test := range testTables {
mockTagReader := &mocks.TagReaderWriter{}
mockImageReader := &mocks.ImageReaderWriter{}
mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()
if test.Matches {
mockImageReader.On("GetTagIDs", imageID).Return(nil, nil).Once()
mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once()
}
image := models.Image{
ID: imageID,
Path: test.Path,
}
err := ImageTags(&image, mockImageReader, mockTagReader)
assert.Nil(err)
mockTagReader.AssertExpectations(t)
mockImageReader.AssertExpectations(t)
}
}

View File

@@ -0,0 +1,784 @@
// +build integration
package autotag
import (
"context"
"database/sql"
"fmt"
"io/ioutil"
"os"
"testing"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sqlite"
"github.com/stashapp/stash/pkg/utils"
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
const testName = "Foo's Bar"
const existingStudioName = "ExistingStudio"
const existingStudioSceneName = testName + ".dontChangeStudio.mp4"
const existingStudioImageName = testName + ".dontChangeStudio.mp4"
const existingStudioGalleryName = testName + ".dontChangeStudio.mp4"
var existingStudioID int
func testTeardown(databaseFile string) {
err := database.DB.Close()
if err != nil {
panic(err)
}
err = os.Remove(databaseFile)
if err != nil {
panic(err)
}
}
func runTests(m *testing.M) int {
// create the database file
f, err := ioutil.TempFile("", "*.sqlite")
if err != nil {
panic(fmt.Sprintf("Could not create temporary file: %s", err.Error()))
}
f.Close()
databaseFile := f.Name()
database.Initialize(databaseFile)
// defer close and delete the database
defer testTeardown(databaseFile)
err = populateDB()
if err != nil {
panic(fmt.Sprintf("Could not populate database: %s", err.Error()))
} else {
// run the tests
return m.Run()
}
}
func TestMain(m *testing.M) {
ret := runTests(m)
os.Exit(ret)
}
func createPerformer(pqb models.PerformerWriter) error {
// create the performer
performer := models.Performer{
Checksum: testName,
Name: sql.NullString{Valid: true, String: testName},
Favorite: sql.NullBool{Valid: true, Bool: false},
}
_, err := pqb.Create(performer)
if err != nil {
return err
}
return nil
}
func createStudio(qb models.StudioWriter, name string) (*models.Studio, error) {
// create the studio
studio := models.Studio{
Checksum: name,
Name: sql.NullString{Valid: true, String: name},
}
return qb.Create(studio)
}
func createTag(qb models.TagWriter) error {
// create the studio
tag := models.Tag{
Name: testName,
}
_, err := qb.Create(tag)
if err != nil {
return err
}
return nil
}
func createScenes(sqb models.SceneReaderWriter) error {
// create the scenes
scenePatterns, falseScenePatterns := generateTestPaths(testName, sceneExt)
for _, fn := range scenePatterns {
err := createScene(sqb, makeScene(fn, true))
if err != nil {
return err
}
}
for _, fn := range falseScenePatterns {
err := createScene(sqb, makeScene(fn, false))
if err != nil {
return err
}
}
// add organized scenes
for _, fn := range scenePatterns {
s := makeScene("organized"+fn, false)
s.Organized = true
err := createScene(sqb, s)
if err != nil {
return err
}
}
// create scene with existing studio io
studioScene := makeScene(existingStudioSceneName, true)
studioScene.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)}
err := createScene(sqb, studioScene)
if err != nil {
return err
}
return nil
}
func makeScene(name string, expectedResult bool) *models.Scene {
scene := &models.Scene{
Checksum: sql.NullString{String: utils.MD5FromString(name), Valid: true},
Path: name,
}
// if expectedResult is true then we expect it to match, set the title accordingly
if expectedResult {
scene.Title = sql.NullString{Valid: true, String: name}
}
return scene
}
func createScene(sqb models.SceneWriter, scene *models.Scene) error {
_, err := sqb.Create(*scene)
if err != nil {
return fmt.Errorf("Failed to create scene with name '%s': %s", scene.Path, err.Error())
}
return nil
}
func createImages(sqb models.ImageReaderWriter) error {
// create the images
imagePatterns, falseImagePatterns := generateTestPaths(testName, imageExt)
for _, fn := range imagePatterns {
err := createImage(sqb, makeImage(fn, true))
if err != nil {
return err
}
}
for _, fn := range falseImagePatterns {
err := createImage(sqb, makeImage(fn, false))
if err != nil {
return err
}
}
// add organized images
for _, fn := range imagePatterns {
s := makeImage("organized"+fn, false)
s.Organized = true
err := createImage(sqb, s)
if err != nil {
return err
}
}
// create image with existing studio io
studioImage := makeImage(existingStudioImageName, true)
studioImage.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)}
err := createImage(sqb, studioImage)
if err != nil {
return err
}
return nil
}
func makeImage(name string, expectedResult bool) *models.Image {
image := &models.Image{
Checksum: utils.MD5FromString(name),
Path: name,
}
// if expectedResult is true then we expect it to match, set the title accordingly
if expectedResult {
image.Title = sql.NullString{Valid: true, String: name}
}
return image
}
func createImage(sqb models.ImageWriter, image *models.Image) error {
_, err := sqb.Create(*image)
if err != nil {
return fmt.Errorf("Failed to create image with name '%s': %s", image.Path, err.Error())
}
return nil
}
func createGalleries(sqb models.GalleryReaderWriter) error {
// create the galleries
galleryPatterns, falseGalleryPatterns := generateTestPaths(testName, galleryExt)
for _, fn := range galleryPatterns {
err := createGallery(sqb, makeGallery(fn, true))
if err != nil {
return err
}
}
for _, fn := range falseGalleryPatterns {
err := createGallery(sqb, makeGallery(fn, false))
if err != nil {
return err
}
}
// add organized galleries
for _, fn := range galleryPatterns {
s := makeGallery("organized"+fn, false)
s.Organized = true
err := createGallery(sqb, s)
if err != nil {
return err
}
}
// create gallery with existing studio io
studioGallery := makeGallery(existingStudioGalleryName, true)
studioGallery.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)}
err := createGallery(sqb, studioGallery)
if err != nil {
return err
}
return nil
}
func makeGallery(name string, expectedResult bool) *models.Gallery {
gallery := &models.Gallery{
Checksum: utils.MD5FromString(name),
Path: models.NullString(name),
}
// if expectedResult is true then we expect it to match, set the title accordingly
if expectedResult {
gallery.Title = sql.NullString{Valid: true, String: name}
}
return gallery
}
func createGallery(sqb models.GalleryWriter, gallery *models.Gallery) error {
_, err := sqb.Create(*gallery)
if err != nil {
return fmt.Errorf("Failed to create gallery with name '%s': %s", gallery.Path.String, err.Error())
}
return nil
}
func withTxn(f func(r models.Repository) error) error {
t := sqlite.NewTransactionManager()
return t.WithTxn(context.TODO(), f)
}
func populateDB() error {
if err := withTxn(func(r models.Repository) error {
err := createPerformer(r.Performer())
if err != nil {
return err
}
_, err = createStudio(r.Studio(), testName)
if err != nil {
return err
}
// create existing studio
existingStudio, err := createStudio(r.Studio(), existingStudioName)
if err != nil {
return err
}
existingStudioID = existingStudio.ID
err = createTag(r.Tag())
if err != nil {
return err
}
err = createScenes(r.Scene())
if err != nil {
return err
}
err = createImages(r.Image())
if err != nil {
return err
}
err = createGalleries(r.Gallery())
if err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}
func TestParsePerformerScenes(t *testing.T) {
var performers []*models.Performer
if err := withTxn(func(r models.Repository) error {
var err error
performers, err = r.Performer().All()
return err
}); err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
for _, p := range performers {
if err := withTxn(func(r models.Repository) error {
return PerformerScenes(p, nil, r.Scene())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that scenes were tagged correctly
withTxn(func(r models.Repository) error {
pqb := r.Performer()
scenes, err := r.Scene().All()
if err != nil {
t.Error(err.Error())
}
for _, scene := range scenes {
performers, err := pqb.FindBySceneID(scene.ID)
if err != nil {
t.Errorf("Error getting scene performers: %s", err.Error())
}
// title is only set on scenes where we expect performer to be set
if scene.Title.String == scene.Path && len(performers) == 0 {
t.Errorf("Did not set performer '%s' for path '%s'", testName, scene.Path)
} else if scene.Title.String != scene.Path && len(performers) > 0 {
t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, scene.Path)
}
}
return nil
})
}
func TestParseStudioScenes(t *testing.T) {
var studios []*models.Studio
if err := withTxn(func(r models.Repository) error {
var err error
studios, err = r.Studio().All()
return err
}); err != nil {
t.Errorf("Error getting studio: %s", err)
return
}
for _, s := range studios {
if err := withTxn(func(r models.Repository) error {
return StudioScenes(s, nil, r.Scene())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that scenes were tagged correctly
withTxn(func(r models.Repository) error {
scenes, err := r.Scene().All()
if err != nil {
t.Error(err.Error())
}
for _, scene := range scenes {
// check for existing studio id scene first
if scene.Path == existingStudioSceneName {
if scene.StudioID.Int64 != int64(existingStudioID) {
t.Error("Incorrectly overwrote studio ID for scene with existing studio ID")
}
} else {
// title is only set on scenes where we expect studio to be set
if scene.Title.String == scene.Path {
if !scene.StudioID.Valid {
t.Errorf("Did not set studio '%s' for path '%s'", testName, scene.Path)
} else if scene.StudioID.Int64 != int64(studios[1].ID) {
t.Errorf("Incorrect studio id %d set for path '%s'", scene.StudioID.Int64, scene.Path)
}
} else if scene.Title.String != scene.Path && scene.StudioID.Int64 == int64(studios[1].ID) {
t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, scene.Path)
}
}
}
return nil
})
}
func TestParseTagScenes(t *testing.T) {
var tags []*models.Tag
if err := withTxn(func(r models.Repository) error {
var err error
tags, err = r.Tag().All()
return err
}); err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
for _, s := range tags {
if err := withTxn(func(r models.Repository) error {
return TagScenes(s, nil, r.Scene())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that scenes were tagged correctly
withTxn(func(r models.Repository) error {
scenes, err := r.Scene().All()
if err != nil {
t.Error(err.Error())
}
tqb := r.Tag()
for _, scene := range scenes {
tags, err := tqb.FindBySceneID(scene.ID)
if err != nil {
t.Errorf("Error getting scene tags: %s", err.Error())
}
// title is only set on scenes where we expect performer to be set
if scene.Title.String == scene.Path && len(tags) == 0 {
t.Errorf("Did not set tag '%s' for path '%s'", testName, scene.Path)
} else if scene.Title.String != scene.Path && len(tags) > 0 {
t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, scene.Path)
}
}
return nil
})
}
func TestParsePerformerImages(t *testing.T) {
var performers []*models.Performer
if err := withTxn(func(r models.Repository) error {
var err error
performers, err = r.Performer().All()
return err
}); err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
for _, p := range performers {
if err := withTxn(func(r models.Repository) error {
return PerformerImages(p, nil, r.Image())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that images were tagged correctly
withTxn(func(r models.Repository) error {
pqb := r.Performer()
images, err := r.Image().All()
if err != nil {
t.Error(err.Error())
}
for _, image := range images {
performers, err := pqb.FindByImageID(image.ID)
if err != nil {
t.Errorf("Error getting image performers: %s", err.Error())
}
// title is only set on images where we expect performer to be set
if image.Title.String == image.Path && len(performers) == 0 {
t.Errorf("Did not set performer '%s' for path '%s'", testName, image.Path)
} else if image.Title.String != image.Path && len(performers) > 0 {
t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, image.Path)
}
}
return nil
})
}
func TestParseStudioImages(t *testing.T) {
var studios []*models.Studio
if err := withTxn(func(r models.Repository) error {
var err error
studios, err = r.Studio().All()
return err
}); err != nil {
t.Errorf("Error getting studio: %s", err)
return
}
for _, s := range studios {
if err := withTxn(func(r models.Repository) error {
return StudioImages(s, nil, r.Image())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that images were tagged correctly
withTxn(func(r models.Repository) error {
images, err := r.Image().All()
if err != nil {
t.Error(err.Error())
}
for _, image := range images {
// check for existing studio id image first
if image.Path == existingStudioImageName {
if image.StudioID.Int64 != int64(existingStudioID) {
t.Error("Incorrectly overwrote studio ID for image with existing studio ID")
}
} else {
// title is only set on images where we expect studio to be set
if image.Title.String == image.Path {
if !image.StudioID.Valid {
t.Errorf("Did not set studio '%s' for path '%s'", testName, image.Path)
} else if image.StudioID.Int64 != int64(studios[1].ID) {
t.Errorf("Incorrect studio id %d set for path '%s'", image.StudioID.Int64, image.Path)
}
} else if image.Title.String != image.Path && image.StudioID.Int64 == int64(studios[1].ID) {
t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, image.Path)
}
}
}
return nil
})
}
func TestParseTagImages(t *testing.T) {
var tags []*models.Tag
if err := withTxn(func(r models.Repository) error {
var err error
tags, err = r.Tag().All()
return err
}); err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
for _, s := range tags {
if err := withTxn(func(r models.Repository) error {
return TagImages(s, nil, r.Image())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that images were tagged correctly
withTxn(func(r models.Repository) error {
images, err := r.Image().All()
if err != nil {
t.Error(err.Error())
}
tqb := r.Tag()
for _, image := range images {
tags, err := tqb.FindByImageID(image.ID)
if err != nil {
t.Errorf("Error getting image tags: %s", err.Error())
}
// title is only set on images where we expect performer to be set
if image.Title.String == image.Path && len(tags) == 0 {
t.Errorf("Did not set tag '%s' for path '%s'", testName, image.Path)
} else if image.Title.String != image.Path && len(tags) > 0 {
t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, image.Path)
}
}
return nil
})
}
func TestParsePerformerGalleries(t *testing.T) {
var performers []*models.Performer
if err := withTxn(func(r models.Repository) error {
var err error
performers, err = r.Performer().All()
return err
}); err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
for _, p := range performers {
if err := withTxn(func(r models.Repository) error {
return PerformerGalleries(p, nil, r.Gallery())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that galleries were tagged correctly
withTxn(func(r models.Repository) error {
pqb := r.Performer()
galleries, err := r.Gallery().All()
if err != nil {
t.Error(err.Error())
}
for _, gallery := range galleries {
performers, err := pqb.FindByGalleryID(gallery.ID)
if err != nil {
t.Errorf("Error getting gallery performers: %s", err.Error())
}
// title is only set on galleries where we expect performer to be set
if gallery.Title.String == gallery.Path.String && len(performers) == 0 {
t.Errorf("Did not set performer '%s' for path '%s'", testName, gallery.Path.String)
} else if gallery.Title.String != gallery.Path.String && len(performers) > 0 {
t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, gallery.Path.String)
}
}
return nil
})
}
func TestParseStudioGalleries(t *testing.T) {
var studios []*models.Studio
if err := withTxn(func(r models.Repository) error {
var err error
studios, err = r.Studio().All()
return err
}); err != nil {
t.Errorf("Error getting studio: %s", err)
return
}
for _, s := range studios {
if err := withTxn(func(r models.Repository) error {
return StudioGalleries(s, nil, r.Gallery())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that galleries were tagged correctly
withTxn(func(r models.Repository) error {
galleries, err := r.Gallery().All()
if err != nil {
t.Error(err.Error())
}
for _, gallery := range galleries {
// check for existing studio id gallery first
if gallery.Path.String == existingStudioGalleryName {
if gallery.StudioID.Int64 != int64(existingStudioID) {
t.Error("Incorrectly overwrote studio ID for gallery with existing studio ID")
}
} else {
// title is only set on galleries where we expect studio to be set
if gallery.Title.String == gallery.Path.String {
if !gallery.StudioID.Valid {
t.Errorf("Did not set studio '%s' for path '%s'", testName, gallery.Path.String)
} else if gallery.StudioID.Int64 != int64(studios[1].ID) {
t.Errorf("Incorrect studio id %d set for path '%s'", gallery.StudioID.Int64, gallery.Path.String)
}
} else if gallery.Title.String != gallery.Path.String && gallery.StudioID.Int64 == int64(studios[1].ID) {
t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, gallery.Path.String)
}
}
}
return nil
})
}
func TestParseTagGalleries(t *testing.T) {
var tags []*models.Tag
if err := withTxn(func(r models.Repository) error {
var err error
tags, err = r.Tag().All()
return err
}); err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
for _, s := range tags {
if err := withTxn(func(r models.Repository) error {
return TagGalleries(s, nil, r.Gallery())
}); err != nil {
t.Errorf("Error auto-tagging performers: %s", err)
}
}
// verify that galleries were tagged correctly
withTxn(func(r models.Repository) error {
galleries, err := r.Gallery().All()
if err != nil {
t.Error(err.Error())
}
tqb := r.Tag()
for _, gallery := range galleries {
tags, err := tqb.FindByGalleryID(gallery.ID)
if err != nil {
t.Errorf("Error getting gallery tags: %s", err.Error())
}
// title is only set on galleries where we expect performer to be set
if gallery.Title.String == gallery.Path.String && len(tags) == 0 {
t.Errorf("Did not set tag '%s' for path '%s'", testName, gallery.Path.String)
} else if gallery.Title.String != gallery.Path.String && len(tags) > 0 {
t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, gallery.Path.String)
}
}
return nil
})
}

62
pkg/autotag/performer.go Normal file
View File

@@ -0,0 +1,62 @@
package autotag
import (
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
)
func getMatchingPerformers(path string, performerReader models.PerformerReader) ([]*models.Performer, error) {
words := getPathWords(path)
performers, err := performerReader.QueryForAutoTag(words)
if err != nil {
return nil, err
}
var ret []*models.Performer
for _, p := range performers {
// TODO - commenting out alias handling until both sides work correctly
if nameMatchesPath(p.Name.String, path) { // || nameMatchesPath(p.Aliases.String, path) {
ret = append(ret, p)
}
}
return ret, nil
}
func getPerformerTagger(p *models.Performer) tagger {
return tagger{
ID: p.ID,
Type: "performer",
Name: p.Name.String,
}
}
// PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer.
func PerformerScenes(p *models.Performer, paths []string, rw models.SceneReaderWriter) error {
t := getPerformerTagger(p)
return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
return scene.AddPerformer(rw, otherID, subjectID)
})
}
// PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer.
func PerformerImages(p *models.Performer, paths []string, rw models.ImageReaderWriter) error {
t := getPerformerTagger(p)
return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
return image.AddPerformer(rw, otherID, subjectID)
})
}
// PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer.
func PerformerGalleries(p *models.Performer, paths []string, rw models.GalleryReaderWriter) error {
t := getPerformerTagger(p)
return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
return gallery.AddPerformer(rw, otherID, subjectID)
})
}

View File

@@ -0,0 +1,225 @@
package autotag
import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
)
func TestPerformerScenes(t *testing.T) {
type test struct {
performerName string
expectedRegex string
}
performerNames := []test{
{
"performer name",
`(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"performer + name",
`(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range performerNames {
testPerformerScenes(t, p.performerName, p.expectedRegex)
}
}
func testPerformerScenes(t *testing.T, performerName, expectedRegex string) {
mockSceneReader := &mocks.SceneReaderWriter{}
const performerID = 2
var scenes []*models.Scene
matchingPaths, falsePaths := generateTestPaths(performerName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) {
scenes = append(scenes, &models.Scene{
ID: i + 1,
Path: p,
})
}
performer := models.Performer{
ID: performerID,
Name: models.NullString(performerName),
}
organized := false
perPage := models.PerPageAll
expectedSceneFilter := &models.SceneFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once()
for i := range matchingPaths {
sceneID := i + 1
mockSceneReader.On("GetPerformerIDs", sceneID).Return(nil, nil).Once()
mockSceneReader.On("UpdatePerformers", sceneID, []int{performerID}).Return(nil).Once()
}
err := PerformerScenes(&performer, nil, mockSceneReader)
assert := assert.New(t)
assert.Nil(err)
mockSceneReader.AssertExpectations(t)
}
func TestPerformerImages(t *testing.T) {
type test struct {
performerName string
expectedRegex string
}
performerNames := []test{
{
"performer name",
`(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"performer + name",
`(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range performerNames {
testPerformerImages(t, p.performerName, p.expectedRegex)
}
}
func testPerformerImages(t *testing.T, performerName, expectedRegex string) {
mockImageReader := &mocks.ImageReaderWriter{}
const performerID = 2
var images []*models.Image
matchingPaths, falsePaths := generateTestPaths(performerName, imageExt)
for i, p := range append(matchingPaths, falsePaths...) {
images = append(images, &models.Image{
ID: i + 1,
Path: p,
})
}
performer := models.Performer{
ID: performerID,
Name: models.NullString(performerName),
}
organized := false
perPage := models.PerPageAll
expectedImageFilter := &models.ImageFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once()
for i := range matchingPaths {
imageID := i + 1
mockImageReader.On("GetPerformerIDs", imageID).Return(nil, nil).Once()
mockImageReader.On("UpdatePerformers", imageID, []int{performerID}).Return(nil).Once()
}
err := PerformerImages(&performer, nil, mockImageReader)
assert := assert.New(t)
assert.Nil(err)
mockImageReader.AssertExpectations(t)
}
func TestPerformerGalleries(t *testing.T) {
type test struct {
performerName string
expectedRegex string
}
performerNames := []test{
{
"performer name",
`(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"performer + name",
`(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range performerNames {
testPerformerGalleries(t, p.performerName, p.expectedRegex)
}
}
func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) {
mockGalleryReader := &mocks.GalleryReaderWriter{}
const performerID = 2
var galleries []*models.Gallery
matchingPaths, falsePaths := generateTestPaths(performerName, galleryExt)
for i, p := range append(matchingPaths, falsePaths...) {
galleries = append(galleries, &models.Gallery{
ID: i + 1,
Path: models.NullString(p),
})
}
performer := models.Performer{
ID: performerID,
Name: models.NullString(performerName),
}
organized := false
perPage := models.PerPageAll
expectedGalleryFilter := &models.GalleryFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()
for i := range matchingPaths {
galleryID := i + 1
mockGalleryReader.On("GetPerformerIDs", galleryID).Return(nil, nil).Once()
mockGalleryReader.On("UpdatePerformers", galleryID, []int{performerID}).Return(nil).Once()
}
err := PerformerGalleries(&performer, nil, mockGalleryReader)
assert := assert.New(t)
assert.Nil(err)
mockGalleryReader.AssertExpectations(t)
}

117
pkg/autotag/scene.go Normal file
View File

@@ -0,0 +1,117 @@
package autotag
import (
"fmt"
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
)
func scenePathsFilter(paths []string) *models.SceneFilterType {
if paths == nil {
return nil
}
sep := string(filepath.Separator)
var ret *models.SceneFilterType
var or *models.SceneFilterType
for _, p := range paths {
newOr := &models.SceneFilterType{}
if or != nil {
or.Or = newOr
} else {
ret = newOr
}
or = newOr
if !strings.HasSuffix(p, sep) {
p = p + sep
}
or.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}
func getMatchingScenes(name string, paths []string, sceneReader models.SceneReader) ([]*models.Scene, error) {
regex := getPathQueryRegex(name)
organized := false
filter := models.SceneFilterType{
Path: &models.StringCriterionInput{
Value: "(?i)" + regex,
Modifier: models.CriterionModifierMatchesRegex,
},
Organized: &organized,
}
filter.And = scenePathsFilter(paths)
pp := models.PerPageAll
scenes, _, err := sceneReader.Query(&filter, &models.FindFilterType{
PerPage: &pp,
})
if err != nil {
return nil, fmt.Errorf("error querying scenes with regex '%s': %s", regex, err.Error())
}
var ret []*models.Scene
for _, p := range scenes {
if nameMatchesPath(name, p.Path) {
ret = append(ret, p)
}
}
return ret, nil
}
func getSceneFileTagger(s *models.Scene) tagger {
return tagger{
ID: s.ID,
Type: "scene",
Name: s.GetTitle(),
Path: s.Path,
}
}
// ScenePerformers tags the provided scene with performers whose name matches the scene's path.
func ScenePerformers(s *models.Scene, rw models.SceneReaderWriter, performerReader models.PerformerReader) error {
t := getSceneFileTagger(s)
return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) {
return scene.AddPerformer(rw, subjectID, otherID)
})
}
// SceneStudios tags the provided scene with the first studio whose name matches the scene's path.
//
// Scenes will not be tagged if studio is already set.
func SceneStudios(s *models.Scene, rw models.SceneReaderWriter, studioReader models.StudioReader) error {
if s.StudioID.Valid {
// don't modify
return nil
}
t := getSceneFileTagger(s)
return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) {
return addSceneStudio(rw, subjectID, otherID)
})
}
// SceneTags tags the provided scene with tags whose name matches the scene's path.
func SceneTags(s *models.Scene, rw models.SceneReaderWriter, tagReader models.TagReader) error {
t := getSceneFileTagger(s)
return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) {
return scene.AddTag(rw, subjectID, otherID)
})
}

278
pkg/autotag/scene_test.go Normal file
View File

@@ -0,0 +1,278 @@
package autotag
import (
"fmt"
"strings"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const sceneExt = "mp4"
var testSeparators = []string{
".",
"-",
"_",
" ",
}
var testEndSeparators = []string{
"{",
"}",
"(",
")",
",",
}
func generateNamePatterns(name, separator, ext string) []string {
var ret []string
ret = append(ret, fmt.Sprintf("%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext))
ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext))
ret = append(ret, fmt.Sprintf("dir/%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("dir\\%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("dir/%s%s/aaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa.%s", name, separator, ext))
return ret
}
func generateSplitNamePatterns(name, separator, ext string) []string {
var ret []string
splitted := strings.Split(name, " ")
// only do this for names that are split into two
if len(splitted) == 2 {
ret = append(ret, fmt.Sprintf("%s%s%s.%s", splitted[0], separator, splitted[1], ext))
}
return ret
}
func generateFalseNamePatterns(name string, separator, ext string) []string {
splitted := strings.Split(name, " ")
var ret []string
// only do this for names that are split into two
if len(splitted) == 2 {
ret = append(ret, fmt.Sprintf("%s%saaa%s%s.%s", splitted[0], separator, separator, splitted[1], ext))
}
return ret
}
func generateTestPaths(testName, ext string) (scenePatterns []string, falseScenePatterns []string) {
separators := append(testSeparators, testEndSeparators...)
for _, separator := range separators {
scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...)
scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...)
scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator, ext)...)
falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...)
}
// add test cases for intra-name separators
for _, separator := range testSeparators {
if separator != " " {
scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator, ext)...)
}
}
// add basic false scenarios
falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.%s", testName, ext))
falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.%s", testName, ext))
// add path separator false scenarios
falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "/", ext)...)
falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "\\", ext)...)
// split patterns only valid for ._- and whitespace
for _, separator := range testSeparators {
scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator, ext)...)
}
// false patterns for other separators
for _, separator := range testEndSeparators {
falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator, ext)...)
}
return
}
type pathTestTable struct {
Path string
Matches bool
}
func generateTestTable(testName, ext string) []pathTestTable {
var ret []pathTestTable
var scenePatterns []string
var falseScenePatterns []string
separators := append(testSeparators, testEndSeparators...)
for _, separator := range separators {
scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...)
scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...)
falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...)
}
for _, p := range scenePatterns {
t := pathTestTable{
Path: p,
Matches: true,
}
ret = append(ret, t)
}
for _, p := range falseScenePatterns {
t := pathTestTable{
Path: p,
Matches: false,
}
ret = append(ret, t)
}
return ret
}
func TestScenePerformers(t *testing.T) {
const sceneID = 1
const performerName = "performer name"
const performerID = 2
performer := models.Performer{
ID: performerID,
Name: models.NullString(performerName),
}
const reversedPerformerName = "name performer"
const reversedPerformerID = 3
reversedPerformer := models.Performer{
ID: reversedPerformerID,
Name: models.NullString(reversedPerformerName),
}
testTables := generateTestTable(performerName, sceneExt)
assert := assert.New(t)
for _, test := range testTables {
mockPerformerReader := &mocks.PerformerReaderWriter{}
mockSceneReader := &mocks.SceneReaderWriter{}
mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once()
if test.Matches {
mockSceneReader.On("GetPerformerIDs", sceneID).Return(nil, nil).Once()
mockSceneReader.On("UpdatePerformers", sceneID, []int{performerID}).Return(nil).Once()
}
scene := models.Scene{
ID: sceneID,
Path: test.Path,
}
err := ScenePerformers(&scene, mockSceneReader, mockPerformerReader)
assert.Nil(err)
mockPerformerReader.AssertExpectations(t)
mockSceneReader.AssertExpectations(t)
}
}
func TestSceneStudios(t *testing.T) {
const sceneID = 1
const studioName = "studio name"
const studioID = 2
studio := models.Studio{
ID: studioID,
Name: models.NullString(studioName),
}
const reversedStudioName = "name studio"
const reversedStudioID = 3
reversedStudio := models.Studio{
ID: reversedStudioID,
Name: models.NullString(reversedStudioName),
}
testTables := generateTestTable(studioName, sceneExt)
assert := assert.New(t)
for _, test := range testTables {
mockStudioReader := &mocks.StudioReaderWriter{}
mockSceneReader := &mocks.SceneReaderWriter{}
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
if test.Matches {
mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once()
expectedStudioID := models.NullInt64(studioID)
mockSceneReader.On("Update", models.ScenePartial{
ID: sceneID,
StudioID: &expectedStudioID,
}).Return(nil, nil).Once()
}
scene := models.Scene{
ID: sceneID,
Path: test.Path,
}
err := SceneStudios(&scene, mockSceneReader, mockStudioReader)
assert.Nil(err)
mockStudioReader.AssertExpectations(t)
mockSceneReader.AssertExpectations(t)
}
}
func TestSceneTags(t *testing.T) {
const sceneID = 1
const tagName = "tag name"
const tagID = 2
tag := models.Tag{
ID: tagID,
Name: tagName,
}
const reversedTagName = "name tag"
const reversedTagID = 3
reversedTag := models.Tag{
ID: reversedTagID,
Name: reversedTagName,
}
testTables := generateTestTable(tagName, sceneExt)
assert := assert.New(t)
for _, test := range testTables {
mockTagReader := &mocks.TagReaderWriter{}
mockSceneReader := &mocks.SceneReaderWriter{}
mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()
if test.Matches {
mockSceneReader.On("GetTagIDs", sceneID).Return(nil, nil).Once()
mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once()
}
scene := models.Scene{
ID: sceneID,
Path: test.Path,
}
err := SceneTags(&scene, mockSceneReader, mockTagReader)
assert.Nil(err)
mockTagReader.AssertExpectations(t)
mockSceneReader.AssertExpectations(t)
}
}

132
pkg/autotag/studio.go Normal file
View File

@@ -0,0 +1,132 @@
package autotag
import (
"database/sql"
"github.com/stashapp/stash/pkg/models"
)
func getMatchingStudios(path string, reader models.StudioReader) ([]*models.Studio, error) {
words := getPathWords(path)
candidates, err := reader.QueryForAutoTag(words)
if err != nil {
return nil, err
}
var ret []*models.Studio
for _, c := range candidates {
if nameMatchesPath(c.Name.String, path) {
ret = append(ret, c)
}
}
return ret, nil
}
func addSceneStudio(sceneWriter models.SceneReaderWriter, sceneID, studioID int) (bool, error) {
// don't set if already set
scene, err := sceneWriter.Find(sceneID)
if err != nil {
return false, err
}
if scene.StudioID.Valid {
return false, nil
}
// set the studio id
s := sql.NullInt64{Int64: int64(studioID), Valid: true}
scenePartial := models.ScenePartial{
ID: sceneID,
StudioID: &s,
}
if _, err := sceneWriter.Update(scenePartial); err != nil {
return false, err
}
return true, nil
}
func addImageStudio(imageWriter models.ImageReaderWriter, imageID, studioID int) (bool, error) {
// don't set if already set
image, err := imageWriter.Find(imageID)
if err != nil {
return false, err
}
if image.StudioID.Valid {
return false, nil
}
// set the studio id
s := sql.NullInt64{Int64: int64(studioID), Valid: true}
imagePartial := models.ImagePartial{
ID: imageID,
StudioID: &s,
}
if _, err := imageWriter.Update(imagePartial); err != nil {
return false, err
}
return true, nil
}
func addGalleryStudio(galleryWriter models.GalleryReaderWriter, galleryID, studioID int) (bool, error) {
// don't set if already set
gallery, err := galleryWriter.Find(galleryID)
if err != nil {
return false, err
}
if gallery.StudioID.Valid {
return false, nil
}
// set the studio id
s := sql.NullInt64{Int64: int64(studioID), Valid: true}
galleryPartial := models.GalleryPartial{
ID: galleryID,
StudioID: &s,
}
if _, err := galleryWriter.UpdatePartial(galleryPartial); err != nil {
return false, err
}
return true, nil
}
func getStudioTagger(p *models.Studio) tagger {
return tagger{
ID: p.ID,
Type: "studio",
Name: p.Name.String,
}
}
// StudioScenes searches for scenes whose path matches the provided studio name and tags the scene with the studio, if studio is not already set on the scene.
func StudioScenes(p *models.Studio, paths []string, rw models.SceneReaderWriter) error {
t := getStudioTagger(p)
return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
return addSceneStudio(rw, otherID, subjectID)
})
}
// StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image.
func StudioImages(p *models.Studio, paths []string, rw models.ImageReaderWriter) error {
t := getStudioTagger(p)
return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
return addImageStudio(rw, otherID, subjectID)
})
}
// StudioGalleries searches for galleries whose path matches the provided studio name and tags the gallery with the studio, if studio is not already set on the gallery.
func StudioGalleries(p *models.Studio, paths []string, rw models.GalleryReaderWriter) error {
t := getStudioTagger(p)
return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
return addGalleryStudio(rw, otherID, subjectID)
})
}

237
pkg/autotag/studio_test.go Normal file
View File

@@ -0,0 +1,237 @@
package autotag
import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
)
func TestStudioScenes(t *testing.T) {
type test struct {
studioName string
expectedRegex string
}
studioNames := []test{
{
"studio name",
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"studio + name",
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range studioNames {
testStudioScenes(t, p.studioName, p.expectedRegex)
}
}
func testStudioScenes(t *testing.T, studioName, expectedRegex string) {
mockSceneReader := &mocks.SceneReaderWriter{}
const studioID = 2
var scenes []*models.Scene
matchingPaths, falsePaths := generateTestPaths(studioName, sceneExt)
for i, p := range append(matchingPaths, falsePaths...) {
scenes = append(scenes, &models.Scene{
ID: i + 1,
Path: p,
})
}
studio := models.Studio{
ID: studioID,
Name: models.NullString(studioName),
}
organized := false
perPage := models.PerPageAll
expectedSceneFilter := &models.SceneFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once()
for i := range matchingPaths {
sceneID := i + 1
mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once()
expectedStudioID := models.NullInt64(studioID)
mockSceneReader.On("Update", models.ScenePartial{
ID: sceneID,
StudioID: &expectedStudioID,
}).Return(nil, nil).Once()
}
err := StudioScenes(&studio, nil, mockSceneReader)
assert := assert.New(t)
assert.Nil(err)
mockSceneReader.AssertExpectations(t)
}
func TestStudioImages(t *testing.T) {
type test struct {
studioName string
expectedRegex string
}
studioNames := []test{
{
"studio name",
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"studio + name",
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range studioNames {
testStudioImages(t, p.studioName, p.expectedRegex)
}
}
func testStudioImages(t *testing.T, studioName, expectedRegex string) {
mockImageReader := &mocks.ImageReaderWriter{}
const studioID = 2
var images []*models.Image
matchingPaths, falsePaths := generateTestPaths(studioName, imageExt)
for i, p := range append(matchingPaths, falsePaths...) {
images = append(images, &models.Image{
ID: i + 1,
Path: p,
})
}
studio := models.Studio{
ID: studioID,
Name: models.NullString(studioName),
}
organized := false
perPage := models.PerPageAll
expectedImageFilter := &models.ImageFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once()
for i := range matchingPaths {
imageID := i + 1
mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once()
expectedStudioID := models.NullInt64(studioID)
mockImageReader.On("Update", models.ImagePartial{
ID: imageID,
StudioID: &expectedStudioID,
}).Return(nil, nil).Once()
}
err := StudioImages(&studio, nil, mockImageReader)
assert := assert.New(t)
assert.Nil(err)
mockImageReader.AssertExpectations(t)
}
func TestStudioGalleries(t *testing.T) {
type test struct {
studioName string
expectedRegex string
}
studioNames := []test{
{
"studio name",
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"studio + name",
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range studioNames {
testStudioGalleries(t, p.studioName, p.expectedRegex)
}
}
func testStudioGalleries(t *testing.T, studioName, expectedRegex string) {
mockGalleryReader := &mocks.GalleryReaderWriter{}
const studioID = 2
var galleries []*models.Gallery
matchingPaths, falsePaths := generateTestPaths(studioName, galleryExt)
for i, p := range append(matchingPaths, falsePaths...) {
galleries = append(galleries, &models.Gallery{
ID: i + 1,
Path: models.NullString(p),
})
}
studio := models.Studio{
ID: studioID,
Name: models.NullString(studioName),
}
organized := false
perPage := models.PerPageAll
expectedGalleryFilter := &models.GalleryFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()
for i := range matchingPaths {
galleryID := i + 1
mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once()
expectedStudioID := models.NullInt64(studioID)
mockGalleryReader.On("UpdatePartial", models.GalleryPartial{
ID: galleryID,
StudioID: &expectedStudioID,
}).Return(nil, nil).Once()
}
err := StudioGalleries(&studio, nil, mockGalleryReader)
assert := assert.New(t)
assert.Nil(err)
mockGalleryReader.AssertExpectations(t)
}

61
pkg/autotag/tag.go Normal file
View File

@@ -0,0 +1,61 @@
package autotag
import (
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
)
func getMatchingTags(path string, tagReader models.TagReader) ([]*models.Tag, error) {
words := getPathWords(path)
tags, err := tagReader.QueryForAutoTag(words)
if err != nil {
return nil, err
}
var ret []*models.Tag
for _, p := range tags {
if nameMatchesPath(p.Name, path) {
ret = append(ret, p)
}
}
return ret, nil
}
func getTagTagger(p *models.Tag) tagger {
return tagger{
ID: p.ID,
Type: "tag",
Name: p.Name,
}
}
// TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag.
func TagScenes(p *models.Tag, paths []string, rw models.SceneReaderWriter) error {
t := getTagTagger(p)
return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
return scene.AddTag(rw, otherID, subjectID)
})
}
// TagImages searches for images whose path matches the provided tag name and tags the image with the tag.
func TagImages(p *models.Tag, paths []string, rw models.ImageReaderWriter) error {
t := getTagTagger(p)
return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
return image.AddTag(rw, otherID, subjectID)
})
}
// TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag.
func TagGalleries(p *models.Tag, paths []string, rw models.GalleryReaderWriter) error {
t := getTagTagger(p)
return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
return gallery.AddTag(rw, otherID, subjectID)
})
}

225
pkg/autotag/tag_test.go Normal file
View File

@@ -0,0 +1,225 @@
package autotag
import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
)
func TestTagScenes(t *testing.T) {
type test struct {
tagName string
expectedRegex string
}
tagNames := []test{
{
"tag name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"tag + name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range tagNames {
testTagScenes(t, p.tagName, p.expectedRegex)
}
}
func testTagScenes(t *testing.T, tagName, expectedRegex string) {
mockSceneReader := &mocks.SceneReaderWriter{}
const tagID = 2
var scenes []*models.Scene
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) {
scenes = append(scenes, &models.Scene{
ID: i + 1,
Path: p,
})
}
tag := models.Tag{
ID: tagID,
Name: tagName,
}
organized := false
perPage := models.PerPageAll
expectedSceneFilter := &models.SceneFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once()
for i := range matchingPaths {
sceneID := i + 1
mockSceneReader.On("GetTagIDs", sceneID).Return(nil, nil).Once()
mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once()
}
err := TagScenes(&tag, nil, mockSceneReader)
assert := assert.New(t)
assert.Nil(err)
mockSceneReader.AssertExpectations(t)
}
func TestTagImages(t *testing.T) {
type test struct {
tagName string
expectedRegex string
}
tagNames := []test{
{
"tag name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"tag + name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range tagNames {
testTagImages(t, p.tagName, p.expectedRegex)
}
}
func testTagImages(t *testing.T, tagName, expectedRegex string) {
mockImageReader := &mocks.ImageReaderWriter{}
const tagID = 2
var images []*models.Image
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) {
images = append(images, &models.Image{
ID: i + 1,
Path: p,
})
}
tag := models.Tag{
ID: tagID,
Name: tagName,
}
organized := false
perPage := models.PerPageAll
expectedImageFilter := &models.ImageFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once()
for i := range matchingPaths {
imageID := i + 1
mockImageReader.On("GetTagIDs", imageID).Return(nil, nil).Once()
mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once()
}
err := TagImages(&tag, nil, mockImageReader)
assert := assert.New(t)
assert.Nil(err)
mockImageReader.AssertExpectations(t)
}
func TestTagGalleries(t *testing.T) {
type test struct {
tagName string
expectedRegex string
}
tagNames := []test{
{
"tag name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`,
},
{
"tag + name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
},
}
for _, p := range tagNames {
testTagGalleries(t, p.tagName, p.expectedRegex)
}
}
func testTagGalleries(t *testing.T, tagName, expectedRegex string) {
mockGalleryReader := &mocks.GalleryReaderWriter{}
const tagID = 2
var galleries []*models.Gallery
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) {
galleries = append(galleries, &models.Gallery{
ID: i + 1,
Path: models.NullString(p),
})
}
tag := models.Tag{
ID: tagID,
Name: tagName,
}
organized := false
perPage := models.PerPageAll
expectedGalleryFilter := &models.GalleryFilterType{
Organized: &organized,
Path: &models.StringCriterionInput{
Value: expectedRegex,
Modifier: models.CriterionModifierMatchesRegex,
},
}
expectedFindFilter := &models.FindFilterType{
PerPage: &perPage,
}
mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()
for i := range matchingPaths {
galleryID := i + 1
mockGalleryReader.On("GetTagIDs", galleryID).Return(nil, nil).Once()
mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once()
}
err := TagGalleries(&tag, nil, mockGalleryReader)
assert := assert.New(t)
assert.Nil(err)
mockGalleryReader.AssertExpectations(t)
}

240
pkg/autotag/tagger.go Normal file
View File

@@ -0,0 +1,240 @@
// Package autotag provides methods to auto-tag scenes with performers,
// studios and tags.
//
// The autotag engine tags scenes with performers/studios/tags if the scene's
// path matches the performer/studio/tag name. A scene's path is considered
// a match if it contains the performer/studio/tag's full name, ignoring any
// '.', '-', '_' characters in the path.
//
// For example, for a performer "foo bar", the following paths would be
// considered a match: "foo bar.mp4", "foobar.mp4", "foo.bar.mp4",
// "foo-bar.mp4", "aaa.foo bar.bbb.mp4".
// The following would not be considered a match:
// "aafoo bar.mp4", "foo barbb.mp4", "foo/bar.mp4"
package autotag
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
const separatorChars = `.\-_ `
// fixes #1292
func escapePathRegex(name string) string {
ret := name
chars := `+*?()|[]{}^$`
for _, c := range chars {
cStr := string(c)
ret = strings.ReplaceAll(ret, cStr, `\`+cStr)
}
return ret
}
func getPathQueryRegex(name string) string {
// escape specific regex characters
name = escapePathRegex(name)
// handle path separators
const separator = `[` + separatorChars + `]`
ret := strings.Replace(name, " ", separator+"*", -1)
ret = `(?:^|_|[^\w\d])` + ret + `(?:$|_|[^\w\d])`
return ret
}
func nameMatchesPath(name, path string) bool {
// escape specific regex characters
name = escapePathRegex(name)
name = strings.ToLower(name)
path = strings.ToLower(path)
// handle path separators
const separator = `[` + separatorChars + `]`
reStr := strings.Replace(name, " ", separator+"*", -1)
reStr = `(?:^|_|[^\w\d])` + reStr + `(?:$|_|[^\w\d])`
re := regexp.MustCompile(reStr)
return re.MatchString(path)
}
func getPathWords(path string) []string {
retStr := path
// remove the extension
ext := filepath.Ext(retStr)
if ext != "" {
retStr = strings.TrimSuffix(retStr, ext)
}
// handle path separators
const separator = `(?:_|[^\w\d])+`
re := regexp.MustCompile(separator)
retStr = re.ReplaceAllString(retStr, " ")
words := strings.Split(retStr, " ")
// remove any single letter words
var ret []string
for _, w := range words {
if len(w) > 1 {
ret = append(ret, w)
}
}
return ret
}
type tagger struct {
ID int
Type string
Name string
Path string
}
type addLinkFunc func(subjectID, otherID int) (bool, error)
func (t *tagger) addError(otherType, otherName string, err error) error {
return fmt.Errorf("error adding %s '%s' to %s '%s': %s", otherType, otherName, t.Type, t.Name, err.Error())
}
func (t *tagger) addLog(otherType, otherName string) {
logger.Infof("Added %s '%s' to %s '%s'", otherType, otherName, t.Type, t.Name)
}
func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc addLinkFunc) error {
others, err := getMatchingPerformers(t.Path, performerReader)
if err != nil {
return err
}
for _, p := range others {
added, err := addFunc(t.ID, p.ID)
if err != nil {
return t.addError("performer", p.Name.String, err)
}
if added {
t.addLog("performer", p.Name.String)
}
}
return nil
}
func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFunc) error {
others, err := getMatchingStudios(t.Path, studioReader)
if err != nil {
return err
}
// only add first studio
if len(others) > 0 {
studio := others[0]
added, err := addFunc(t.ID, studio.ID)
if err != nil {
return t.addError("studio", studio.Name.String, err)
}
if added {
t.addLog("studio", studio.Name.String)
}
}
return nil
}
func (t *tagger) tagTags(tagReader models.TagReader, addFunc addLinkFunc) error {
others, err := getMatchingTags(t.Path, tagReader)
if err != nil {
return err
}
for _, p := range others {
added, err := addFunc(t.ID, p.ID)
if err != nil {
return t.addError("tag", p.Name, err)
}
if added {
t.addLog("tag", p.Name)
}
}
return nil
}
func (t *tagger) tagScenes(paths []string, sceneReader models.SceneReader, addFunc addLinkFunc) error {
others, err := getMatchingScenes(t.Name, paths, sceneReader)
if err != nil {
return err
}
for _, p := range others {
added, err := addFunc(t.ID, p.ID)
if err != nil {
return t.addError("scene", p.GetTitle(), err)
}
if added {
t.addLog("scene", p.GetTitle())
}
}
return nil
}
func (t *tagger) tagImages(paths []string, imageReader models.ImageReader, addFunc addLinkFunc) error {
others, err := getMatchingImages(t.Name, paths, imageReader)
if err != nil {
return err
}
for _, p := range others {
added, err := addFunc(t.ID, p.ID)
if err != nil {
return t.addError("image", p.GetTitle(), err)
}
if added {
t.addLog("image", p.GetTitle())
}
}
return nil
}
func (t *tagger) tagGalleries(paths []string, galleryReader models.GalleryReader, addFunc addLinkFunc) error {
others, err := getMatchingGalleries(t.Name, paths, galleryReader)
if err != nil {
return err
}
for _, p := range others {
added, err := addFunc(t.ID, p.ID)
if err != nil {
return t.addError("gallery", p.GetTitle(), err)
}
if added {
t.addLog("gallery", p.GetTitle())
}
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/fvbommel/sortorder"
@@ -20,12 +21,32 @@ import (
)
var DB *sqlx.DB
var WriteMu *sync.Mutex
var dbPath string
var appSchemaVersion uint = 18
var appSchemaVersion uint = 22
var databaseSchemaVersion uint
var (
// ErrMigrationNeeded indicates that a database migration is needed
// before the database can be initialized
ErrMigrationNeeded = errors.New("database migration required")
// ErrDatabaseNotInitialized indicates that the database is not
// initialized, usually due to an incomplete configuration.
ErrDatabaseNotInitialized = errors.New("database not initialized")
)
const sqlite3Driver = "sqlite3ex"
// Ready returns an error if the database is not ready to begin transactions.
func Ready() error {
if DB == nil {
return ErrDatabaseNotInitialized
}
return nil
}
func init() {
// register custom driver with regexp function
registerCustomDriver()
@@ -35,20 +56,20 @@ func init() {
// performs a full migration to the latest schema version. Otherwise, any
// necessary migrations must be run separately using RunMigrations.
// Returns true if the database is new.
func Initialize(databasePath string) bool {
func Initialize(databasePath string) error {
dbPath = databasePath
if err := getDatabaseSchemaVersion(); err != nil {
panic(err)
return fmt.Errorf("error getting database schema version: %s", err.Error())
}
if databaseSchemaVersion == 0 {
// new database, just run the migrations
if err := RunMigrations(); err != nil {
panic(err)
return fmt.Errorf("error running initial schema migrations: %s", err.Error())
}
// RunMigrations calls Initialise. Just return
return true
return nil
} else {
if databaseSchemaVersion > appSchemaVersion {
panic(fmt.Sprintf("Database schema version %d is incompatible with required schema version %d", databaseSchemaVersion, appSchemaVersion))
@@ -57,14 +78,15 @@ func Initialize(databasePath string) bool {
// if migration is needed, then don't open the connection
if NeedsMigration() {
logger.Warnf("Database schema version %d does not match required schema version %d.", databaseSchemaVersion, appSchemaVersion)
return false
return nil
}
}
const disableForeignKeys = false
DB = open(databasePath, disableForeignKeys)
WriteMu = &sync.Mutex{}
return false
return nil
}
func open(databasePath string, disableForeignKeys bool) *sqlx.DB {
@@ -77,6 +99,7 @@ func open(databasePath string, disableForeignKeys bool) *sqlx.DB {
conn, err := sqlx.Open(sqlite3Driver, url)
conn.SetMaxOpenConns(25)
conn.SetMaxIdleConns(4)
conn.SetConnMaxLifetime(30 * time.Second)
if err != nil {
logger.Fatalf("db.Open(): %q\n", err)
}
@@ -146,6 +169,10 @@ func AppSchemaVersion() uint {
return appSchemaVersion
}
func DatabasePath() string {
return dbPath
}
func DatabaseBackupPath() string {
return fmt.Sprintf("%s.%d.%s", dbPath, databaseSchemaVersion, time.Now().Format("20060102_150405"))
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE `performers_tags` (
`performer_id` integer NOT NULL,
`tag_id` integer NOT NULL,
foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE
);
CREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`);
CREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`);

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