Compare commits

..

57 Commits

Author SHA1 Message Date
Stash Dev
a24194164d temp 2020-01-06 15:35:24 -08:00
Stash Dev
f8aa0433a3 Fix scene covers not importing correctly 2020-01-06 15:18:06 -08:00
WithoutPants
488cd5575d Use arg for regex queries (#311) 2020-01-06 13:02:25 -05:00
WithoutPants
bab7c8f250 Add scenes tab to performer page (#280)
* Make performer page tabbed

* Add performer scenes tab

* Make performer scenes criteria smarter

* Adjust performer page layout. Add URL links

* Add lightbox for performer image

* Alias editing
2020-01-05 13:56:06 -05:00
WithoutPants
7fdaccf669 Xpath scraping from URL (#285)
* Add xpath performer and scene scraping

* Add studio scraping

* Refactor code

* Fix compile error

* Don't overwrite performer URL during a scrape
2020-01-04 11:39:33 -05:00
Stash Dev
d35f3a9b10 Tweaked launch console messages 2019-12-31 14:42:06 -08:00
Stash Dev
653406efd1 Added support for import / export of scene cover images 2019-12-31 14:38:49 -08:00
all-hail-canada
9766071815 Display both server address and listening address in log (#300)
* Show localhost in console output instead of 0.0.0.0

* Updated message to reflect both navigation and listening address

Co-authored-by: InfiniteTF <infinitekittens@protonmail.com>
2019-12-31 09:22:34 -08:00
Stash Dev
9a51c586db Fix incorrect sprites
The step size was being cast to an int which made the time lose precision and would offset sprite images incorrectly
2019-12-31 09:14:52 -08:00
FleetingOrchard
b31af52d41 Fix usage of Box.Bytes causing depreciation message (#295)
* Add release make target

* Use Box.Find now that Box.Bytes is depreciated

Pretty much directly mocked off of the post-depreciation implementation
of Box.Bytes in packr. In theory we should totally be checking the
returned error but I'm lazy.
2019-12-28 09:53:16 -08:00
bnkai
52dd0197ee Added exclude patterns support for Clean Task (#274)
* Added exclude patterns support for Clean Task

* Added test file

* Refactoring and cosmetic fixes

* * Replace Match with MatchString
2019-12-23 21:06:07 -05:00
WithoutPants
f52db4f58b Add stash scraper type (#269)
* Add stash scraper type

* Add graphql client to vendor

* Embed stash credentials in URL

* Fill URL from scraped scene

* Nil IDs returned from remote stash

* Nil check
2019-12-20 19:13:23 -05:00
bnkai
e58088b057 Fixed minor typo bug for scene list thumbnails (#275) 2019-12-18 17:32:24 -05:00
WithoutPants
ea995dc374 Fix transcoded video player position (#272)
* Fix transcoded video player position

* Abbreviate change
2019-12-17 18:36:45 -05:00
WithoutPants
043745a84f Add cache breaker for screenshot using modtime (#271) 2019-12-17 12:28:44 -05:00
bnkai
0714cbfa34 Add exclude file from scan feature (#253)
* Added exclude file from scan feature

* Abort exclusion instead of panicking when pattern isn't valid

* Added UI configuration for exclude patterns

*   * cosmetic fixes
  * changed behavior of exclude function to continue and ignore invalide regex patterns
  * added some more tests (windows networks and continue after regex error)
2019-12-17 09:26:16 -05:00
WithoutPants
f8762c4ef6 jwplayer transcode seek support. Remove video.js (#268) 2019-12-16 11:50:16 -05:00
FleetingOrchard
7ce96cd02b Add "Open Random" to performer list (#265)
Mostly cribbing directly off WithoutPants' work.
2019-12-15 20:37:44 -05:00
WithoutPants
92837fe1f7 Add scene metadata scraping functionality (#236)
* Add scene scraping functionality

* Adapt to changed scraper config
2019-12-15 20:35:34 -05:00
WithoutPants
f8a760d729 Fix vtt for chapter display in scene players (#263) 2019-12-13 15:41:46 -05:00
WithoutPants
da3e91193c Allow uploading of custom scene covers (#262)
* Refactor common code

* Further refactoring

* Add UI support for changing scene cover image

* Add backend support for changing scene screenshot
2019-12-13 15:40:58 -05:00
WithoutPants
c05496a724 Make scene metadata from file metadata optional (#259) 2019-12-13 01:18:02 -05:00
WithoutPants
50784025f2 Change scraper config to yaml (#256) 2019-12-12 14:27:44 -05:00
WithoutPants
50930f6ca7 Add responsive menu (#257) 2019-12-12 13:57:13 -05:00
WithoutPants
bb164f1895 Add Play random button to scenes and scene markers page (#255)
* Add play random button for scenes

* Add play random to scene markers
2019-12-09 09:39:01 -05:00
WithoutPants
c66d9fcc28 Use inputgroup instead of editabletext (#251) 2019-12-08 17:28:38 -05:00
echo6ix
7dab3fcff7 Beautify scene list table (#252)
* Beautify scene list table

Added modifier props to HTMLTable component.

* Apply table change to performers table
2019-12-08 17:20:38 -05:00
InfiniteTF
ecf4e802b0 Bump react-scripts to version v3.3.0 (#254) 2019-12-08 09:17:02 -05:00
Colin_
86ecbf1c74 Adding issue templates
RFC template

Feature template

Bug report template
2019-12-07 16:24:37 -05:00
Stash Dev
d76f792f34 JWPlayer 8.11.5 2019-12-05 12:34:55 -08:00
WithoutPants
12c7faab4e Scene ui improvements (#232)
* Move duration and resolution to overlay

* Improve display of portrait videos

* Condense filter controls

* Add performer images to scene tags

* Add studio overlay to scene cards

* Fade out scene overlays on hover

* CSS grid tweaks

* Align overlay to bottom of video preview

* Fix opacity value

* Fix performer thumbnails

* Show studio overlay on mouseover

* Correct display colour for display mode buttons

* Add scene zoom slider

* Add show studio as text option

* Move select all/none to more button
2019-12-05 12:24:22 -05:00
WithoutPants
c14153ab5a Allow pasting image into performer/studio (#246) 2019-12-05 10:44:05 -05:00
WithoutPants
fe7bf59906 Marker time input (#242)
* Use duration input for marker time

* Allow reset to current time

* Validate input
2019-12-04 12:47:17 -05:00
WithoutPants
85935f022a Fix video.js current time (#241) 2019-12-03 09:29:45 -05:00
WithoutPants
1760f4fdcf Fix performers/studio/tags updating after parse (#229) 2019-12-01 11:19:44 -05:00
WithoutPants
1704d3771f Add scene auto-tagging from filename (#204)
* Add auto tagging for performers, studios and tags

* Fix scene title logging

* Fix golang 1.11 compile error

* Improve regex pattern. Only log when added

* Remove paging from parse query

* Add integration test

* Fix prefix detection on paths
2019-12-01 11:18:44 -05:00
Stash Dev
ca2680a86d Improve display of wall items
Respect the aspect ratio and show all content
2019-11-30 13:38:23 -08:00
bnkai
3f511e48e8 Fix previews generation bug (#231) 2019-11-30 08:58:15 -08:00
Stash Dev
ddae45f2b4 Revert "Use justified layout for wall (#218)"
This reverts commit 7f03f48310.
2019-11-30 08:41:03 -08:00
WithoutPants
dc781df417 Fix marker page error (#237) 2019-11-30 06:46:20 -08:00
WithoutPants
8493c013e7 Loop and autostart flags. Save interface options (#230) 2019-11-28 20:41:17 -05:00
StashAppDev
bcd3cefcc9 Update FUNDING.yml 2019-11-28 09:14:31 -08:00
echo6ix
a4858327aa README: Added additional notes to dev installion (#190)
* Go download url/instructions
* `yarn install`
* `mingw32-make` for Windows
2019-11-28 10:40:24 -05:00
WithoutPants
c759a068b1 Fix clobbered script (#228) 2019-11-28 10:03:32 -05:00
WithoutPants
482d9ddd45 Upload pull request builds to transfer.sh (#165)
* Upload pull request builds to transfer.sh
2019-11-27 13:17:32 -05:00
StashAppDev
7c97e36af8 Config should be read from cwd before user profile (#225)
fixes #219
2019-11-26 13:11:42 -08:00
WithoutPants
2a02e5a65d Add test and integration tests to the Makefile (#222)
* Add unit and integration test to make file

* Add gitattributes for go.mod/go.sum files

* Always run integration tests

* Removed redundant call. Clarified targets
2019-11-24 21:10:16 -05:00
Leopere
46654f1672 fix builds (#223)
* updated cross-compile.sh and .travis.yml
2019-11-19 08:59:23 -05:00
WithoutPants
17247060b6 Generic performer scrapers (#203)
* Generalise scraper API

* Add script performer scraper

* Fixes from testing

* Add context to scrapers and generalise

* Add scraping performer from URL

* Add error handling

* Move log to debug

* Add supported scrape types
2019-11-18 21:49:05 -05:00
WithoutPants
9bfa4e7560 Fix if ] character 2019-11-18 12:54:20 +11:00
WithoutPants
1f9da15491 Fix extra quote in travis.yml 2019-11-18 12:34:04 +11:00
WithoutPants
561a718674 Fix missing quote in travis.yml 2019-11-18 12:23:32 +11:00
WithoutPants
7f03f48310 Use justified layout for wall (#218) 2019-11-17 16:43:14 -05:00
WithoutPants
23657408de Refresh config when paths change (#217) 2019-11-17 16:42:24 -05:00
WithoutPants
5963844191 Add develop branch releases and display version tag (#216)
* Add releases for develop branch. Show version tag

* Pass version tag to cross-compile
2019-11-17 16:41:08 -05:00
WithoutPants
6dcb270471 Close database after migrating. Add reset errors (#215) 2019-11-17 16:39:33 -05:00
WithoutPants
6a75d5551f Use vendor when building (#201)
* Use vendor code for all go calls

* Add missing vendor dependencies

* Add travis_retry to yarn install

* Fix go test call
2019-11-16 08:03:28 -08:00
308 changed files with 195635 additions and 4523 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
go.mod text eol=lf
go.sum text eol=lf

2
.github/FUNDING.yml vendored
View File

@@ -2,7 +2,7 @@
# github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
# patreon: # Replace with a single Patreon username
# open_collective: # Replace with a single Open Collective username
open_collective: stashapp
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -9,18 +9,23 @@ env:
- GO111MODULE=on
before_install:
- echo -e "machine github.com\n login $CI_USER_TOKEN" > ~/.netrc
- yarn --cwd ui/v2 install
- travis_retry yarn --cwd ui/v2 install
- make generate
- CI=false yarn --cwd ui/v2 build # TODO: Fix warnings
#- go get -v github.com/mgechev/revive
script:
#- make lint
#- make vet
- go test
- make it
after_success:
- if [ "$TRAVIS_BRANCH" = "develop" ]; then export TAG_SUFFIX="_dev"; elif [ "$TRAVIS_BRANCH" != "master" ]; then export TAG_SUFFIX="_$TRAVIS_BRANCH"; fi
- export STASH_VERSION="v0.0.0-alpha${TAG_SUFFIX}"
- docker pull stashapp/compiler:develop
- sh ./scripts/cross-compile.sh ${STASH_VERSION}
- 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then sh ./scripts/upload-pull-request.sh; fi'
before_deploy:
- docker pull stashappdev/compiler
- sh ./scripts/cross-compile.sh
- git tag -f v0.0.0-alpha
- if [ "$TRAVIS_BRANCH" = "develop" ]; then export TAG_SUFFIX="_dev"; fi
- git tag -f ${STASH_VERSION}
- git push -f --tags
- export RELEASE_DATE=$(date +'%Y-%m-%d %H:%M:%S %Z')
deploy:
@@ -37,8 +42,9 @@ deploy:
body: ${RELEASE_DATE}
on:
repo: stashapp/stash
branch: master
all_branches: true
condition: $TRAVIS_BRANCH =~ ^(master|develop)$
branches:
only:
- master
- develop
- develop

View File

@@ -3,6 +3,8 @@ ifeq ($(OS),Windows_NT)
SET := set
endif
release: generate ui build
build:
$(eval DATE := $(shell go run scripts/getDate.go))
$(eval GITHASH := $(shell git rev-parse --short HEAD))
@@ -17,7 +19,7 @@ clean:
# Regenerates GraphQL files
.PHONY: generate
generate:
go generate
go generate -mod=vendor
cd ui/v2 && yarn run gqlgen
# Runs gofmt -w on the project's source code, modifying any files that do not match its style.
@@ -28,12 +30,22 @@ fmt:
# Runs go vet on the project's source code.
.PHONY: vet
vet:
go vet ./...
go vet -mod=vendor ./...
.PHONY: lint
lint:
revive -config revive.toml -exclude ./vendor/... ./...
# runs unit tests - excluding integration tests
.PHONY: test
test:
go test -mod=vendor ./...
# runs all tests - including integration tests
.PHONY: it
it:
go test -mod=vendor -tags=integration ./...
.PHONY: ui
ui:
cd ui/v2 && yarn build

View File

@@ -62,12 +62,14 @@ Join the [Discord server](https://discord.gg/2TsNFKt).
## Install
* [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`
* [Binary Download](https://github.com/gobuffalo/packr/releases)
* [Yarn](https://yarnpkg.com/en/docs/install) - Yarn package manager
* Run `yarn install` in the `stash/ui/v2` folder (before running make generate for first time).
NOTE: You may need to run the `go get` commands outside the project directory to avoid modifying the projects module file.
@@ -86,6 +88,8 @@ TODO
2. Add `GO111MODULE=on`
3. Under system variables find the `Path`. Edit and add `C:\Program Files\mingw-w64\*\mingw64\bin` (replace * with the correct path).
NOTE: The `make` command in Windows will be `mingw32-make` with MingW.
## Commands
* `make generate` - Generate Go GraphQL and packr2 files

7
go.mod
View File

@@ -3,9 +3,10 @@ module github.com/stashapp/stash
require (
github.com/99designs/gqlgen v0.9.0
github.com/PuerkitoBio/goquery v1.5.0
github.com/antchfx/htmlquery v1.2.0
github.com/antchfx/xpath v1.1.2 // indirect
github.com/bmatcuk/doublestar v1.1.5
github.com/disintegration/imaging v1.6.0
github.com/fsnotify/fsnotify v1.4.7
github.com/go-chi/chi v4.0.2+incompatible
github.com/gobuffalo/packr/v2 v2.0.2
github.com/golang-migrate/migrate/v4 v4.3.1
@@ -14,14 +15,16 @@ require (
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jmoiron/sqlx v1.2.0
github.com/mattn/go-sqlite3 v1.10.0
github.com/microcosm-cc/bluemonday v1.0.2 // indirect
github.com/rs/cors v1.6.0
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/sirupsen/logrus v1.4.2
github.com/spf13/pflag v1.0.3
github.com/spf13/viper v1.4.0
github.com/vektah/gqlparser v1.1.2
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
golang.org/x/image v0.0.0-20190118043309-183bebdce1b2 // indirect
golang.org/x/net v0.0.0-20190522155817-f3200d17e092
gopkg.in/yaml.v2 v2.2.2
)
replace git.apache.org/thrift.git => github.com/apache/thrift v0.0.0-20180902110319-2566ecd5d999

56
go.sum
View File

@@ -1,18 +1,12 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.28.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.36.0/go.mod h1:RUoy9p/M4ge0HzT8L+SDZ8jg+Q6fth0CiBuhFJpSV40=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
contrib.go.opencensus.io/exporter/stackdriver v0.6.0/go.mod h1:QeFzMJDAw8TXt5+aRaSuE8l5BwaMIOIlaVkBOPRuMuw=
dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU=
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
github.com/99designs/gqlgen v0.4.5-0.20190127090136-055fb4bc9a6a h1:oTsAt8YXjEk1fo7uZR7gya1jrH48oPulx5oF6zWTHRw=
github.com/99designs/gqlgen v0.4.5-0.20190127090136-055fb4bc9a6a/go.mod h1:st7qHA6ssU3uRZkmv+wzrzgX4srvIqEIdE5iuRW8GhE=
github.com/99designs/gqlgen v0.8.2 h1:xOkDPWn/MZjkQ32pu6Axx15mNah0NAq9WalFqT+RavA=
github.com/99designs/gqlgen v0.8.2/go.mod h1:aLyJw9xUgdJxZ8EqNQxo2pGFhXXJ/hq8t7J4yn8TgI4=
github.com/99designs/gqlgen v0.9.0 h1:g1arBPML74Vqv0L3Q+TqIhGXLspV+2MYtRLkBxuZrlE=
github.com/99designs/gqlgen v0.9.0/go.mod h1:HrrG7ic9EgLPsULxsZh/Ti+p0HNWgR3XRuvnD0pb5KY=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
@@ -36,16 +30,17 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/antchfx/htmlquery v1.2.0 h1:oKShnsGlnOHX6t4uj5OHgLKkABcJoqnXpqnscoi9Lpw=
github.com/antchfx/htmlquery v1.2.0/go.mod h1:MS9yksVSQXls00iXkiMqXr0J+umL/AmxXKuP28SUJM8=
github.com/antchfx/xpath v1.1.2 h1:YziPrtM0gEJBnhdUGxYcIVYXZ8FXbtbovxOi+UW/yWQ=
github.com/antchfx/xpath v1.1.2/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/apache/thrift v0.0.0-20180902110319-2566ecd5d999/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aws/aws-sdk-go v1.15.54/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar v1.1.5 h1:2bNwBOmhyFEFcoB3tGvTD5xanq+4kyOZlB8wFYbMjkk=
github.com/bmatcuk/doublestar v1.1.5/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
@@ -97,17 +92,12 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsouza/fake-gcs-server v1.3.0/go.mod h1:Lq+43m2znsXfDKHnQMfdA0HpYYAEJsfizsbpk5k3TLo=
github.com/fsouza/fake-gcs-server v1.7.0/go.mod h1:5XIRs4YvwNbNoz+1JF8j6KLAyDh7RHGAyAK3EP2EsNk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.0.1+incompatible h1:RSRC5qmFPtO90t7pTL0DBMNpZFsb/sHF3RXVlDgFisA=
github.com/go-chi/chi v4.0.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-ini/ini v1.39.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@@ -322,7 +312,6 @@ github.com/gobuffalo/uuid v2.0.5+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw
github.com/gobuffalo/validate v2.0.3+incompatible/go.mod h1:N+EtDe0J8252BgfzQUChBgfd6L93m9weay53EWFVsMM=
github.com/gobuffalo/x v0.0.0-20181003152136-452098b06085/go.mod h1:WevpGD+5YOreDJznWevcn8NTmQEW5STSBgIkpkjzqXc=
github.com/gobuffalo/x v0.0.0-20181007152206-913e47c59ca7/go.mod h1:9rDPXaB3kXdKWzMc4odGQQdG2e2DIEmANy5aSJ9yesY=
github.com/gocql/gocql v0.0.0-20181124151448-70385f88b28b/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0=
github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0=
github.com/gofrs/uuid v3.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -330,11 +319,10 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-migrate/migrate/v4 v4.2.2 h1:m9WF3B3yge1mKm5+/q6C3qPETMWqphrod3+osb+sP8A=
github.com/golang-migrate/migrate/v4 v4.2.2/go.mod h1:JRwdki93/aFawDXMUM4GcRu/FAIfyw+1Kuyd9vkbaeA=
github.com/golang-migrate/migrate/v4 v4.3.1 h1:3eR1NY+pplX+m6yJ1fQf5dFWX3fBgUtZfDiaS/kJVu4=
github.com/golang-migrate/migrate/v4 v4.3.1/go.mod h1:mJ89KBgbXmM3P49BqOxRL3riNF/ATlg5kMhm17GA0dE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -356,7 +344,6 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gopherjs/gopherjs v0.0.0-20181004151105-1babbf986f6f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@@ -376,8 +363,6 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmg
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/h2non/filetype v1.0.6 h1:g84/+gdkAT1hnYO+tHpCLoikm13Ju55OkN4KCb1uGEQ=
github.com/h2non/filetype v1.0.6/go.mod h1:isekKqOuhMj+s/7r3rIeTErIRy4Rub5uBWHfvMusLMU=
github.com/h2non/filetype v1.0.8 h1:le8gpf+FQA0/DlDABbtisA1KiTS0Xi+YSC/E8yY3Y14=
github.com/h2non/filetype v1.0.8/go.mod h1:isekKqOuhMj+s/7r3rIeTErIRy4Rub5uBWHfvMusLMU=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
@@ -397,7 +382,6 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
@@ -407,7 +391,6 @@ github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/karrick/godirwalk v1.7.5/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34=
@@ -429,7 +412,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kshvakov/clickhouse v1.3.4/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLgx3MUnGwJqpE=
github.com/kshvakov/clickhouse v1.3.5/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLgx3MUnGwJqpE=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -470,7 +452,6 @@ github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:F
github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mongodb/mongo-go-driver v0.1.0/go.mod h1:NK/HWDIIZkaYsnYa0hmtP443T5ELr0KDecmIioVuuyU=
github.com/mongodb/mongo-go-driver v0.3.0/go.mod h1:NK/HWDIIZkaYsnYa0hmtP443T5ELr0KDecmIioVuuyU=
github.com/monoculum/formam v0.0.0-20180901015400-4e68be1d79ba/go.mod h1:RKgILGEJq24YyJ2ban8EO0RUVSJlF1pGsEvoLEACr/Q=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -510,7 +491,6 @@ github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7q
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20180920065004-418d78d0b9a7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
@@ -537,6 +517,8 @@ github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxr
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw=
github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
github.com/shurcooL/highlight_go v0.0.0-20170515013102-78fb10f4a5f8/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
@@ -564,8 +546,6 @@ github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
@@ -585,8 +565,6 @@ github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI=
github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -604,10 +582,8 @@ github.com/unrolled/secure v0.0.0-20180918153822-f340ee86eb8b/go.mod h1:mnPT77IA
github.com/unrolled/secure v0.0.0-20181005190816-ff9db2ff917f/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/vektah/dataloaden v0.2.0/go.mod h1:vxM6NuRlgiR0M6wbVTJeKp9vQIs81ZMfCYO+4yq/jbE=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser v1.1.0 h1:3668p2gUlO+PiS81x957Rpr3/FPRWG6cxgCXAvTS1hw=
github.com/vektah/gqlparser v1.1.0/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
github.com/vektah/gqlparser v1.1.2 h1:ZsyLGn7/7jDNI+y4SEhI4yAxRChlv15pUHMjijT+e68=
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
@@ -617,7 +593,6 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.17.0/go.mod h1:mp1VrMQxhlqqDpKvH4UcQUa4YwlzNmymAjPrDdfxNpI=
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@@ -658,13 +633,11 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180404174746-b3c676e531a6/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180816102801-aaf60122140d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180921000356-2f5d2388922f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180925072008-f04abc6bdfa7/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -698,6 +671,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -705,7 +679,6 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180921163948-d47a0f339242/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180925112736-b09afc3d579e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180927150500-dad3d9fb7b6e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181005133103-4497e2df6f9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181011152604-fa43e7bc11ba/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -722,7 +695,6 @@ golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190102155601-82a175fd1598/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190108104531-7fbe1cd0fcc2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190116161447-11f53e031339 h1:g/Jesu8+QLnA0CPzF3E1pURg0Byr7i6jLoX5sqjcAh0=
golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -741,7 +713,6 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180924175601-e93be7f42f9f/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181003024731-2f84ea8ef872/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181006002542-f60d9635b16a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181008205924-a2b3f7f249e9/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -763,7 +734,6 @@ golang.org/x/tools v0.0.0-20181207183836-8bc39b988060/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20181212172921-837e80568c09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190102213336-ca9055ed7d04/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190104182027-498d95493402/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190108222858-421f03a57a64/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190111214448-fc1d57b08d7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190118193359-16909d206f00 h1:6OmoTtlNJlHuWNIjTEyUtMBHrryp8NRuf/XtnC7MmXM=
@@ -782,8 +752,6 @@ golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd h1:oMEQDWVXVNpceQoVd1JN3CQ7LYJJzs5qWqZIUcxXHHw=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.0.0-20180921000521-920bb1beccf7/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.0.0-20181015145326-625cd1887957/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
@@ -794,19 +762,17 @@ google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180924164928-221a8d4f7494/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
google.golang.org/genproto v0.0.0-20190108161440-ae2f86662275/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
google.golang.org/genproto v0.0.0-20190201180003-4b09977fb922/go.mod h1:L3J43x8/uS+qIUoksaLKe6OS3nUKxOKuIFz1sl2/jx4=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.15.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -823,7 +789,6 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.39.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mail.v2 v2.0.0-20180731213649-a0242b2233b4/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -834,7 +799,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20180920025451-e3ad64cb4ed3/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=

View File

@@ -28,3 +28,15 @@ models:
model: github.com/stashapp/stash/pkg/models.Studio
Tag:
model: github.com/stashapp/stash/pkg/models.Tag
ScrapedPerformer:
model: github.com/stashapp/stash/pkg/models.ScrapedPerformer
ScrapedScene:
model: github.com/stashapp/stash/pkg/models.ScrapedScene
ScrapedScenePerformer:
model: github.com/stashapp/stash/pkg/models.ScrapedScenePerformer
ScrapedSceneStudio:
model: github.com/stashapp/stash/pkg/models.ScrapedSceneStudio
ScrapedSceneTag:
model: github.com/stashapp/stash/pkg/models.ScrapedSceneTag
SceneFileType:
model: github.com/stashapp/stash/pkg/models.SceneFileType

View File

@@ -10,9 +10,15 @@ fragment ConfigGeneralData on ConfigGeneralResult {
logOut
logLevel
logAccess
excludes
}
fragment ConfigInterfaceData on ConfigInterfaceResult {
soundOnPreview
wallShowTitle
maximumLoopDuration
autostartVideo
showStudioAsText
css
cssEnabled
}
@@ -24,4 +30,4 @@ fragment ConfigData on ConfigResult {
interface {
...ConfigInterfaceData
}
}
}

View File

@@ -0,0 +1,75 @@
fragment ScrapedPerformerData on ScrapedPerformer {
name
url
birthdate
ethnicity
country
eye_color
height
measurements
fake_tits
career_length
tattoos
piercings
aliases
}
fragment ScrapedScenePerformerData on ScrapedScenePerformer {
id
name
url
twitter
instagram
birthdate
ethnicity
country
eye_color
height
measurements
fake_tits
career_length
tattoos
piercings
aliases
}
fragment ScrapedSceneStudioData on ScrapedSceneStudio {
id
name
url
}
fragment ScrapedSceneTagData on ScrapedSceneTag {
id
name
}
fragment ScrapedSceneData on ScrapedScene {
title
details
url
date
file {
size
duration
video_codec
audio_codec
width
height
framerate
bitrate
}
studio {
...ScrapedSceneStudioData
}
tags {
...ScrapedSceneTagData
}
performers {
...ScrapedScenePerformerData
}
}

View File

@@ -8,7 +8,8 @@ mutation SceneUpdate(
$studio_id: ID,
$gallery_id: ID,
$performer_ids: [ID!] = [],
$tag_ids: [ID!] = []) {
$tag_ids: [ID!] = [],
$cover_image: String) {
sceneUpdate(input: {
id: $id,
@@ -20,7 +21,8 @@ mutation SceneUpdate(
studio_id: $studio_id,
gallery_id: $gallery_id,
performer_ids: $performer_ids,
tag_ids: $tag_ids
tag_ids: $tag_ids,
cover_image: $cover_image
}) {
...SceneData
}

View File

@@ -61,7 +61,8 @@ query Logs {
}
query Version {
version {
hash,
version
hash
build_time
}
}

View File

@@ -0,0 +1,51 @@
query ListPerformerScrapers {
listPerformerScrapers {
id
name
performer {
urls
supported_scrapes
}
}
}
query ListSceneScrapers {
listSceneScrapers {
id
name
scene {
urls
supported_scrapes
}
}
}
query ScrapePerformerList($scraper_id: ID!, $query: String!) {
scrapePerformerList(scraper_id: $scraper_id, query: $query) {
...ScrapedPerformerData
}
}
query ScrapePerformer($scraper_id: ID!, $scraped_performer: ScrapedPerformerInput!) {
scrapePerformer(scraper_id: $scraper_id, scraped_performer: $scraped_performer) {
...ScrapedPerformerData
}
}
query ScrapePerformerURL($url: String!) {
scrapePerformerURL(url: $url) {
...ScrapedPerformerData
}
}
query ScrapeScene($scraper_id: ID!, $scene: SceneUpdateInput!) {
scrapeScene(scraper_id: $scraper_id, scene: $scene) {
...ScrapedSceneData
}
}
query ScrapeSceneURL($url: String!) {
scrapeSceneURL(url: $url) {
...ScrapedSceneData
}
}

View File

@@ -14,6 +14,10 @@ query MetadataGenerate($input: GenerateMetadataInput!) {
metadataGenerate(input: $input)
}
query MetadataAutoTag($input: AutoTagMetadataInput!) {
metadataAutoTag(input: $input)
}
query MetadataClean {
metadataClean
}

View File

@@ -45,6 +45,20 @@ type Query {
# Scrapers
"""List available scrapers"""
listPerformerScrapers: [Scraper!]!
listSceneScrapers: [Scraper!]!
"""Scrape a list of performers based on name"""
scrapePerformerList(scraper_id: ID!, query: String!): [ScrapedPerformer!]!
"""Scrapes a complete performer record based on a scrapePerformerList result"""
scrapePerformer(scraper_id: ID!, scraped_performer: ScrapedPerformerInput!): ScrapedPerformer
"""Scrapes a complete performer record based on a URL"""
scrapePerformerURL(url: String!): ScrapedPerformer
"""Scrapes a complete scene record based on an existing scene"""
scrapeScene(scraper_id: ID!, scene: SceneUpdateInput!): ScrapedScene
"""Scrapes a complete performer record based on a URL"""
scrapeSceneURL(url: String!): ScrapedScene
"""Scrape a performer using Freeones"""
scrapeFreeones(performer_name: String!): ScrapedPerformer
"""Scrape a list of performers from a query"""
@@ -66,6 +80,8 @@ type Query {
metadataScan(input: ScanMetadataInput!): String!
"""Start generating content. Returns the job ID"""
metadataGenerate(input: GenerateMetadataInput!): String!
"""Start auto-tagging. Returns the job ID"""
metadataAutoTag(input: AutoTagMetadataInput!): String!
"""Clean metadata. Returns the job ID"""
metadataClean: String!

View File

@@ -30,6 +30,8 @@ input ConfigGeneralInput {
logLevel: String!
"""Whether to log http access"""
logAccess: Boolean!
"""Array of file regexp to exclude from Scan"""
excludes: [String!]
}
type ConfigGeneralResult {
@@ -55,15 +57,37 @@ type ConfigGeneralResult {
logLevel: String!
"""Whether to log http access"""
logAccess: Boolean!
"""Array of file regexp to exclude from Scan"""
excludes: [String!]!
}
input ConfigInterfaceInput {
"""Enable sound on mouseover previews"""
soundOnPreview: Boolean
"""Show title and tags in wall view"""
wallShowTitle: Boolean
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player"""
autostartVideo: Boolean
"""If true, studio overlays will be shown as text instead of logo images"""
showStudioAsText: Boolean
"""Custom CSS"""
css: String
cssEnabled: Boolean
}
type ConfigInterfaceResult {
"""Enable sound on mouseover previews"""
soundOnPreview: Boolean
"""Show title and tags in wall view"""
wallShowTitle: Boolean
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player"""
autostartVideo: Boolean
"""If true, studio overlays will be shown as text instead of logo images"""
showStudioAsText: Boolean
"""Custom CSS"""
css: String
cssEnabled: Boolean
@@ -73,4 +97,4 @@ type ConfigInterfaceResult {
type ConfigResult {
general: ConfigGeneralResult!
interface: ConfigInterfaceResult!
}
}

View File

@@ -6,7 +6,16 @@ input GenerateMetadataInput {
}
input ScanMetadataInput {
nameFromMetadata: Boolean!
useFileMetadata: Boolean!
}
input AutoTagMetadataInput {
"""IDs of performers to tag files with, or "*" for all"""
performers: [String!]
"""IDs of studios to tag files with, or "*" for all"""
studios: [String!]
"""IDs of tags to tag files with, or "*" for all"""
tags: [String!]
}
type MetadataUpdateStatus {

View File

@@ -51,6 +51,8 @@ input SceneUpdateInput {
gallery_id: ID
performer_ids: [ID!]
tag_ids: [ID!]
"""This should be base64 encoded"""
cover_image: String
}
input BulkSceneUpdateInput {

View File

@@ -15,4 +15,22 @@ type ScrapedPerformer {
tattoos: String
piercings: String
aliases: String
}
input ScrapedPerformerInput {
name: String
url: String
twitter: String
instagram: String
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
}

View File

@@ -0,0 +1,70 @@
enum ScrapeType {
"""From text query"""
NAME
"""From existing object"""
FRAGMENT
"""From URL"""
URL
}
type ScraperSpec {
"""URLs matching these can be scraped with"""
urls: [String!]
supported_scrapes: [ScrapeType!]!
}
type Scraper {
id: ID!
name: String!
"""Details for performer scraper"""
performer: ScraperSpec
"""Details for scene scraper"""
scene: ScraperSpec
}
type ScrapedScenePerformer {
"""Set if performer matched"""
id: ID
name: String!
url: String
twitter: String
instagram: String
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
}
type ScrapedSceneStudio {
"""Set if studio matched"""
id: ID
name: String!
url: String
}
type ScrapedSceneTag {
"""Set if tag matched"""
id: ID
name: String!
}
type ScrapedScene {
title: String
details: String
url: String
date: String
file: SceneFileType # Resolver
studio: ScrapedSceneStudio
tags: [ScrapedSceneTag!]
performers: [ScrapedScenePerformer!]
}

View File

@@ -1,4 +1,5 @@
type Version {
version: String
hash: String!
build_time: String!
}

View File

@@ -1,4 +1,4 @@
//go:generate go run github.com/99designs/gqlgen
//go:generate go run -mod=vendor github.com/99designs/gqlgen
package main
import (

View File

@@ -8,7 +8,6 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
)
type Resolver struct{}
@@ -108,9 +107,10 @@ func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, err
}
func (r *queryResolver) Version(ctx context.Context) (*models.Version, error) {
hash, buildtime := GetVersion()
version, hash, buildtime := GetVersion()
return &models.Version{
Version: &version,
Hash: hash,
BuildTime: buildtime,
}, nil
@@ -160,20 +160,12 @@ func (r *queryResolver) SceneMarkerTags(ctx context.Context, scene_id string) ([
return result, nil
}
func (r *queryResolver) ScrapeFreeones(ctx context.Context, performer_name string) (*models.ScrapedPerformer, error) {
return scraper.GetPerformer(performer_name)
}
func (r *queryResolver) ScrapeFreeonesPerformerList(ctx context.Context, query string) ([]string, error) {
return scraper.GetPerformerNames(query)
}
// wasFieldIncluded returns true if the given field was included in the request.
// Slices are unmarshalled to empty slices even if the field was omitted. This
// method determines if it was omitted altogether.
func wasFieldIncluded(ctx context.Context, field string) bool {
rctx := graphql.GetRequestContext(ctx)
_, ret := rctx.Variables[field]
return ret
}

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/models"
@@ -64,7 +65,7 @@ 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)
screenshotPath := builder.GetScreenshotURL()
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt.Timestamp)
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL()
webpPath := builder.GetStreamPreviewImageURL()

View File

@@ -6,6 +6,7 @@ import (
"path/filepath"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
@@ -71,14 +72,40 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
logger.SetLogLevel(input.LogLevel)
}
if input.Excludes != nil {
config.Set(config.Exclude, input.Excludes)
}
if err := config.Write(); err != nil {
return makeConfigGeneralResult(), err
}
manager.GetInstance().RefreshConfig()
return makeConfigGeneralResult(), nil
}
func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) {
if input.SoundOnPreview != nil {
config.Set(config.SoundOnPreview, *input.SoundOnPreview)
}
if input.WallShowTitle != nil {
config.Set(config.WallShowTitle, *input.WallShowTitle)
}
if input.MaximumLoopDuration != nil {
config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
}
if input.AutostartVideo != nil {
config.Set(config.AutostartVideo, *input.AutostartVideo)
}
if input.ShowStudioAsText != nil {
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
}
css := ""
if input.CSS != nil {

View File

@@ -11,6 +11,7 @@ import (
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (*models.Scene, error) {
@@ -59,6 +60,9 @@ func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.Sce
func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.Tx) (*models.Scene, error) {
// Populate scene from the input
sceneID, _ := strconv.Atoi(input.ID)
var coverImageData []byte
updatedTime := time.Now()
updatedScene := models.ScenePartial{
ID: sceneID,
@@ -76,6 +80,14 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.T
if input.Date != nil {
updatedScene.Date = &models.SQLiteDate{String: *input.Date, Valid: true}
}
if input.CoverImage != nil && *input.CoverImage != "" {
var err error
_, coverImageData, err = utils.ProcessBase64Image(*input.CoverImage)
if err != nil {
return nil, err
}
updatedScene.Cover = &coverImageData
}
if input.Rating != nil {
updatedScene.Rating = &sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
@@ -149,6 +161,19 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.T
return nil, err
}
// only update the cover image if provided and everything else was successful
if coverImageData != nil {
scene, err := qb.Find(sceneID)
if err != nil {
return nil, err
}
err = manager.SetSceneScreenshot(scene.Checksum, coverImageData)
if err != nil {
return nil, err
}
}
return scene, nil
}
@@ -283,7 +308,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
if err := tx.Commit(); err != nil {
return false, err
}
// if delete generated is true, then delete the generated files
// for the scene
if input.DeleteGenerated != nil && *input.DeleteGenerated {

View File

@@ -45,14 +45,26 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
LogOut: config.GetLogOut(),
LogLevel: config.GetLogLevel(),
LogAccess: config.GetLogAccess(),
Excludes: config.GetExcludes(),
}
}
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
soundOnPreview := config.GetSoundOnPreview()
wallShowTitle := config.GetWallShowTitle()
maximumLoopDuration := config.GetMaximumLoopDuration()
autostartVideo := config.GetAutostartVideo()
showStudioAsText := config.GetShowStudioAsText()
css := config.GetCSS()
cssEnabled := config.GetCSSEnabled()
return &models.ConfigInterfaceResult{
CSS: &css,
CSSEnabled: &cssEnabled,
SoundOnPreview: &soundOnPreview,
WallShowTitle: &wallShowTitle,
MaximumLoopDuration: &maximumLoopDuration,
AutostartVideo: &autostartVideo,
ShowStudioAsText: &showStudioAsText,
CSS: &css,
CSSEnabled: &cssEnabled,
}
}

View File

@@ -10,10 +10,10 @@ import (
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
qb := models.NewSceneQueryBuilder()
idInt, _ := strconv.Atoi(*id)
var scene *models.Scene
var err error
if id != nil {
idInt, _ := strconv.Atoi(*id)
scene, err = qb.Find(idInt)
} else if checksum != nil {
scene, err = qb.FindByChecksum(*checksum)

View File

@@ -8,7 +8,7 @@ import (
)
func (r *queryResolver) MetadataScan(ctx context.Context, input models.ScanMetadataInput) (string, error) {
manager.GetInstance().Scan(input.NameFromMetadata)
manager.GetInstance().Scan(input.UseFileMetadata)
return "todo", nil
}
@@ -27,6 +27,11 @@ func (r *queryResolver) MetadataGenerate(ctx context.Context, input models.Gener
return "todo", nil
}
func (r *queryResolver) MetadataAutoTag(ctx context.Context, input models.AutoTagMetadataInput) (string, error) {
manager.GetInstance().AutoTag(input.Performers, input.Studios, input.Tags)
return "todo", nil
}
func (r *queryResolver) MetadataClean(ctx context.Context) (string, error) {
manager.GetInstance().Clean()
return "todo", nil

View File

@@ -0,0 +1,65 @@
package api
import (
"context"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
)
// deprecated
func (r *queryResolver) ScrapeFreeones(ctx context.Context, performer_name string) (*models.ScrapedPerformer, error) {
scrapedPerformer := models.ScrapedPerformerInput{
Name: &performer_name,
}
return scraper.GetFreeonesScraper().ScrapePerformer(scrapedPerformer)
}
// deprecated
func (r *queryResolver) ScrapeFreeonesPerformerList(ctx context.Context, query string) ([]string, error) {
scrapedPerformers, err := scraper.GetFreeonesScraper().ScrapePerformerNames(query)
if err != nil {
return nil, err
}
var ret []string
for _, v := range scrapedPerformers {
name := v.Name
ret = append(ret, *name)
}
return ret, nil
}
func (r *queryResolver) ListPerformerScrapers(ctx context.Context) ([]*models.Scraper, error) {
return scraper.ListPerformerScrapers()
}
func (r *queryResolver) ListSceneScrapers(ctx context.Context) ([]*models.Scraper, error) {
return scraper.ListSceneScrapers()
}
func (r *queryResolver) ScrapePerformerList(ctx context.Context, scraperID string, query string) ([]*models.ScrapedPerformer, error) {
if query == "" {
return nil, nil
}
return scraper.ScrapePerformerList(scraperID, query)
}
func (r *queryResolver) ScrapePerformer(ctx context.Context, scraperID string, scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error) {
return scraper.ScrapePerformer(scraperID, scrapedPerformer)
}
func (r *queryResolver) ScrapePerformerURL(ctx context.Context, url string) (*models.ScrapedPerformer, error) {
return scraper.ScrapePerformerURL(url)
}
func (r *queryResolver) ScrapeScene(ctx context.Context, scraperID string, scene models.SceneUpdateInput) (*models.ScrapedScene, error) {
return scraper.ScrapeScene(scraperID, scene)
}
func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*models.ScrapedScene, error) {
return scraper.ScrapeSceneURL(url)
}

View File

@@ -112,6 +112,33 @@ func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath)
}
func getChapterVttTitle(marker *models.SceneMarker) string {
if marker.Title != "" {
return marker.Title
}
qb := models.NewTagQueryBuilder()
primaryTag, err := qb.Find(marker.PrimaryTagID, nil)
if err != nil {
// should not happen
panic(err)
}
ret := primaryTag.Name
tags, err := qb.FindBySceneMarkerID(marker.ID, nil)
if err != nil {
// should not happen
panic(err)
}
for _, t := range tags {
ret += ", " + t.Name
}
return ret
}
func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
qb := models.NewSceneMarkerQueryBuilder()
@@ -121,10 +148,11 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
}
vttLines := []string{"WEBVTT", ""}
for _, marker := range sceneMarkers {
for i, marker := range sceneMarkers {
vttLines = append(vttLines, strconv.Itoa(i+1))
time := utils.GetVTTTime(marker.Seconds)
vttLines = append(vttLines, time+" --> "+time)
vttLines = append(vttLines, marker.Title)
vttLines = append(vttLines, getChapterVttTitle(marker))
vttLines = append(vttLines, "")
}
vtt := strings.Join(vttLines, "\n")

View File

@@ -21,12 +21,14 @@ import (
"github.com/gorilla/websocket"
"github.com/rs/cors"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/manager/paths"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
var version string = ""
var buildstamp string = ""
var githash string = ""
@@ -67,7 +69,7 @@ func Start() {
setupUIBox = packr.New("Setup UI Box", "../../ui/setup")
initialiseImages()
r := chi.NewRouter()
r.Use(authenticateHandler())
@@ -112,7 +114,7 @@ func Start() {
if !config.GetCSSEnabled() {
return
}
// search for custom.css in current directory, then $HOME/.stash
fn := config.GetCSSPath()
exists, _ := utils.FileExists(fn)
@@ -127,7 +129,7 @@ func Start() {
r.HandleFunc("/setup*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
data := setupUIBox.Bytes("index.html")
data, _ := setupUIBox.Find("index.html")
_, _ = w.Write(data)
} else {
r.URL.Path = strings.Replace(r.URL.Path, "/setup", "", 1)
@@ -182,6 +184,8 @@ func Start() {
return
}
manager.GetInstance().RefreshConfig()
http.Redirect(w, r, "/", 301)
})
@@ -189,13 +193,19 @@ func Start() {
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
data := uiBox.Bytes("index.html")
data, _ := uiBox.Find("index.html")
_, _ = w.Write(data)
} else {
http.FileServer(uiBox).ServeHTTP(w, r)
}
})
displayHost := config.GetHost()
if displayHost == "0.0.0.0" {
displayHost = "localhost"
}
displayAddress := displayHost + ":" + strconv.Itoa(config.GetPort())
address := config.GetHost() + ":" + strconv.Itoa(config.GetPort())
if tlsConfig := makeTLSConfig(); tlsConfig != nil {
httpsServer := &http.Server{
@@ -206,7 +216,8 @@ func Start() {
go func() {
printVersion()
logger.Infof("stash is running on HTTPS at https://" + address + "/")
logger.Infof("stash is listening on " + address)
logger.Infof("stash is running at https://" + displayAddress + "/")
logger.Fatal(httpsServer.ListenAndServeTLS("", ""))
}()
} else {
@@ -217,18 +228,23 @@ func Start() {
go func() {
printVersion()
logger.Infof("stash is running on HTTP at http://" + address + "/")
logger.Infof("stash is listening on " + address)
logger.Infof("stash is running at http://" + displayAddress + "/")
logger.Fatal(server.ListenAndServe())
}()
}
}
func printVersion() {
fmt.Printf("stash version: %s (%s)\n", githash, buildstamp)
versionString := githash
if version != "" {
versionString = version + " (" + versionString + ")"
}
fmt.Printf("stash version: %s - %s\n", versionString, buildstamp)
}
func GetVersion() (string, string) {
return githash, buildstamp
func GetVersion() (string, string, string) {
return version, githash, buildstamp
}
func makeTLSConfig() *tls.Config {

View File

@@ -1,6 +1,9 @@
package urlbuilders
import "strconv"
import (
"strconv"
"time"
)
type SceneURLBuilder struct {
BaseURL string
@@ -30,8 +33,8 @@ func (b SceneURLBuilder) GetSpriteVTTURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "_thumbs.vtt"
}
func (b SceneURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot"
func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?" + strconv.FormatInt(updateTime.Unix(), 10)
}
func (b SceneURLBuilder) GetChaptersVTTURL() string {

View File

@@ -2,6 +2,7 @@ package database
import (
"database/sql"
"errors"
"fmt"
"os"
"regexp"
@@ -16,7 +17,7 @@ import (
)
var DB *sqlx.DB
var appSchemaVersion uint = 1
var appSchemaVersion uint = 2
const sqlite3Driver = "sqlite3_regexp"
@@ -38,10 +39,20 @@ func Initialize(databasePath string) {
DB = conn
}
func Reset(databasePath string) {
_ = DB.Close()
_ = os.Remove(databasePath)
func Reset(databasePath string) error {
err := DB.Close()
if err != nil {
return errors.New("Error closing database: " + err.Error())
}
err = os.Remove(databasePath)
if err != nil {
return errors.New("Error removing database: " + err.Error())
}
Initialize(databasePath)
return nil
}
// Migrate the database
@@ -71,6 +82,7 @@ func runMigrations(databasePath string) {
panic(err.Error())
}
}
m.Close()
}
func registerRegexpFunc() {

View File

@@ -0,0 +1 @@
ALTER TABLE `scenes` ADD COLUMN `cover` blob;

View File

@@ -17,8 +17,8 @@ func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePre
args := []string{
"-v", "error",
"-ss", strconv.Itoa(options.Time),
"-t", "0.75",
"-i", probeResult.Path,
"-t", "0.75",
"-max_muxing_queue_size", "1024", // https://trac.ffmpeg.org/ticket/6375
"-y",
"-c:v", "libx264",

View File

@@ -22,12 +22,21 @@ const Password = "password"
const Database = "database"
const ScrapersPath = "scrapers_path"
const Exclude = "exclude"
const MaxTranscodeSize = "max_transcode_size"
const MaxStreamingTranscodeSize = "max_streaming_transcode_size"
const Host = "host"
const Port = "port"
// Interface options
const SoundOnPreview = "sound_on_preview"
const WallShowTitle = "wall_show_title"
const MaximumLoopDuration = "maximum_loop_duration"
const AutostartVideo = "autostart_video"
const ShowStudioAsText = "show_studio_as_text"
const CSSEnabled = "cssEnabled"
// Logging options
@@ -73,6 +82,24 @@ func GetDatabasePath() string {
return viper.GetString(Database)
}
func GetDefaultScrapersPath() string {
// default to the same directory as the config file
configFileUsed := viper.ConfigFileUsed()
configDir := filepath.Dir(configFileUsed)
fn := filepath.Join(configDir, "scrapers")
return fn
}
func GetExcludes() []string {
return viper.GetStringSlice(Exclude)
}
func GetScrapersPath() string {
return viper.GetString(ScrapersPath)
}
func GetHost() string {
return viper.GetString(Host)
}
@@ -149,6 +176,32 @@ func ValidateCredentials(username string, password string) bool {
return username == authUser && err == nil
}
// Interface options
func GetSoundOnPreview() bool {
viper.SetDefault(SoundOnPreview, true)
return viper.GetBool(SoundOnPreview)
}
func GetWallShowTitle() bool {
viper.SetDefault(WallShowTitle, true)
return viper.GetBool(WallShowTitle)
}
func GetMaximumLoopDuration() int {
viper.SetDefault(MaximumLoopDuration, 0)
return viper.GetInt(MaximumLoopDuration)
}
func GetAutostartVideo() bool {
viper.SetDefault(AutostartVideo, false)
return viper.GetBool(AutostartVideo)
}
func GetShowStudioAsText() bool {
viper.SetDefault(ShowStudioAsText, false)
return viper.GetBool(ShowStudioAsText)
}
func GetCSSPath() string {
// use custom.css in the same directory as the config file
configFileUsed := viper.ConfigFileUsed()

View File

@@ -0,0 +1,89 @@
package manager
import (
"github.com/stashapp/stash/pkg/logger"
"regexp"
"strings"
)
func excludeFiles(files []string, patterns []string) ([]string, int) {
if patterns == nil {
logger.Infof("No exclude patterns in config")
return files, 0
} else {
var results []string
var exclCount int
fileRegexps := generateRegexps(patterns)
if len(fileRegexps) == 0 {
logger.Infof("Excluded 0 files from scan")
return files, 0
}
for i := 0; i < len(files); i++ {
if matchFileSimple(files[i], fileRegexps) {
logger.Infof("File matched pattern. Excluding:\"%s\"", files[i])
exclCount++
} else {
//if pattern doesn't match add file to list
results = append(results, files[i])
}
}
logger.Infof("Excluded %d file(s) from scan", exclCount)
return results, exclCount
}
}
func matchFile(file string, patterns []string) bool {
if patterns == nil {
logger.Infof("No exclude patterns in config.")
} else {
fileRegexps := generateRegexps(patterns)
if len(fileRegexps) == 0 {
return false
}
for _, regPattern := range fileRegexps {
if regPattern.MatchString(strings.ToLower(file)) {
return true
}
}
}
return false
}
func generateRegexps(patterns []string) []*regexp.Regexp {
var fileRegexps []*regexp.Regexp
for _, pattern := range patterns {
reg, err := regexp.Compile(strings.ToLower(pattern))
if err != nil {
logger.Errorf("Exclude :%v", err)
} else {
fileRegexps = append(fileRegexps, reg)
}
}
if len(fileRegexps) == 0 {
return nil
} else {
return fileRegexps
}
}
func matchFileSimple(file string, regExps []*regexp.Regexp) bool {
for _, regPattern := range regExps {
if regPattern.MatchString(strings.ToLower(file)) {
return true
}
}
return false
}

View File

@@ -0,0 +1,91 @@
package manager
import (
"fmt"
"github.com/stashapp/stash/pkg/logger"
"testing"
)
var excludeTestFilenames = []string{
"/stash/videos/filename.mp4",
"/stash/videos/new filename.mp4",
"filename sample.mp4",
"/stash/videos/exclude/not wanted.webm",
"/stash/videos/exclude/not wanted2.webm",
"/somewhere/trash/not wanted.wmv",
"/disk2/stash/videos/exclude/!!wanted!!.avi",
"/disk2/stash/videos/xcl/not wanted.avi",
"/stash/videos/partial.file.001.webm",
"/stash/videos/partial.file.002.webm",
"/stash/videos/partial.file.003.webm",
"/stash/videos/sample file sample.mkv",
"/stash/videos/.ckRVp1/.still_encoding.mp4",
"c:\\stash\\videos\\exclude\\filename windows.mp4",
"c:\\stash\\videos\\filename windows.mp4",
"\\\\network\\videos\\filename windows network.mp4",
"\\\\network\\share\\windows network wanted.mp4",
"\\\\network\\share\\windows network wanted sample.mp4",
"\\\\network\\private\\windows.network.skip.mp4"}
var excludeTests = []struct {
testPattern []string
expected int
}{
{[]string{"sample\\.mp4$", "trash", "\\.[\\d]{3}\\.webm$"}, 6}, //generic
{[]string{"no_match\\.mp4"}, 0}, //no match
{[]string{"^/stash/videos/exclude/", "/videos/xcl/"}, 3}, //linux
{[]string{"/\\.[[:word:]]+/"}, 1}, //linux hidden dirs (handbrake unraid issue?)
{[]string{"c:\\\\stash\\\\videos\\\\exclude"}, 1}, //windows
{[]string{"\\/[/invalid"}, 0}, //invalid pattern
{[]string{"\\/[/invalid", "sample\\.[[:alnum:]]+$"}, 3}, //invalid pattern but continue
{[]string{"^\\\\\\\\network"}, 4}, //windows net share
{[]string{"\\\\private\\\\"}, 1}, //windows net share
{[]string{"\\\\private\\\\", "sample\\.mp4"}, 3}, //windows net share
}
func TestExcludeFiles(t *testing.T) {
for _, test := range excludeTests {
err := runExclude(excludeTestFilenames, test.testPattern, test.expected)
if err != nil {
t.Error(err)
}
}
}
func runExclude(filenames []string, patterns []string, expCount int) error {
files, count := excludeFiles(filenames, patterns)
if count != expCount {
return fmt.Errorf("Was expecting %d, found %d", expCount, count)
}
if len(files) != len(filenames)-expCount {
return fmt.Errorf("Returned list should have %d files, not %d ", len(filenames)-expCount, len(files))
}
return nil
}
func TestMatchFile(t *testing.T) {
for _, test := range excludeTests {
err := runMatch(excludeTestFilenames, test.testPattern, test.expected)
if err != nil {
t.Error(err)
}
}
}
func runMatch(filenames []string, patterns []string, expCount int) error {
count := 0
for _, file := range filenames {
if matchFile(file, patterns) {
logger.Infof("File \"%s\" matched pattern\n", file)
count++
}
}
if count != expCount {
return fmt.Errorf("Was expecting %d, found %d", expCount, count)
}
return nil
}

View File

@@ -3,13 +3,14 @@ package manager
import (
"bytes"
"fmt"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
"math"
"os/exec"
"runtime"
"strconv"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
)
type GeneratorInfo struct {
@@ -84,7 +85,7 @@ func (g *GeneratorInfo) configure() error {
// Something seriously wrong with this file
if numberOfFrames == 0 || !utils.IsValidFloat64(framerate) {
logger.Errorf(
"number of frames or framerate is 0. nb_frames <%s> framerate <%s> duration <%s>",
"number of frames or framerate is 0. nb_frames <%s> framerate <%f> duration <%f>",
videoStream.NbFrames,
framerate,
g.VideoFile.Duration,

View File

@@ -66,15 +66,15 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path)
// Create `this.chunkCount` thumbnails in the tmp directory
stepSize := int(g.Info.VideoFile.Duration / float64(g.Info.ChunkCount))
stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount)
for i := 0; i < g.Info.ChunkCount; i++ {
time := i * stepSize
time := float64(i) * stepSize
num := fmt.Sprintf("%.3d", i)
filename := "thumbnail" + num + ".jpg"
options := ffmpeg.ScreenshotOptions{
OutputPath: instance.Paths.Generated.GetTmpPath(filename),
Time: float64(time),
Time: time,
Width: 160,
}
encoder.Screenshot(g.Info.VideoFile, options)

View File

@@ -10,6 +10,7 @@ const (
Generate JobStatus = 4
Clean JobStatus = 5
Scrape JobStatus = 6
AutoTag JobStatus = 7
)
func (s JobStatus) String() string {
@@ -26,6 +27,8 @@ func (s JobStatus) String() string {
statusMessage = "Scan"
case Generate:
statusMessage = "Generate"
case AutoTag:
statusMessage = "Auto Tag"
}
return statusMessage

View File

@@ -39,6 +39,7 @@ type Scene struct {
Tags []string `json:"tags,omitempty"`
Markers []SceneMarker `json:"markers,omitempty"`
File *SceneFile `json:"file,omitempty"`
Cover string `json:"cover,omitempty"`
CreatedAt models.JSONTime `json:"created_at,omitempty"`
UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
}

View File

@@ -25,6 +25,11 @@ type singleton struct {
var instance *singleton
var once sync.Once
type flagStruct struct {
configFilePath string
}
var flags = flagStruct{}
func GetInstance() *singleton {
Initialize()
return instance
@@ -33,9 +38,9 @@ func GetInstance() *singleton {
func Initialize() *singleton {
once.Do(func() {
_ = utils.EnsureDir(paths.GetConfigDirectory())
initFlags()
initConfig()
initLog()
initFlags()
initEnvs()
instance = &singleton{
Status: TaskStatus{Status: Idle, Progress: -1},
@@ -43,7 +48,7 @@ func Initialize() *singleton {
JSON: &jsonUtils{},
}
instance.refreshConfig()
instance.RefreshConfig()
initFFMPEG()
})
@@ -55,8 +60,11 @@ func initConfig() {
// The config file is called config. Leave off the file extension.
viper.SetConfigName("config")
viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory
if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists {
viper.SetConfigFile(flags.configFilePath)
}
viper.AddConfigPath(".") // Look for config in the working directory
viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory
err := viper.ReadInConfig() // Find and read the config file
if err != nil { // Handle errors reading the config file
@@ -65,12 +73,16 @@ func initConfig() {
panic(err)
}
}
logger.Infof("using config file: %s", viper.ConfigFileUsed())
viper.SetDefault(config.Database, paths.GetDefaultDatabaseFilePath())
// Set generated to the metadata path for backwards compat
viper.SetDefault(config.Generated, viper.GetString(config.Metadata))
// Set default scrapers path
viper.SetDefault(config.ScrapersPath, config.GetDefaultScrapersPath())
// Disabling config watching due to race condition issue
// See: https://github.com/spf13/viper/issues/174
// Changes to the config outside the system will require a restart
@@ -88,6 +100,7 @@ func initConfig() {
func initFlags() {
pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host")
pflag.Int("port", 9999, "port to serve from")
pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use")
pflag.Parse()
if err := viper.BindPFlags(pflag.CommandLine); err != nil {
@@ -131,7 +144,7 @@ func initLog() {
logger.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel())
}
func (s *singleton) refreshConfig() {
func (s *singleton) RefreshConfig() {
s.Paths = paths.NewPaths()
if config.IsValid() {
_ = utils.EnsureDir(s.Paths.Generated.Screenshots)

View File

@@ -1,15 +1,15 @@
package manager
import (
"path/filepath"
"sync"
"time"
"github.com/bmatcuk/doublestar"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
"path/filepath"
"strconv"
"sync"
"time"
)
type TaskStatus struct {
@@ -17,6 +17,8 @@ type TaskStatus struct {
Progress float64
LastUpdate time.Time
stopping bool
upTo int
total int
}
func (t *TaskStatus) Stop() bool {
@@ -34,10 +36,16 @@ func (t *TaskStatus) setProgress(upTo int, total int) {
if total == 0 {
t.Progress = 1
}
t.upTo = upTo
t.total = total
t.Progress = float64(upTo) / float64(total)
t.updated()
}
func (t *TaskStatus) incrementProgress() {
t.setProgress(t.upTo+1, t.total)
}
func (t *TaskStatus) indefiniteProgress() {
t.Progress = -1
t.updated()
@@ -47,7 +55,7 @@ func (t *TaskStatus) updated() {
t.LastUpdate = time.Now()
}
func (s *singleton) Scan(nameFromMetadata bool) {
func (s *singleton) Scan(useFileMetadata bool) {
if s.Status.Status != Idle {
return
}
@@ -69,6 +77,7 @@ func (s *singleton) Scan(nameFromMetadata bool) {
return
}
results, _ = excludeFiles(results, config.GetExcludes())
total := len(results)
logger.Infof("Starting scan of %d files. %d New files found", total, s.neededScan(results))
@@ -81,7 +90,7 @@ func (s *singleton) Scan(nameFromMetadata bool) {
return
}
wg.Add(1)
task := ScanTask{FilePath: path, NameFromMetadata: nameFromMetadata}
task := ScanTask{FilePath: path, UseFileMetadata: useFileMetadata}
go task.Start(&wg)
wg.Wait()
}
@@ -202,6 +211,172 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
}()
}
func (s *singleton) AutoTag(performerIds []string, studioIds []string, tagIds []string) {
if s.Status.Status != Idle {
return
}
s.Status.SetStatus(AutoTag)
s.Status.indefiniteProgress()
go func() {
defer s.returnToIdleState()
// calculate work load
performerCount := len(performerIds)
studioCount := len(studioIds)
tagCount := len(tagIds)
performerQuery := models.NewPerformerQueryBuilder()
studioQuery := models.NewTagQueryBuilder()
tagQuery := models.NewTagQueryBuilder()
const wildcard = "*"
var err error
if performerCount == 1 && performerIds[0] == wildcard {
performerCount, err = performerQuery.Count()
if err != nil {
logger.Errorf("Error getting performer count: %s", err.Error())
}
}
if studioCount == 1 && studioIds[0] == wildcard {
studioCount, err = studioQuery.Count()
if err != nil {
logger.Errorf("Error getting studio count: %s", err.Error())
}
}
if tagCount == 1 && tagIds[0] == wildcard {
tagCount, err = tagQuery.Count()
if err != nil {
logger.Errorf("Error getting tag count: %s", err.Error())
}
}
total := performerCount + studioCount + tagCount
s.Status.setProgress(0, total)
s.autoTagPerformers(performerIds)
s.autoTagStudios(studioIds)
s.autoTagTags(tagIds)
}()
}
func (s *singleton) autoTagPerformers(performerIds []string) {
performerQuery := models.NewPerformerQueryBuilder()
var wg sync.WaitGroup
for _, performerId := range performerIds {
var performers []*models.Performer
if performerId == "*" {
var err error
performers, err = performerQuery.All()
if err != nil {
logger.Errorf("Error querying performers: %s", err.Error())
continue
}
} else {
performerIdInt, err := strconv.Atoi(performerId)
if err != nil {
logger.Errorf("Error parsing performer id %s: %s", performerId, err.Error())
continue
}
performer, err := performerQuery.Find(performerIdInt)
if err != nil {
logger.Errorf("Error finding performer id %s: %s", performerId, err.Error())
continue
}
performers = append(performers, performer)
}
for _, performer := range performers {
wg.Add(1)
task := AutoTagPerformerTask{performer: performer}
go task.Start(&wg)
wg.Wait()
s.Status.incrementProgress()
}
}
}
func (s *singleton) autoTagStudios(studioIds []string) {
studioQuery := models.NewStudioQueryBuilder()
var wg sync.WaitGroup
for _, studioId := range studioIds {
var studios []*models.Studio
if studioId == "*" {
var err error
studios, err = studioQuery.All()
if err != nil {
logger.Errorf("Error querying studios: %s", err.Error())
continue
}
} else {
studioIdInt, err := strconv.Atoi(studioId)
if err != nil {
logger.Errorf("Error parsing studio id %s: %s", studioId, err.Error())
continue
}
studio, err := studioQuery.Find(studioIdInt, nil)
if err != nil {
logger.Errorf("Error finding studio id %s: %s", studioId, err.Error())
continue
}
studios = append(studios, studio)
}
for _, studio := range studios {
wg.Add(1)
task := AutoTagStudioTask{studio: studio}
go task.Start(&wg)
wg.Wait()
s.Status.incrementProgress()
}
}
}
func (s *singleton) autoTagTags(tagIds []string) {
tagQuery := models.NewTagQueryBuilder()
var wg sync.WaitGroup
for _, tagId := range tagIds {
var tags []*models.Tag
if tagId == "*" {
var err error
tags, err = tagQuery.All()
if err != nil {
logger.Errorf("Error querying tags: %s", err.Error())
continue
}
} else {
tagIdInt, err := strconv.Atoi(tagId)
if err != nil {
logger.Errorf("Error parsing tag id %s: %s", tagId, err.Error())
continue
}
tag, err := tagQuery.Find(tagIdInt, nil)
if err != nil {
logger.Errorf("Error finding tag id %s: %s", tagId, err.Error())
continue
}
tags = append(tags, tag)
}
for _, tag := range tags {
wg.Add(1)
task := AutoTagTagTask{tag: tag}
go task.Start(&wg)
wg.Wait()
s.Status.incrementProgress()
}
}
}
func (s *singleton) Clean() {
if s.Status.Status != Idle {
return
@@ -236,7 +411,7 @@ func (s *singleton) Clean() {
}
if scene == nil {
logger.Errorf("nil scene, skipping generate")
logger.Errorf("nil scene, skipping Clean")
continue
}

View File

@@ -0,0 +1,61 @@
package manager
import (
"bytes"
"image"
"image/jpeg"
"os"
"github.com/disintegration/imaging"
// needed to decode other image formats
_ "image/gif"
_ "image/png"
)
func writeImage(path string, imageData []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(imageData)
return err
}
func writeThumbnail(path string, thumbnail image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return jpeg.Encode(f, thumbnail, nil)
}
func SetSceneScreenshot(checksum string, imageData []byte) error {
thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum)
normalPath := instance.Paths.Scene.GetScreenshotPath(checksum)
img, _, err := image.Decode(bytes.NewReader(imageData))
if err != nil {
return err
}
// resize to 320 width maintaining aspect ratio, for the thumbnail
const width = 320
origWidth := img.Bounds().Max.X
origHeight := img.Bounds().Max.Y
height := width / origWidth * origHeight
thumbnail := imaging.Resize(img, width, height, imaging.Lanczos)
err = writeThumbnail(thumbPath, thumbnail)
if err != nil {
return err
}
err = writeImage(normalPath, imageData)
return err
}

171
pkg/manager/task_autotag.go Normal file
View File

@@ -0,0 +1,171 @@
package manager
import (
"context"
"database/sql"
"strings"
"sync"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type AutoTagPerformerTask struct {
performer *models.Performer
}
func (t *AutoTagPerformerTask) Start(wg *sync.WaitGroup) {
defer wg.Done()
t.autoTagPerformer()
}
func getQueryRegex(name string) string {
const separatorChars = `.\-_ `
// handle path separators
const endSeparatorChars = separatorChars + `\\/`
const separator = `[` + separatorChars + `]`
const endSeparator = `[` + endSeparatorChars + `]`
ret := strings.Replace(name, " ", separator+"*", -1)
ret = "(?:^|" + endSeparator + "+)" + ret + "(?:$|" + endSeparator + "+)"
return ret
}
func (t *AutoTagPerformerTask) autoTagPerformer() {
qb := models.NewSceneQueryBuilder()
jqb := models.NewJoinsQueryBuilder()
regex := getQueryRegex(t.performer.Name.String)
scenes, err := qb.QueryAllByPathRegex(regex)
if err != nil {
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())
return
}
ctx := context.TODO()
tx := database.DB.MustBeginTx(ctx, nil)
for _, scene := range scenes {
added, err := jqb.AddPerformerScene(scene.ID, t.performer.ID, tx)
if err != nil {
logger.Infof("Error adding performer '%s' to scene '%s': %s", t.performer.Name.String, scene.GetTitle(), err.Error())
tx.Rollback()
return
}
if added {
logger.Infof("Added performer '%s' to scene '%s'", t.performer.Name.String, scene.GetTitle())
}
}
if err := tx.Commit(); err != nil {
logger.Infof("Error adding performer to scene: %s", err.Error())
return
}
}
type AutoTagStudioTask struct {
studio *models.Studio
}
func (t *AutoTagStudioTask) Start(wg *sync.WaitGroup) {
defer wg.Done()
t.autoTagStudio()
}
func (t *AutoTagStudioTask) autoTagStudio() {
qb := models.NewSceneQueryBuilder()
regex := getQueryRegex(t.studio.Name.String)
scenes, err := qb.QueryAllByPathRegex(regex)
if err != nil {
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())
return
}
ctx := context.TODO()
tx := database.DB.MustBeginTx(ctx, nil)
for _, scene := range scenes {
if scene.StudioID.Int64 == int64(t.studio.ID) {
// don't modify
continue
}
logger.Infof("Adding studio '%s' to scene '%s'", t.studio.Name.String, scene.GetTitle())
// set the studio id
studioID := sql.NullInt64{Int64: int64(t.studio.ID), Valid: true}
scenePartial := models.ScenePartial{
ID: scene.ID,
StudioID: &studioID,
}
_, err := qb.Update(scenePartial, tx)
if err != nil {
logger.Infof("Error adding studio to scene: %s", err.Error())
tx.Rollback()
return
}
}
if err := tx.Commit(); err != nil {
logger.Infof("Error adding studio to scene: %s", err.Error())
return
}
}
type AutoTagTagTask struct {
tag *models.Tag
}
func (t *AutoTagTagTask) Start(wg *sync.WaitGroup) {
defer wg.Done()
t.autoTagTag()
}
func (t *AutoTagTagTask) autoTagTag() {
qb := models.NewSceneQueryBuilder()
jqb := models.NewJoinsQueryBuilder()
regex := getQueryRegex(t.tag.Name)
scenes, err := qb.QueryAllByPathRegex(regex)
if err != nil {
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())
return
}
ctx := context.TODO()
tx := database.DB.MustBeginTx(ctx, nil)
for _, scene := range scenes {
added, err := jqb.AddSceneTag(scene.ID, t.tag.ID, tx)
if err != nil {
logger.Infof("Error adding tag '%s' to scene '%s': %s", t.tag.Name, scene.GetTitle(), err.Error())
tx.Rollback()
return
}
if added {
logger.Infof("Added tag '%s' to scene '%s'", t.tag.Name, scene.GetTitle())
}
}
if err := tx.Commit(); err != nil {
logger.Infof("Error adding tag to scene: %s", err.Error())
return
}
}

View File

@@ -0,0 +1,339 @@
// +build integration
package manager
import (
"context"
"database/sql"
"fmt"
"io/ioutil"
"os"
"strings"
"sync"
"testing"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jmoiron/sqlx"
)
const testName = "Foo's Bar"
const testExtension = ".mp4"
var testSeparators = []string{
".",
"-",
"_",
" ",
}
func generateNamePatterns(name string, separator string) []string {
var ret []string
ret = append(ret, fmt.Sprintf("%s%saaa"+testExtension, name, separator))
ret = append(ret, fmt.Sprintf("aaa%s%s"+testExtension, separator, name))
ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb"+testExtension, separator, name, separator))
ret = append(ret, fmt.Sprintf("dir/%s%saaa"+testExtension, name, separator))
ret = append(ret, fmt.Sprintf("dir\\%s%saaa"+testExtension, name, separator))
ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb"+testExtension, name, separator))
ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb"+testExtension, name, separator))
ret = append(ret, fmt.Sprintf("dir/%s%s/aaa"+testExtension, name, separator))
ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa"+testExtension, name, separator))
return ret
}
func generateFalseNamePattern(name string, separator string) string {
splitted := strings.Split(name, " ")
return fmt.Sprintf("%s%saaa%s%s"+testExtension, splitted[0], separator, separator, splitted[1])
}
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(tx *sqlx.Tx) error {
// create the performer
pqb := models.NewPerformerQueryBuilder()
performer := models.Performer{
Image: []byte{0, 1, 2},
Checksum: testName,
Name: sql.NullString{Valid: true, String: testName},
Favorite: sql.NullBool{Valid: true, Bool: false},
}
_, err := pqb.Create(performer, tx)
if err != nil {
return err
}
return nil
}
func createStudio(tx *sqlx.Tx) error {
// create the studio
qb := models.NewStudioQueryBuilder()
studio := models.Studio{
Image: []byte{0, 1, 2},
Checksum: testName,
Name: sql.NullString{Valid: true, String: testName},
}
_, err := qb.Create(studio, tx)
if err != nil {
return err
}
return nil
}
func createTag(tx *sqlx.Tx) error {
// create the studio
qb := models.NewTagQueryBuilder()
tag := models.Tag{
Name: testName,
}
_, err := qb.Create(tag, tx)
if err != nil {
return err
}
return nil
}
func createScenes(tx *sqlx.Tx) error {
sqb := models.NewSceneQueryBuilder()
// create the scenes
var scenePatterns []string
var falseScenePatterns []string
for _, separator := range testSeparators {
scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...)
scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...)
if separator != " " {
scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator)...)
}
falseScenePatterns = append(falseScenePatterns, generateFalseNamePattern(testName, separator))
}
for _, fn := range scenePatterns {
err := createScene(sqb, tx, fn, true)
if err != nil {
return err
}
}
for _, fn := range falseScenePatterns {
err := createScene(sqb, tx, fn, false)
if err != nil {
return err
}
}
return nil
}
func createScene(sqb models.SceneQueryBuilder, tx *sqlx.Tx, name string, expectedResult bool) error {
scene := models.Scene{
Checksum: utils.MD5FromString(name),
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}
}
_, err := sqb.Create(scene, tx)
if err != nil {
return fmt.Errorf("Failed to create scene with name '%s': %s", name, err.Error())
}
return nil
}
func populateDB() error {
ctx := context.TODO()
tx := database.DB.MustBeginTx(ctx, nil)
err := createPerformer(tx)
if err != nil {
return err
}
err = createStudio(tx)
if err != nil {
return err
}
err = createTag(tx)
if err != nil {
return err
}
err = createScenes(tx)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func TestParsePerformers(t *testing.T) {
pqb := models.NewPerformerQueryBuilder()
performers, err := pqb.All()
if err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
task := AutoTagPerformerTask{
performer: performers[0],
}
var wg sync.WaitGroup
wg.Add(1)
task.Start(&wg)
// verify that scenes were tagged correctly
sqb := models.NewSceneQueryBuilder()
scenes, err := sqb.All()
for _, scene := range scenes {
performers, err := pqb.FindBySceneID(scene.ID, nil)
if err != nil {
t.Errorf("Error getting scene performers: %s", err.Error())
return
}
// 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)
}
}
}
func TestParseStudios(t *testing.T) {
studioQuery := models.NewStudioQueryBuilder()
studios, err := studioQuery.All()
if err != nil {
t.Errorf("Error getting studio: %s", err)
return
}
task := AutoTagStudioTask{
studio: studios[0],
}
var wg sync.WaitGroup
wg.Add(1)
task.Start(&wg)
// verify that scenes were tagged correctly
sqb := models.NewSceneQueryBuilder()
scenes, err := sqb.All()
for _, scene := range scenes {
// title is only set on scenes where we expect studio to be set
if scene.Title.String == scene.Path && scene.StudioID.Int64 != int64(studios[0].ID) {
t.Errorf("Did not set studio '%s' for path '%s'", testName, scene.Path)
} else if scene.Title.String != scene.Path && scene.StudioID.Int64 == int64(studios[0].ID) {
t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, scene.Path)
}
}
}
func TestParseTags(t *testing.T) {
tagQuery := models.NewTagQueryBuilder()
tags, err := tagQuery.All()
if err != nil {
t.Errorf("Error getting performer: %s", err)
return
}
task := AutoTagTagTask{
tag: tags[0],
}
var wg sync.WaitGroup
wg.Add(1)
task.Start(&wg)
// verify that scenes were tagged correctly
sqb := models.NewSceneQueryBuilder()
scenes, err := sqb.All()
for _, scene := range scenes {
tags, err := tagQuery.FindBySceneID(scene.ID, nil)
if err != nil {
t.Errorf("Error getting scene tags: %s", err.Error())
return
}
// 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)
}
}
}

View File

@@ -21,8 +21,12 @@ func (t *CleanTask) Start(wg *sync.WaitGroup) {
if t.fileExists(t.Scene.Path) && t.pathInStash() {
logger.Debugf("File Found: %s", t.Scene.Path)
if matchFile(t.Scene.Path, config.GetExcludes()) {
logger.Infof("File matched regex. Cleaning: \"%s\"", t.Scene.Path)
t.deleteScene(t.Scene.ID)
}
} else {
logger.Infof("File not found. Cleaning: %s", t.Scene.Path)
logger.Infof("File not found. Cleaning: \"%s\"", t.Scene.Path)
t.deleteScene(t.Scene.ID)
}
}

View File

@@ -161,6 +161,10 @@ func (t *ExportTask) ExportScenes(ctx context.Context) {
newSceneJSON.File.Bitrate = int(scene.Bitrate.Int64)
}
if len(scene.Cover) > 0 {
newSceneJSON.Cover = utils.GetBase64StringFromData(scene.Cover)
}
sceneJSON, err := instance.JSON.getScene(scene.Checksum)
if err != nil {
logger.Debugf("[scenes] error reading scene json: %s", err.Error())

View File

@@ -3,6 +3,10 @@ package manager
import (
"context"
"database/sql"
"strconv"
"sync"
"time"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
@@ -10,9 +14,6 @@ import (
"github.com/stashapp/stash/pkg/manager/jsonschema"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
"strconv"
"sync"
"time"
)
type ImportTask struct {
@@ -34,7 +35,12 @@ func (t *ImportTask) Start(wg *sync.WaitGroup) {
}
t.Scraped = scraped
database.Reset(config.GetDatabasePath())
err := database.Reset(config.GetDatabasePath())
if err != nil {
logger.Errorf("Error resetting database: %s", err.Error())
return
}
ctx := context.TODO()
@@ -64,9 +70,6 @@ func (t *ImportTask) ImportPerformers(ctx context.Context) {
logger.Progressf("[performers] %d of %d", index, len(t.Mappings.Performers))
// generate checksum from performer name rather than image
checksum := utils.MD5FromString(performerJSON.Name)
// Process the base 64 encoded image string
_, imageData, err := utils.ProcessBase64Image(performerJSON.Image)
if err != nil {
@@ -75,6 +78,8 @@ func (t *ImportTask) ImportPerformers(ctx context.Context) {
return
}
checksum := utils.MD5FromBytes(imageData)
// Populate a new performer from the input
newPerformer := models.Performer{
Image: imageData,
@@ -162,9 +167,6 @@ func (t *ImportTask) ImportStudios(ctx context.Context) {
logger.Progressf("[studios] %d of %d", index, len(t.Mappings.Studios))
// generate checksum from studio name rather than image
checksum := utils.MD5FromString(studioJSON.Name)
// Process the base 64 encoded image string
_, imageData, err := utils.ProcessBase64Image(studioJSON.Image)
if err != nil {
@@ -173,6 +175,8 @@ func (t *ImportTask) ImportStudios(ctx context.Context) {
return
}
checksum := utils.MD5FromBytes(imageData)
// Populate a new studio from the input
newStudio := models.Studio{
Image: imageData,
@@ -376,6 +380,21 @@ func (t *ImportTask) ImportScenes(ctx context.Context) {
continue
}
// Process the base 64 encoded cover image string
if sceneJSON.Cover != "" {
_, coverImageData, err := utils.ProcessBase64Image(sceneJSON.Cover)
if err != nil {
logger.Warnf("[scenes] <%s> invalid cover image: %s", mappingJSON.Checksum, err.Error())
}
if len(coverImageData) > 0 {
if err = SetSceneScreenshot(mappingJSON.Checksum, coverImageData); err != nil {
logger.Warnf("[scenes] <%s> failed to create cover image: %s", mappingJSON.Checksum, err.Error())
} else {
newScene.Cover = coverImageData
}
}
}
// Populate scene fields
if sceneJSON != nil {
if sceneJSON.Title != "" {

View File

@@ -16,8 +16,8 @@ import (
)
type ScanTask struct {
FilePath string
NameFromMetadata bool
FilePath string
UseFileMetadata bool
}
func (t *ScanTask) Start(wg *sync.WaitGroup) {
@@ -92,8 +92,8 @@ func (t *ScanTask) scanScene() {
return
}
// Override title to be filename if nameFromMetadata is false
if !t.NameFromMetadata {
// Override title to be filename if UseFileMetadata is false
if !t.UseFileMetadata {
videoFile.SetTitleFromPath()
}
@@ -127,8 +127,6 @@ func (t *ScanTask) scanScene() {
Checksum: checksum,
Path: t.FilePath,
Title: sql.NullString{String: videoFile.Title, Valid: true},
Details: sql.NullString{String: videoFile.Comment, Valid: true},
Date: models.SQLiteDate{String: videoFile.CreationTime.Format("2006-01-02")},
Duration: sql.NullFloat64{Float64: videoFile.Duration, Valid: true},
VideoCodec: sql.NullString{String: videoFile.VideoCodec, Valid: true},
AudioCodec: sql.NullString{String: videoFile.AudioCodec, Valid: true},
@@ -140,6 +138,11 @@ func (t *ScanTask) scanScene() {
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
}
if t.UseFileMetadata {
newScene.Details = sql.NullString{String: videoFile.Comment, Valid: true}
newScene.Date = models.SQLiteDate{String: videoFile.CreationTime.Format("2006-01-02")}
}
_, err = qb.Create(newScene, tx)
}

View File

@@ -2,12 +2,14 @@ package models
import (
"database/sql"
"path/filepath"
)
type Scene struct {
ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"`
Path string `db:"path" json:"path"`
Cover []byte `db:"cover" json:"cover"`
Title sql.NullString `db:"title" json:"title"`
Details sql.NullString `db:"details" json:"details"`
URL sql.NullString `db:"url" json:"url"`
@@ -27,9 +29,10 @@ type Scene struct {
}
type ScenePartial struct {
ID int `db:"id" json:"id"`
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Path *string `db:"path" json:"path"`
Cover *[]byte `db:"cover" json:"cover"`
Title *sql.NullString `db:"title" json:"title"`
Details *sql.NullString `db:"details" json:"details"`
URL *sql.NullString `db:"url" json:"url"`
@@ -47,3 +50,22 @@ type ScenePartial struct {
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
func (s Scene) GetTitle() string {
if s.Title.String != "" {
return s.Title.String
}
return filepath.Base(s.Path)
}
type SceneFileType struct {
Size *string `graphql:"size" json:"size"`
Duration *float64 `graphql:"duration" json:"duration"`
VideoCodec *string `graphql:"video_codec" json:"video_codec"`
AudioCodec *string `graphql:"audio_codec" json:"audio_codec"`
Width *int `graphql:"width" json:"width"`
Height *int `graphql:"height" json:"height"`
Framerate *float64 `graphql:"framerate" json:"framerate"`
Bitrate *int `graphql:"bitrate" json:"bitrate"`
}

View File

@@ -22,3 +22,65 @@ type ScrapedItem struct {
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
type ScrapedPerformer struct {
Name *string `graphql:"name" json:"name"`
URL *string `graphql:"url" json:"url"`
Twitter *string `graphql:"twitter" json:"twitter"`
Instagram *string `graphql:"instagram" json:"instagram"`
Birthdate *string `graphql:"birthdate" json:"birthdate"`
Ethnicity *string `graphql:"ethnicity" json:"ethnicity"`
Country *string `graphql:"country" json:"country"`
EyeColor *string `graphql:"eye_color" json:"eye_color"`
Height *string `graphql:"height" json:"height"`
Measurements *string `graphql:"measurements" json:"measurements"`
FakeTits *string `graphql:"fake_tits" json:"fake_tits"`
CareerLength *string `graphql:"career_length" json:"career_length"`
Tattoos *string `graphql:"tattoos" json:"tattoos"`
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
}
type ScrapedScene struct {
Title *string `graphql:"title" json:"title"`
Details *string `graphql:"details" json:"details"`
URL *string `graphql:"url" json:"url"`
Date *string `graphql:"date" json:"date"`
File *SceneFileType `graphql:"file" json:"file"`
Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
Performers []*ScrapedScenePerformer `graphql:"performers" json:"performers"`
}
type ScrapedScenePerformer struct {
// Set if performer matched
ID *string `graphql:"id" json:"id"`
Name string `graphql:"name" json:"name"`
URL *string `graphql:"url" json:"url"`
Twitter *string `graphql:"twitter" json:"twitter"`
Instagram *string `graphql:"instagram" json:"instagram"`
Birthdate *string `graphql:"birthdate" json:"birthdate"`
Ethnicity *string `graphql:"ethnicity" json:"ethnicity"`
Country *string `graphql:"country" json:"country"`
EyeColor *string `graphql:"eye_color" json:"eye_color"`
Height *string `graphql:"height" json:"height"`
Measurements *string `graphql:"measurements" json:"measurements"`
FakeTits *string `graphql:"fake_tits" json:"fake_tits"`
CareerLength *string `graphql:"career_length" json:"career_length"`
Tattoos *string `graphql:"tattoos" json:"tattoos"`
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
}
type ScrapedSceneStudio struct {
// Set if studio matched
ID *string `graphql:"id" json:"id"`
Name string `graphql:"name" json:"name"`
URL *string `graphql:"url" json:"url"`
}
type ScrapedSceneTag struct {
// Set if tag matched
ID *string `graphql:"id" json:"id"`
Name string `graphql:"name" json:"name"`
}

View File

@@ -1,6 +1,11 @@
package models
import "github.com/jmoiron/sqlx"
import (
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
)
type JoinsQueryBuilder struct{}
@@ -8,6 +13,41 @@ func NewJoinsQueryBuilder() JoinsQueryBuilder {
return JoinsQueryBuilder{}
}
func (qb *JoinsQueryBuilder) GetScenePerformers(sceneID int, tx *sqlx.Tx) ([]PerformersScenes, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from performers_scenes WHERE scene_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, sceneID)
} else {
rows, err = database.DB.Queryx(query, sceneID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
performerScenes := make([]PerformersScenes, 0)
for rows.Next() {
performerScene := PerformersScenes{}
if err := rows.StructScan(&performerScene); err != nil {
return nil, err
}
performerScenes = append(performerScenes, performerScene)
}
if err := rows.Err(); err != nil {
return nil, err
}
return performerScenes, nil
}
func (qb *JoinsQueryBuilder) CreatePerformersScenes(newJoins []PerformersScenes, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
@@ -22,6 +62,36 @@ func (qb *JoinsQueryBuilder) CreatePerformersScenes(newJoins []PerformersScenes,
return nil
}
// AddPerformerScene adds a performer to a scene. It does not make any change
// if the performer already exists on the scene. It returns true if scene
// performer was added.
func (qb *JoinsQueryBuilder) AddPerformerScene(sceneID int, performerID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingPerformers, err := qb.GetScenePerformers(sceneID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingPerformers {
if p.PerformerID == performerID && p.SceneID == sceneID {
return false, nil
}
}
performerJoin := PerformersScenes{
PerformerID: performerID,
SceneID: sceneID,
}
performerJoins := append(existingPerformers, performerJoin)
err = qb.UpdatePerformersScenes(sceneID, performerJoins, tx)
return err == nil, err
}
func (qb *JoinsQueryBuilder) UpdatePerformersScenes(sceneID int, updatedJoins []PerformersScenes, tx *sqlx.Tx) error {
ensureTx(tx)
@@ -41,6 +111,41 @@ func (qb *JoinsQueryBuilder) DestroyPerformersScenes(sceneID int, tx *sqlx.Tx) e
return err
}
func (qb *JoinsQueryBuilder) GetSceneTags(sceneID int, tx *sqlx.Tx) ([]ScenesTags, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from scenes_tags WHERE scene_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, sceneID)
} else {
rows, err = database.DB.Queryx(query, sceneID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
sceneTags := make([]ScenesTags, 0)
for rows.Next() {
sceneTag := ScenesTags{}
if err := rows.StructScan(&sceneTag); err != nil {
return nil, err
}
sceneTags = append(sceneTags, sceneTag)
}
if err := rows.Err(); err != nil {
return nil, err
}
return sceneTags, nil
}
func (qb *JoinsQueryBuilder) CreateScenesTags(newJoins []ScenesTags, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
@@ -66,6 +171,35 @@ func (qb *JoinsQueryBuilder) UpdateScenesTags(sceneID int, updatedJoins []Scenes
return qb.CreateScenesTags(updatedJoins, tx)
}
// AddSceneTag adds a tag to a scene. It does not make any change if the tag
// already exists on the scene. It returns true if scene tag was added.
func (qb *JoinsQueryBuilder) AddSceneTag(sceneID int, tagID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingTags, err := qb.GetSceneTags(sceneID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingTags {
if p.TagID == tagID && p.SceneID == sceneID {
return false, nil
}
}
tagJoin := ScenesTags{
TagID: tagID,
SceneID: sceneID,
}
tagJoins := append(existingTags, tagJoin)
err = qb.UpdateScenesTags(sceneID, tagJoins, tx)
return err == nil, err
}
func (qb *JoinsQueryBuilder) DestroyScenesTags(sceneID int, tx *sqlx.Tx) error {
ensureTx(tx)

View File

@@ -42,9 +42,11 @@ func (qb *SceneQueryBuilder) Create(newScene Scene, tx *sqlx.Tx) (*Scene, error)
ensureTx(tx)
result, err := tx.NamedExec(
`INSERT INTO scenes (checksum, path, title, details, url, date, rating, size, duration, video_codec,
audio_codec, width, height, framerate, bitrate, studio_id, created_at, updated_at)
audio_codec, width, height, framerate, bitrate, studio_id, cover,
created_at, updated_at)
VALUES (:checksum, :path, :title, :details, :url, :date, :rating, :size, :duration, :video_codec,
:audio_codec, :width, :height, :framerate, :bitrate, :studio_id, :created_at, :updated_at)
:audio_codec, :width, :height, :framerate, :bitrate, :studio_id, :cover,
:created_at, :updated_at)
`,
newScene,
)
@@ -291,6 +293,32 @@ func getMultiCriterionClause(table string, joinTable string, joinTableField stri
return whereClause, havingClause
}
func (qb *SceneQueryBuilder) QueryAllByPathRegex(regex string) ([]*Scene, error) {
var args []interface{}
body := selectDistinctIDs("scenes") + " WHERE scenes.path regexp ?"
args = append(args, "(?i)"+regex)
idsResult, err := runIdsQuery(body, args)
if err != nil {
return nil, err
}
var scenes []*Scene
for _, id := range idsResult {
scene, err := qb.Find(id)
if err != nil {
return nil, err
}
scenes = append(scenes, scene)
}
return scenes, nil
}
func (qb *SceneQueryBuilder) QueryByPathRegex(findFilter *FindFilterType) ([]*Scene, int) {
if findFilter == nil {
findFilter = &FindFilterType{}
@@ -302,7 +330,8 @@ func (qb *SceneQueryBuilder) QueryByPathRegex(findFilter *FindFilterType) ([]*Sc
body := selectDistinctIDs("scenes")
if q := findFilter.Q; q != nil && *q != "" {
whereClauses = append(whereClauses, "scenes.path regexp '(?i)"+*q+"'")
whereClauses = append(whereClauses, "scenes.path regexp ?")
args = append(args, "(?i)"+*q)
}
sortAndPagination := qb.getSceneSort(findFilter) + getPagination(findFilter)

View File

@@ -32,6 +32,9 @@ func (t *SQLiteDate) Scan(value interface{}) error {
// Value implements the driver Valuer interface.
func (t SQLiteDate) Value() (driver.Value, error) {
if !t.Valid {
return nil, nil
}
result, err := utils.ParseDateStringAsFormat(t.String, "2006-01-02")
if err != nil {
logger.Debugf("sqlite date conversion error: %s", err.Error())

326
pkg/scraper/config.go Normal file
View File

@@ -0,0 +1,326 @@
package scraper
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
"github.com/stashapp/stash/pkg/models"
)
type stashServer struct {
URL string `yaml:"url"`
}
type scraperAction string
const (
scraperActionScript scraperAction = "script"
scraperActionStash scraperAction = "stash"
scraperActionXPath scraperAction = "scrapeXPath"
)
var allScraperAction = []scraperAction{
scraperActionScript,
scraperActionStash,
scraperActionXPath,
}
func (e scraperAction) IsValid() bool {
switch e {
case scraperActionScript, scraperActionStash, scraperActionXPath:
return true
}
return false
}
type scraperTypeConfig struct {
Action scraperAction `yaml:"action"`
Script []string `yaml:"script,flow"`
Scraper string `yaml:"scraper"`
scraperConfig *scraperConfig
}
type scrapePerformerNamesFunc func(c scraperTypeConfig, name string) ([]*models.ScrapedPerformer, error)
type performerByNameConfig struct {
scraperTypeConfig `yaml:",inline"`
performScrape scrapePerformerNamesFunc
}
func (c *performerByNameConfig) resolveFn() {
if c.Action == scraperActionScript {
c.performScrape = scrapePerformerNamesScript
} else if c.Action == scraperActionStash {
c.performScrape = scrapePerformerNamesStash
}
}
type scrapePerformerFragmentFunc func(c scraperTypeConfig, scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error)
type performerByFragmentConfig struct {
scraperTypeConfig `yaml:",inline"`
performScrape scrapePerformerFragmentFunc
}
func (c *performerByFragmentConfig) resolveFn() {
if c.Action == scraperActionScript {
c.performScrape = scrapePerformerFragmentScript
} else if c.Action == scraperActionStash {
c.performScrape = scrapePerformerFragmentStash
}
}
type scrapeByURLConfig struct {
scraperTypeConfig `yaml:",inline"`
URL []string `yaml:"url,flow"`
}
func (c scrapeByURLConfig) matchesURL(url string) bool {
for _, thisURL := range c.URL {
if strings.Contains(url, thisURL) {
return true
}
}
return false
}
type scrapePerformerByURLFunc func(c scraperTypeConfig, url string) (*models.ScrapedPerformer, error)
type scrapePerformerByURLConfig struct {
scrapeByURLConfig `yaml:",inline"`
performScrape scrapePerformerByURLFunc
}
func (c *scrapePerformerByURLConfig) resolveFn() {
if c.Action == scraperActionScript {
c.performScrape = scrapePerformerURLScript
} else if c.Action == scraperActionXPath {
c.performScrape = scrapePerformerURLXpath
}
}
type scrapeSceneFragmentFunc func(c scraperTypeConfig, scene models.SceneUpdateInput) (*models.ScrapedScene, error)
type sceneByFragmentConfig struct {
scraperTypeConfig `yaml:",inline"`
performScrape scrapeSceneFragmentFunc
}
func (c *sceneByFragmentConfig) resolveFn() {
if c.Action == scraperActionScript {
c.performScrape = scrapeSceneFragmentScript
} else if c.Action == scraperActionStash {
c.performScrape = scrapeSceneFragmentStash
}
}
type scrapeSceneByURLFunc func(c scraperTypeConfig, url string) (*models.ScrapedScene, error)
type scrapeSceneByURLConfig struct {
scrapeByURLConfig `yaml:",inline"`
performScrape scrapeSceneByURLFunc
}
func (c *scrapeSceneByURLConfig) resolveFn() {
if c.Action == scraperActionScript {
c.performScrape = scrapeSceneURLScript
} else if c.Action == scraperActionXPath {
c.performScrape = scrapeSceneURLXPath
}
}
type scraperConfig struct {
ID string
Name string `yaml:"name"`
PerformerByName *performerByNameConfig `yaml:"performerByName"`
PerformerByFragment *performerByFragmentConfig `yaml:"performerByFragment"`
PerformerByURL []*scrapePerformerByURLConfig `yaml:"performerByURL"`
SceneByFragment *sceneByFragmentConfig `yaml:"sceneByFragment"`
SceneByURL []*scrapeSceneByURLConfig `yaml:"sceneByURL"`
StashServer *stashServer `yaml:"stashServer"`
XPathScrapers xpathScrapers `yaml:"xPathScrapers"`
}
func loadScraperFromYAML(path string) (*scraperConfig, error) {
ret := &scraperConfig{}
file, err := os.Open(path)
defer file.Close()
if err != nil {
return nil, err
}
parser := yaml.NewDecoder(file)
parser.SetStrict(true)
err = parser.Decode(&ret)
if err != nil {
return nil, err
}
// set id to the filename
id := filepath.Base(path)
id = id[:strings.LastIndex(id, ".")]
ret.ID = id
// set the scraper interface
ret.initialiseConfigs()
return ret, nil
}
func (c *scraperConfig) initialiseConfigs() {
if c.PerformerByName != nil {
c.PerformerByName.resolveFn()
c.PerformerByName.scraperConfig = c
}
if c.PerformerByFragment != nil {
c.PerformerByFragment.resolveFn()
c.PerformerByFragment.scraperConfig = c
}
for _, s := range c.PerformerByURL {
s.resolveFn()
s.scraperConfig = c
}
if c.SceneByFragment != nil {
c.SceneByFragment.resolveFn()
c.SceneByFragment.scraperConfig = c
}
for _, s := range c.SceneByURL {
s.resolveFn()
s.scraperConfig = c
}
}
func (c scraperConfig) toScraper() *models.Scraper {
ret := models.Scraper{
ID: c.ID,
Name: c.Name,
}
performer := models.ScraperSpec{}
if c.PerformerByName != nil {
performer.SupportedScrapes = append(performer.SupportedScrapes, models.ScrapeTypeName)
}
if c.PerformerByFragment != nil {
performer.SupportedScrapes = append(performer.SupportedScrapes, models.ScrapeTypeFragment)
}
if len(c.PerformerByURL) > 0 {
performer.SupportedScrapes = append(performer.SupportedScrapes, models.ScrapeTypeURL)
for _, v := range c.PerformerByURL {
performer.Urls = append(performer.Urls, v.URL...)
}
}
if len(performer.SupportedScrapes) > 0 {
ret.Performer = &performer
}
scene := models.ScraperSpec{}
if c.SceneByFragment != nil {
scene.SupportedScrapes = append(scene.SupportedScrapes, models.ScrapeTypeFragment)
}
if len(c.SceneByURL) > 0 {
scene.SupportedScrapes = append(scene.SupportedScrapes, models.ScrapeTypeURL)
for _, v := range c.SceneByURL {
scene.Urls = append(scene.Urls, v.URL...)
}
}
if len(scene.SupportedScrapes) > 0 {
ret.Scene = &scene
}
return &ret
}
func (c scraperConfig) supportsPerformers() bool {
return c.PerformerByName != nil || c.PerformerByFragment != nil || len(c.PerformerByURL) > 0
}
func (c scraperConfig) matchesPerformerURL(url string) bool {
for _, scraper := range c.PerformerByURL {
if scraper.matchesURL(url) {
return true
}
}
return false
}
func (c scraperConfig) ScrapePerformerNames(name string) ([]*models.ScrapedPerformer, error) {
if c.PerformerByName != nil && c.PerformerByName.performScrape != nil {
return c.PerformerByName.performScrape(c.PerformerByName.scraperTypeConfig, name)
}
return nil, nil
}
func (c scraperConfig) ScrapePerformer(scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error) {
if c.PerformerByFragment != nil && c.PerformerByFragment.performScrape != nil {
return c.PerformerByFragment.performScrape(c.PerformerByFragment.scraperTypeConfig, scrapedPerformer)
}
return nil, nil
}
func (c scraperConfig) ScrapePerformerURL(url string) (*models.ScrapedPerformer, error) {
for _, scraper := range c.PerformerByURL {
if scraper.matchesURL(url) && scraper.performScrape != nil {
ret, err := scraper.performScrape(scraper.scraperTypeConfig, url)
if err != nil {
return nil, err
}
if ret != nil {
return ret, nil
}
}
}
return nil, nil
}
func (c scraperConfig) supportsScenes() bool {
return c.SceneByFragment != nil || len(c.SceneByURL) > 0
}
func (c scraperConfig) matchesSceneURL(url string) bool {
for _, scraper := range c.SceneByURL {
if scraper.matchesURL(url) {
return true
}
}
return false
}
func (c scraperConfig) ScrapeScene(scene models.SceneUpdateInput) (*models.ScrapedScene, error) {
if c.SceneByFragment != nil && c.SceneByFragment.performScrape != nil {
return c.SceneByFragment.performScrape(c.SceneByFragment.scraperTypeConfig, scene)
}
return nil, nil
}
func (c scraperConfig) ScrapeSceneURL(url string) (*models.ScrapedScene, error) {
for _, scraper := range c.SceneByURL {
if scraper.matchesURL(url) && scraper.performScrape != nil {
ret, err := scraper.performScrape(scraper.scraperTypeConfig, url)
if err != nil {
return nil, err
}
if ret != nil {
return ret, nil
}
}
}
return nil, nil
}

View File

@@ -2,17 +2,46 @@ package scraper
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
func GetPerformerNames(q string) ([]string, error) {
const freeonesScraperID = "builtin_freeones"
const freeonesName = "Freeones"
var freeonesURLs = []string{
"freeones.com",
}
func GetFreeonesScraper() scraperConfig {
return scraperConfig{
ID: freeonesScraperID,
Name: "Freeones",
PerformerByName: &performerByNameConfig{
performScrape: GetPerformerNames,
},
PerformerByFragment: &performerByFragmentConfig{
performScrape: GetPerformer,
},
PerformerByURL: []*scrapePerformerByURLConfig{
&scrapePerformerByURLConfig{
scrapeByURLConfig: scrapeByURLConfig{
URL: freeonesURLs,
},
performScrape: GetPerformerURL,
},
},
}
}
func GetPerformerNames(c scraperTypeConfig, q string) ([]*models.ScrapedPerformer, error) {
// Request the HTML page.
queryURL := "https://www.freeones.com/suggestions.php?q=" + url.PathEscape(q) + "&t=1"
res, err := http.Get(queryURL)
@@ -31,65 +60,42 @@ func GetPerformerNames(q string) ([]string, error) {
}
// Find the performers
var performerNames []string
var performers []*models.ScrapedPerformer
doc.Find(".suggestion").Each(func(i int, s *goquery.Selection) {
name := strings.Trim(s.Text(), " ")
performerNames = append(performerNames, name)
p := models.ScrapedPerformer{
Name: &name,
}
performers = append(performers, &p)
})
return performerNames, nil
return performers, nil
}
func GetPerformer(performerName string) (*models.ScrapedPerformer, error) {
queryURL := "https://www.freeones.com/search/?t=1&q=" + url.PathEscape(performerName) + "&view=thumbs"
res, err := http.Get(queryURL)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", res.StatusCode, res.Status)
func GetPerformerURL(c scraperTypeConfig, href string) (*models.ScrapedPerformer, error) {
// if we're already in the bio page, just scrape it
if regexp.MustCompile(`\/bio_.*\.php$`).MatchString(href) {
return getPerformerBio(c, href)
}
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
return nil, err
// otherwise try to get the bio page from the url
profileRE := regexp.MustCompile(`_links\/(.*?)\/$`)
if profileRE.MatchString(href) {
href = profileRE.ReplaceAllString(href, "_links/bio_$1.php")
return getPerformerBio(c, href)
}
performerLink := doc.Find("div.Block3 a").FilterFunction(func(i int, s *goquery.Selection) bool {
href, _ := s.Attr("href")
if href == "/html/j_links/Jenna_Leigh_c/" || href == "/html/a_links/Alexa_Grace_c/" {
return false
}
if strings.ToLower(s.Text()) == strings.ToLower(performerName) {
return true
}
alias := s.ParentsFiltered(".babeNameBlock").Find(".babeAlias").First();
if strings.Contains( strings.ToLower(alias.Text()), strings.ToLower(performerName) ) {
return true
}
return false
})
return nil, nil
}
href, _ := performerLink.Attr("href")
href = strings.TrimSuffix(href, "/")
regex := regexp.MustCompile(`.+_links\/(.+)`)
matches := regex.FindStringSubmatch(href)
if len(matches) < 2 {
return nil, fmt.Errorf("No matches found in %s",href)
}
href = strings.Replace(href, matches[1], "bio_"+matches[1]+".php", -1)
href = "https://www.freeones.com" + href
func getPerformerBio(c scraperTypeConfig, href string) (*models.ScrapedPerformer, error) {
bioRes, err := http.Get(href)
if err != nil {
return nil, err
}
defer bioRes.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", res.StatusCode, res.Status)
if bioRes.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", bioRes.StatusCode, bioRes.Status)
}
// Load the HTML document
@@ -175,6 +181,57 @@ func GetPerformer(performerName string) (*models.ScrapedPerformer, error) {
return &result, nil
}
func GetPerformer(c scraperTypeConfig, scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error) {
if scrapedPerformer.Name == nil {
return nil, nil
}
performerName := *scrapedPerformer.Name
queryURL := "https://www.freeones.com/search/?t=1&q=" + url.PathEscape(performerName) + "&view=thumbs"
res, err := http.Get(queryURL)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", res.StatusCode, res.Status)
}
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
return nil, err
}
performerLink := doc.Find("div.Block3 a").FilterFunction(func(i int, s *goquery.Selection) bool {
href, _ := s.Attr("href")
if href == "/html/j_links/Jenna_Leigh_c/" || href == "/html/a_links/Alexa_Grace_c/" {
return false
}
if strings.ToLower(s.Text()) == strings.ToLower(performerName) {
return true
}
alias := s.ParentsFiltered(".babeNameBlock").Find(".babeAlias").First()
if strings.Contains(strings.ToLower(alias.Text()), strings.ToLower(performerName)) {
return true
}
return false
})
href, _ := performerLink.Attr("href")
href = strings.TrimSuffix(href, "/")
regex := regexp.MustCompile(`.+_links\/(.+)`)
matches := regex.FindStringSubmatch(href)
if len(matches) < 2 {
return nil, fmt.Errorf("No matches found in %s", href)
}
href = strings.Replace(href, matches[1], "bio_"+matches[1]+".php", -1)
href = "https://www.freeones.com" + href
return getPerformerBio(c, href)
}
func getIndexes(doc *goquery.Document) map[string]int {
var indexes = make(map[string]int)
doc.Find(".paramname").Each(func(i int, s *goquery.Selection) {
@@ -236,7 +293,7 @@ func paramValue(params *goquery.Selection, paramIndex int) string {
return content
}
node = node.NextSibling
if (node == nil) {
if node == nil {
return ""
}
return trim(node.FirstChild.Data)

251
pkg/scraper/scrapers.go Normal file
View File

@@ -0,0 +1,251 @@
package scraper
import (
"errors"
"path/filepath"
"strconv"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
)
var scrapers []scraperConfig
func loadScrapers() ([]scraperConfig, error) {
if scrapers != nil {
return scrapers, nil
}
path := config.GetScrapersPath()
scrapers = make([]scraperConfig, 0)
logger.Debugf("Reading scraper configs from %s", path)
scraperFiles, err := filepath.Glob(filepath.Join(path, "*.yml"))
if err != nil {
logger.Errorf("Error reading scraper configs: %s", err.Error())
return nil, err
}
// add built-in freeones scraper
scrapers = append(scrapers, GetFreeonesScraper())
for _, file := range scraperFiles {
scraper, err := loadScraperFromYAML(file)
if err != nil {
logger.Errorf("Error loading scraper %s: %s", file, err.Error())
} else {
scrapers = append(scrapers, *scraper)
}
}
return scrapers, nil
}
func ListPerformerScrapers() ([]*models.Scraper, error) {
// read scraper config files from the directory and cache
scrapers, err := loadScrapers()
if err != nil {
return nil, err
}
var ret []*models.Scraper
for _, s := range scrapers {
// filter on type
if s.supportsPerformers() {
ret = append(ret, s.toScraper())
}
}
return ret, nil
}
func ListSceneScrapers() ([]*models.Scraper, error) {
// read scraper config files from the directory and cache
scrapers, err := loadScrapers()
if err != nil {
return nil, err
}
var ret []*models.Scraper
for _, s := range scrapers {
// filter on type
if s.supportsScenes() {
ret = append(ret, s.toScraper())
}
}
return ret, nil
}
func findScraper(scraperID string) *scraperConfig {
// read scraper config files from the directory and cache
loadScrapers()
for _, s := range scrapers {
if s.ID == scraperID {
return &s
}
}
return nil
}
func ScrapePerformerList(scraperID string, query string) ([]*models.ScrapedPerformer, error) {
// find scraper with the provided id
s := findScraper(scraperID)
if s != nil {
return s.ScrapePerformerNames(query)
}
return nil, errors.New("Scraper with ID " + scraperID + " not found")
}
func ScrapePerformer(scraperID string, scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error) {
// find scraper with the provided id
s := findScraper(scraperID)
if s != nil {
return s.ScrapePerformer(scrapedPerformer)
}
return nil, errors.New("Scraper with ID " + scraperID + " not found")
}
func ScrapePerformerURL(url string) (*models.ScrapedPerformer, error) {
for _, s := range scrapers {
if s.matchesPerformerURL(url) {
return s.ScrapePerformerURL(url)
}
}
return nil, nil
}
func matchPerformer(p *models.ScrapedScenePerformer) error {
qb := models.NewPerformerQueryBuilder()
performers, err := qb.FindByNames([]string{p.Name}, nil)
if err != nil {
return err
}
if len(performers) != 1 {
// ignore - cannot match
return nil
}
id := strconv.Itoa(performers[0].ID)
p.ID = &id
return nil
}
func matchStudio(s *models.ScrapedSceneStudio) error {
qb := models.NewStudioQueryBuilder()
studio, err := qb.FindByName(s.Name, nil)
if err != nil {
return err
}
if studio == nil {
// ignore - cannot match
return nil
}
id := strconv.Itoa(studio.ID)
s.ID = &id
return nil
}
func matchTag(s *models.ScrapedSceneTag) error {
qb := models.NewTagQueryBuilder()
tag, err := qb.FindByName(s.Name, nil)
if err != nil {
return err
}
if tag == nil {
// ignore - cannot match
return nil
}
id := strconv.Itoa(tag.ID)
s.ID = &id
return nil
}
func postScrapeScene(ret *models.ScrapedScene) error {
for _, p := range ret.Performers {
err := matchPerformer(p)
if err != nil {
return err
}
}
for _, t := range ret.Tags {
err := matchTag(t)
if err != nil {
return err
}
}
if ret.Studio != nil {
err := matchStudio(ret.Studio)
if err != nil {
return err
}
}
return nil
}
func ScrapeScene(scraperID string, scene models.SceneUpdateInput) (*models.ScrapedScene, error) {
// find scraper with the provided id
s := findScraper(scraperID)
if s != nil {
ret, err := s.ScrapeScene(scene)
if err != nil {
return nil, err
}
if ret != nil {
err = postScrapeScene(ret)
if err != nil {
return nil, err
}
}
return ret, nil
}
return nil, errors.New("Scraper with ID " + scraperID + " not found")
}
func ScrapeSceneURL(url string) (*models.ScrapedScene, error) {
for _, s := range scrapers {
if s.matchesSceneURL(url) {
ret, err := s.ScrapeSceneURL(url)
if err != nil {
return nil, err
}
err = postScrapeScene(ret)
if err != nil {
return nil, err
}
return ret, nil
}
}
return nil, nil
}

132
pkg/scraper/script.go Normal file
View File

@@ -0,0 +1,132 @@
package scraper
import (
"encoding/json"
"errors"
"io"
"io/ioutil"
"os/exec"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
)
func runScraperScript(command []string, inString string, out interface{}) error {
cmd := exec.Command(command[0], command[1:]...)
cmd.Dir = config.GetScrapersPath()
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
go func() {
defer stdin.Close()
io.WriteString(stdin, inString)
}()
stderr, err := cmd.StderrPipe()
if err != nil {
logger.Error("Scraper stderr not available: " + err.Error())
}
stdout, err := cmd.StdoutPipe()
if nil != err {
logger.Error("Scraper stdout not available: " + err.Error())
}
if err = cmd.Start(); err != nil {
logger.Error("Error running scraper script: " + err.Error())
return errors.New("Error running scraper script")
}
// TODO - add a timeout here
decodeErr := json.NewDecoder(stdout).Decode(out)
stderrData, _ := ioutil.ReadAll(stderr)
stderrString := string(stderrData)
err = cmd.Wait()
if err != nil {
// error message should be in the stderr stream
logger.Errorf("scraper error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderrString)
return errors.New("Error running scraper script")
}
if decodeErr != nil {
logger.Errorf("error decoding performer from scraper data: %s", err.Error())
return errors.New("Error decoding performer from scraper script")
}
return nil
}
func scrapePerformerNamesScript(c scraperTypeConfig, name string) ([]*models.ScrapedPerformer, error) {
inString := `{"name": "` + name + `"}`
var performers []models.ScrapedPerformer
err := runScraperScript(c.Script, inString, &performers)
// convert to pointers
var ret []*models.ScrapedPerformer
if err == nil {
for i := 0; i < len(performers); i++ {
ret = append(ret, &performers[i])
}
}
return ret, err
}
func scrapePerformerFragmentScript(c scraperTypeConfig, scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error) {
inString, err := json.Marshal(scrapedPerformer)
if err != nil {
return nil, err
}
var ret models.ScrapedPerformer
err = runScraperScript(c.Script, string(inString), &ret)
return &ret, err
}
func scrapePerformerURLScript(c scraperTypeConfig, url string) (*models.ScrapedPerformer, error) {
inString := `{"url": "` + url + `"}`
var ret models.ScrapedPerformer
err := runScraperScript(c.Script, string(inString), &ret)
return &ret, err
}
func scrapeSceneFragmentScript(c scraperTypeConfig, scene models.SceneUpdateInput) (*models.ScrapedScene, error) {
inString, err := json.Marshal(scene)
if err != nil {
return nil, err
}
var ret models.ScrapedScene
err = runScraperScript(c.Script, string(inString), &ret)
return &ret, err
}
func scrapeSceneURLScript(c scraperTypeConfig, url string) (*models.ScrapedScene, error) {
inString := `{"url": "` + url + `"}`
var ret models.ScrapedScene
err := runScraperScript(c.Script, string(inString), &ret)
return &ret, err
}

132
pkg/scraper/stash.go Normal file
View File

@@ -0,0 +1,132 @@
package scraper
import (
"context"
"strconv"
"github.com/shurcooL/graphql"
"github.com/stashapp/stash/pkg/models"
)
func getStashClient(c scraperTypeConfig) *graphql.Client {
url := c.scraperConfig.StashServer.URL
return graphql.NewClient(url+"/graphql", nil)
}
type stashFindPerformerNamePerformer struct {
ID string `json:"id" graphql:"id"`
Name string `json:"id" graphql:"name"`
}
func (p stashFindPerformerNamePerformer) toPerformer() *models.ScrapedPerformer {
return &models.ScrapedPerformer{
Name: &p.Name,
// put id into the URL field
URL: &p.ID,
}
}
type stashFindPerformerNamesResultType struct {
Count int `graphql:"count"`
Performers []*stashFindPerformerNamePerformer `graphql:"performers"`
}
func scrapePerformerNamesStash(c scraperTypeConfig, name string) ([]*models.ScrapedPerformer, error) {
client := getStashClient(c)
var q struct {
FindPerformers stashFindPerformerNamesResultType `graphql:"findPerformers(filter: $f)"`
}
page := 1
perPage := 10
vars := map[string]interface{}{
"f": models.FindFilterType{
Q: &name,
Page: &page,
PerPage: &perPage,
},
}
err := client.Query(context.Background(), &q, vars)
if err != nil {
return nil, err
}
var ret []*models.ScrapedPerformer
for _, p := range q.FindPerformers.Performers {
ret = append(ret, p.toPerformer())
}
return ret, nil
}
func scrapePerformerFragmentStash(c scraperTypeConfig, scrapedPerformer models.ScrapedPerformerInput) (*models.ScrapedPerformer, error) {
client := getStashClient(c)
var q struct {
FindPerformer *models.ScrapedPerformer `graphql:"findPerformer(id: $f)"`
}
// get the id from the URL field
vars := map[string]interface{}{
"f": *scrapedPerformer.URL,
}
err := client.Query(context.Background(), &q, vars)
if err != nil {
return nil, err
}
return q.FindPerformer, nil
}
func scrapeSceneFragmentStash(c scraperTypeConfig, scene models.SceneUpdateInput) (*models.ScrapedScene, error) {
// query by MD5
// assumes that the scene exists in the database
qb := models.NewSceneQueryBuilder()
id, err := strconv.Atoi(scene.ID)
if err != nil {
return nil, err
}
storedScene, err := qb.Find(id)
if err != nil {
return nil, err
}
var q struct {
FindScene *models.ScrapedScene `graphql:"findScene(checksum: $c)"`
}
checksum := graphql.String(storedScene.Checksum)
vars := map[string]interface{}{
"c": &checksum,
}
client := getStashClient(c)
err = client.Query(context.Background(), &q, vars)
if err != nil {
return nil, err
}
if q.FindScene != nil {
// the ids of the studio, performers and tags must be nilled
if q.FindScene.Studio != nil {
q.FindScene.Studio.ID = nil
}
for _, p := range q.FindScene.Performers {
p.ID = nil
}
for _, t := range q.FindScene.Tags {
t.ID = nil
}
}
return q.FindScene, nil
}

267
pkg/scraper/xpath.go Normal file
View File

@@ -0,0 +1,267 @@
package scraper
import (
"errors"
"reflect"
"regexp"
"strings"
"github.com/antchfx/htmlquery"
"golang.org/x/net/html"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type commonXPathConfig map[string]string
func (c commonXPathConfig) applyCommon(src string) string {
ret := src
for commonKey, commonVal := range c {
if strings.Contains(ret, commonKey) {
ret = strings.Replace(ret, commonKey, commonVal, -1)
}
}
return ret
}
type xpathScraperConfig map[string]interface{}
func createXPathScraperConfig(src map[interface{}]interface{}) xpathScraperConfig {
ret := make(xpathScraperConfig)
if src != nil {
for k, v := range src {
keyStr, isStr := k.(string)
if isStr {
ret[keyStr] = v
}
}
}
return ret
}
func (s xpathScraperConfig) process(doc *html.Node, common commonXPathConfig) []xPathResult {
var ret []xPathResult
for k, v := range s {
asStr, isStr := v.(string)
if isStr {
// apply common
if common != nil {
asStr = common.applyCommon(asStr)
}
found := htmlquery.Find(doc, asStr)
if len(found) > 0 {
for i, elem := range found {
if i >= len(ret) {
ret = append(ret, make(xPathResult))
}
ret[i][k] = elem
}
}
}
// TODO - handle map type
}
return ret
}
type xpathScrapers map[string]*xpathScraper
type xpathScraper struct {
Common commonXPathConfig `yaml:"common"`
Scene xpathScraperConfig `yaml:"scene"`
Performer xpathScraperConfig `yaml:"performer"`
}
const (
XPathScraperConfigSceneTags = "Tags"
XPathScraperConfigScenePerformers = "Performers"
XPathScraperConfigSceneStudio = "Studio"
)
func (s xpathScraper) GetSceneSimple() xpathScraperConfig {
// exclude the complex sub-configs
ret := make(xpathScraperConfig)
mapped := s.Scene
if mapped != nil {
for k, v := range mapped {
if k != XPathScraperConfigSceneTags && k != XPathScraperConfigScenePerformers && k != XPathScraperConfigSceneStudio {
ret[k] = v
}
}
}
return ret
}
func (s xpathScraper) getSceneSubMap(key string) xpathScraperConfig {
var ret map[interface{}]interface{}
mapped := s.Scene
if mapped != nil {
v, ok := mapped[key]
if ok {
ret, _ = v.(map[interface{}]interface{})
}
}
if ret != nil {
return createXPathScraperConfig(ret)
}
return nil
}
func (s xpathScraper) GetScenePerformers() xpathScraperConfig {
return s.getSceneSubMap(XPathScraperConfigScenePerformers)
}
func (s xpathScraper) GetSceneTags() xpathScraperConfig {
return s.getSceneSubMap(XPathScraperConfigSceneTags)
}
func (s xpathScraper) GetSceneStudio() xpathScraperConfig {
return s.getSceneSubMap(XPathScraperConfigSceneStudio)
}
func (s xpathScraper) scrapePerformer(doc *html.Node) (*models.ScrapedPerformer, error) {
var ret models.ScrapedPerformer
performerMap := s.Performer
if performerMap == nil {
return nil, nil
}
results := performerMap.process(doc, s.Common)
if len(results) > 0 {
results[0].apply(&ret)
}
return &ret, nil
}
func (s xpathScraper) scrapeScene(doc *html.Node) (*models.ScrapedScene, error) {
var ret models.ScrapedScene
sceneMap := s.GetSceneSimple()
if sceneMap == nil {
return nil, nil
}
scenePerformersMap := s.GetScenePerformers()
sceneTagsMap := s.GetSceneTags()
sceneStudioMap := s.GetSceneStudio()
results := sceneMap.process(doc, s.Common)
if len(results) > 0 {
results[0].apply(&ret)
// now apply the performers and tags
if scenePerformersMap != nil {
performerResults := scenePerformersMap.process(doc, s.Common)
for _, p := range performerResults {
performer := &models.ScrapedScenePerformer{}
p.apply(performer)
ret.Performers = append(ret.Performers, performer)
}
}
if sceneTagsMap != nil {
tagResults := sceneTagsMap.process(doc, s.Common)
for _, p := range tagResults {
tag := &models.ScrapedSceneTag{}
p.apply(tag)
ret.Tags = append(ret.Tags, tag)
}
}
if sceneStudioMap != nil {
studioResults := sceneStudioMap.process(doc, s.Common)
if len(studioResults) > 0 {
studio := &models.ScrapedSceneStudio{}
studioResults[0].apply(studio)
ret.Studio = studio
}
}
}
return &ret, nil
}
type xPathResult map[string]*html.Node
func (r xPathResult) apply(dest interface{}) {
destVal := reflect.ValueOf(dest)
// dest should be a pointer
destVal = destVal.Elem()
for key, v := range r {
field := destVal.FieldByName(key)
if field.IsValid() {
value := htmlquery.InnerText(v)
value = strings.TrimSpace(value)
// remove multiple whitespace and end lines
re := regexp.MustCompile("\n")
value = re.ReplaceAllString(value, "")
re = regexp.MustCompile(" +")
value = re.ReplaceAllString(value, " ")
var reflectValue reflect.Value
if field.Kind() == reflect.Ptr {
reflectValue = reflect.ValueOf(&value)
} else {
reflectValue = reflect.ValueOf(value)
}
field.Set(reflectValue)
} else {
logger.Errorf("Field %s does not exist in %T", key, dest)
}
}
}
func scrapePerformerURLXpath(c scraperTypeConfig, url string) (*models.ScrapedPerformer, error) {
scraper := c.scraperConfig.XPathScrapers[c.Scraper]
if scraper == nil {
return nil, errors.New("xpath scraper with name " + c.Scraper + " not found in config")
}
doc, err := htmlquery.LoadURL(url)
if err != nil {
return nil, err
}
return scraper.scrapePerformer(doc)
}
func scrapeSceneURLXPath(c scraperTypeConfig, url string) (*models.ScrapedScene, error) {
scraper := c.scraperConfig.XPathScrapers[c.Scraper]
if scraper == nil {
return nil, errors.New("xpath scraper with name " + c.Scraper + " not found in config")
}
doc, err := htmlquery.LoadURL(url)
if err != nil {
return nil, err
}
return scraper.scrapeScene(doc)
}

732
pkg/scraper/xpath_test.go Normal file
View File

@@ -0,0 +1,732 @@
package scraper
import (
"strings"
"testing"
"github.com/antchfx/htmlquery"
"github.com/stashapp/stash/pkg/models"
"gopkg.in/yaml.v2"
)
// adapted from https://www.freeones.com/html/m_links/bio_Mia_Malkova.php
const htmlDoc1 = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en" dir="ltr">
<head>
<title>Freeones: Mia Malkova Biography</title>
</head>
<body data-babe="Mia Malkova">
<div class="ContentBlock Block1">
<div class="ContentBlockBody" style="padding: 0px;">
<table id="biographyTable" border="0" cellspacing="0" cellpadding="0" width="100%">
<tbody>
<tr>
<td class="paramname">
<div><b>Babe Name:</b></div>
</td>
<td class="paramvalue">
<a href="/html/m_links/Mia_Malkova/">Mia Malkova</a>&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<div><b>Profession:</b></div>
</td>
<td class="paramvalue">Porn Star
</td>
</tr>
<tr>
<td class="paramname">
<b>Ethnicity:</b>
</td>
<td class="paramvalue">
Caucasian&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Country of Origin:</b>
</td>
<td class="paramvalue">
<span class="country-us">
United States
<span>
</span></span></td>
</tr>
<tr>
<td class="paramname">
<b>Date of Birth:</b>
</td>
<td class="paramvalue">
July 1, 1992 (27 years old)&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Aliases:</b>
</td>
<td class="paramvalue">
Mia Bliss, Madison Clover, Madison Swan, Mia Mountain, Jessica&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Eye Color:</b>
</td>
<td class="paramvalue">
Hazel&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Hair Color:</b>
</td>
<td class="paramvalue">
Blonde&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Height:</b>
</td>
<td class="paramvalue">
<script type="text/javascript">
<!--
heightcm = "171";
morethenone = 'inch';
feet = heightcm / 30.48;
inches = (feet - Math.floor(feet)) * 30.48 / 2.54;
feet = Math.floor(feet);
inches = inches.toFixed(0);
if (inches > 1) {
morethenone = 'inches';
}
if (heightcm == 0) {
message = 'Unknown';
} else {
message = '171 cm - ' + feet + ' feet and ' + inches + ' ' + morethenone;
}
document.write(message);
// -->
</script>&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Measurements:</b>
</td>
<td class="paramvalue">
34C-26-36
</td>
</tr>
<tr>
<td class="paramname">
<b>Fake boobs:</b>
</td>
<td class="paramvalue">
No&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Career Start And End</b>
</td>
<td class="paramvalue">
2012 - 2019
(7 Years In The Business)
</td>
</tr>
<tr>
<td class="paramname">
<b>Tattoos:</b>
</td>
<td class="paramvalue">
None&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<b>Piercings:</b>
</td>
<td class="paramvalue">
None&nbsp;
</td>
</tr>
<tr>
<td class="paramname">
<div><b>Social Network Links:</b></div>
</td>
<td class="paramvalue">
<ul id="socialmedia">
<li class="twitter"><a href="https://twitter.com/MiaMalkova" target="_blank" alt="Mia Malkova Twitter" title="Mia Malkova Twitter">Twitter</a></li>
<li class="facebook"><a href="https://www.facebook.com/MiaMalcove" target="_blank" alt="Mia Malkova Facebook" title="Mia Malkova Facebook">Facebook</a></li>
<li class="youtube"><a href="https://www.youtube.com/channel/UCEPR0sZKa_ScMoyhemfB7nA" target="_blank" alt="Mia Malkova YouTube" title="Mia Malkova YouTube">YouTube</a></li>
<li class="instagram"><a href="https://www.instagram.com/mia_malkova/" target="_blank" alt="Mia Malkova Instagram" title="Mia Malkova Instagram">Instagram</a></li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
`
func makeCommonXPath(attr string) string {
return `//table[@id="biographyTable"]//tr/td[@class="paramname"]//b[text() = '` + attr + `']/ancestor::tr/td[@class="paramvalue"]`
}
func makeXPathConfig() xpathScraperConfig {
config := make(xpathScraperConfig)
config["Name"] = makeCommonXPath("Babe Name:") + `/a`
config["Ethnicity"] = makeCommonXPath("Ethnicity:")
config["Country"] = makeCommonXPath("Country of Origin:")
config["Birthdate"] = makeCommonXPath("Date of Birth:")
config["Aliases"] = makeCommonXPath("Aliases:")
config["EyeColor"] = makeCommonXPath("Eye Color:")
config["Measurements"] = makeCommonXPath("Measurements:")
config["FakeTits"] = makeCommonXPath("Fake boobs:")
config["Height"] = makeCommonXPath("Height:")
// no colon in attribute header
config["CareerLength"] = makeCommonXPath("Career Start And End")
config["Tattoos"] = makeCommonXPath("Tattoos:")
config["Piercings"] = makeCommonXPath("Piercings:")
return config
}
func verifyField(t *testing.T, expected string, actual *string, field string) {
t.Helper()
if actual == nil || *actual != expected {
if actual == nil {
t.Errorf("Expected %s to be set to %s, instead got nil", field, expected)
} else {
t.Errorf("Expected %s to be set to %s, instead got %s", field, expected, *actual)
}
}
}
func TestScrapePerformerXPath(t *testing.T) {
reader := strings.NewReader(htmlDoc1)
doc, err := htmlquery.Parse(reader)
if err != nil {
t.Errorf("Error loading document: %s", err.Error())
return
}
xpathConfig := makeXPathConfig()
scraper := xpathScraper{
Performer: xpathConfig,
}
performer, err := scraper.scrapePerformer(doc)
if err != nil {
t.Errorf("Error scraping performer: %s", err.Error())
return
}
const performerName = "Mia Malkova"
const ethnicity = "Caucasian"
const country = "United States"
const birthdate = "July 1, 1992 (27 years old)"
const aliases = "Mia Bliss, Madison Clover, Madison Swan, Mia Mountain, Jessica"
const eyeColor = "Hazel"
const measurements = "34C-26-36"
const fakeTits = "No"
const careerLength = "2012 - 2019"
const tattoosPiercings = "None"
verifyField(t, performerName, performer.Name, "Name")
verifyField(t, ethnicity, performer.Ethnicity, "Ethnicity")
verifyField(t, country, performer.Country, "Country")
verifyField(t, birthdate, performer.Birthdate, "Birthdate")
verifyField(t, aliases, performer.Aliases, "Aliases")
verifyField(t, eyeColor, performer.EyeColor, "EyeColor")
verifyField(t, measurements, performer.Measurements, "Measurements")
verifyField(t, fakeTits, performer.FakeTits, "FakeTits")
// TODO - this needs post-processing
//verifyField(t, careerLength, performer.CareerLength, "CareerLength")
verifyField(t, tattoosPiercings, performer.Tattoos, "Tattoos")
verifyField(t, tattoosPiercings, performer.Piercings, "Piercings")
}
const sceneHTML = `
<!DOCTYPE html>
<head>
<title>Test Video - Pornhub.com</title>
<meta property="og:title" content="Test Video" />
<meta property="og:description"
content="Watch Test Video on Pornhub.com, the best hardcore porn site. Pornhub is home to the widest selection of free Babe sex videos full of the hottest pornstars. If you&#039;re craving 3some XXX movies you&#039;ll find them here." />
<meta property="og:image"
content="https://di.phncdn.com/videos/201910/13/254476211/thumbs_80/(m=eaAaGwObaaaa)(mh=_V1YEGdMFS1rEYoW)9.jpg" />
<script type="application/ld+json">
{
"@context": "http://schema.org/",
"@type": "VideoObject",
"name": "Test Video",
"embedUrl": "https://www.pornhub.com/embed/ph5da270596459c",
"duration": "PT00H33M27S",
"thumbnailUrl": "https://di.phncdn.com/videos/201910/13/254476211/thumbs_80/(m=eaAaGwObaaaa)(mh=_V1YEGdMFS1rEYoW)9.jpg",
"uploadDate": "2019-10-13T00:33:51+00:00",
"description": "Watch Test Video on Pornhub&period;com&comma; the best hardcore porn site&period; Pornhub is home to the widest selection of free Babe sex videos full of the hottest pornstars&period; If you&apos;re craving 3some XXX movies you&apos;ll find them here&period;",
"author" : "Mia Malkova", "interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "http://schema.org/WatchAction",
"userInteractionCount": "5,908,861"
},
{
"@type": "InteractionCounter",
"interactionType": "http://schema.org/LikeAction",
"userInteractionCount": "22,090"
}
]
}
</script>
</head>
<body class="logged-out">
<div class="container ">
<div id="main-container" class="clearfix" data-delete-check="1" data-is-private="1" data-is-premium=""
data-liu="0" data-next-shuffle="ph5da270596459c" data-pkey="" data-platform-pc="1" data-playlist-check="0"
data-playlist-id-check="0" data-playlist-geo-check="0" data-friend="0" data-playlist-user-check="0"
data-playlist-video-check="0" data-playlist-shuffle="0" data-shuffle-forward="ph5da270596459c"
data-shuffle-back="ph5da270596459c" data-min-large="1350"
data-video-title="Test Video">
<div id="vpContentContainer">
<div id="hd-leftColVideoPage">
<div class="video-wrapper">
<div class="title-container">
<i class="isMe tooltipTrig" data-title="Video of verified member"></i>
<h1 class="title">
<span class="inlineFree">Test Video</span>
</h1>
</div>
<div class="video-actions-container">
<div class="video-actions-tabs">
<div class="video-action-tab about-tab active">
<div class="video-detailed-info">
<div class="video-info-row">
From:&nbsp;
<div class="usernameWrap clearfix" data-type="channel" data-userid="492538092"
data-liu-user="0"
data-json-url="/user/box?id=492538092&amp;token=MTU3NzA1NTkzNIqATol8v_WrhmNTXkeflvG09C2U7UUT_NyoZUFa7iKq0mlzBkmdgAH1aNHZkJmIOHbbwmho1BehHDoA63K5Wn4."
data-disable-popover="0">
<a rel="" href="/channels/sis-loves-me" class="bolded">Sis Loves Me</a>
<div class="avatarPosition"></div>
</div>
<span class="verified-icon flag tooltipTrig"
data-title="Verified member"></span>
- 87 videos
<span class="subscribers-count">&nbsp;459466</span>
</div>
<div class="video-info-row">
<div class="pornstarsWrapper">
Pornstars:&nbsp;
<a class="pstar-list-btn js-mxp" data-mxptype="Pornstar"
data-mxptext="Alex D" data-id="251341" data-login="1"
href="/pornstar/alex-d">Alex D <span
class="psbox-link-container display-none"></span>
</a>
, <a class="pstar-list-btn js-mxp" data-mxptype="Pornstar"
data-mxptext="Mia Malkova" data-id="10641" data-login="1"
href="/pornstar/mia-malkova">Mia Malkova <span
class="psbox-link-container display-none"></span>
</a>
, <a class="pstar-list-btn js-mxp" data-mxptype="Pornstar"
data-mxptext="Riley Reid" data-id="5343" data-login="1"
href="/pornstar/riley-reid">Riley Reid <span
class="psbox-link-container display-none"></span>
</a>
<div class="tooltipTrig suggestBtn" data-title="Add a pornstar">
<a class="add-btn-small add-pornstar-btn-2">+
<span>Suggest</span></a>
</div>
<div id="deletePornstarResult" class="suggest-result"></div>
</div>
</div>
<div class="video-info-row showLess">
<div class="categoriesWrapper">
Categories:&nbsp;
<a href="/video?c=3"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Amateur</a>,
<a href="/categories/babe"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Babe</a>,
<a href="/video?c=13"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Blowjob</a>,
<a href="/video?c=115"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Exclusive</a>,
<a href="/hd"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">HD
Porn</a>, <a href="/categories/pornstar"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Pornstar</a>,
<a href="/video?c=24"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Public</a>,
<a href="/video?c=131"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Pussy
Licking</a>, <a href="/video?c=65"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Threesome</a>,
<a href="/video?c=139"
onclick="ga('send', 'event', 'Watch Page', 'click', 'Category');">Verified
Models</a>
<div class="tooltipTrig suggestBtn" data-title="Suggest Categories">
<a id="categoryLink" class="add-btn-small ">+
<span>Suggest</span></a>
</div>
</div>
</div>
<div class="video-info-row showLess">
<div class="productionWrapper">
Production:&nbsp;
<a href="/video?p=professional" rel="nofollow"
class="production">professional</a>
</div>
</div>
<div class="video-info-row showLess">
<div class="tagsWrapper">
Tags:&nbsp;
<a href="/video/search?search=3some">3some</a>, <a
href="/video?c=9">blonde</a>, <a href="/video?c=59">small tits</a>,
<a href="/video/search?search=butt">butt</a>, <a
href="/video/search?search=natural+tits">natural tits</a>, <a
href="/video/search?search=petite">petite</a>, <a
href="/video?c=24">public</a>, <a
href="/video/search?search=outside">outside</a>, <a
href="/video/search?search=car">car</a>, <a
href="/video/search?search=garage">garage</a>, <a
href="/video?c=65">threesome</a>, <a
href="/video/search?search=bgg">bgg</a>, <a
href="/video/search?search=girlfrien+d">girlfrien d</a>, <a
href="/video/search?search=parking">parking</a>, <a
href="/video/search?search=sex">sex</a>, <a
href="/video/search?search=gagging">gagging</a>, <a
href="/video?c=13">blowjob</a>, <a
href="/video/search?search=bj">bj</a>, <a
href="/video/search?search=double">double</a>, <a
href="/video/search?search=ass">ass</a>
<div class="tooltipTrig suggestBtn" data-title="Suggest Tags">
<a id="tagLink" class="add-btn-small">+ <span>Suggest</span></a>
</div>
</div>
</div>
<div class="video-info-row showLess">
Added on: <span class="white">2 months ago</span>
</div>
<div class="video-info-row showLess">
Featured on: <span class="white">1 month ago</span>
</div>
</div>
</div>
<div class="video-action-tab jump-to-tab">
<div class="title">Jump to your favorite action</div>
<div class="filters mainFilter float-right">
<div class="dropdownTrigger">
<div>
<span class="textFilter" id="tagSort">Sequence</span>
<span class="arrowFilters"></span>
</div>
<ul class="filterListItem dropdownWrapper">
<li class="active"><a class="actionTagSort"
data-sort="seconds">Sequence</a></li>
<li><a class="actionTagSort" data-sort="tag">Alphabetical</a></li>
</ul>
</div>
</div>
<div class="reset"></div>
<div class="display-grid col-4 gap-row-none sortBy seconds">
<ul class="actionTagList full-width margin-none">
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(862), ga('send', 'event', 'Video Page', 'click', 'Jump to Blowjob');">
Blowjob </a>
&nbsp;
<var>14:22</var>
</li>
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1117), ga('send', 'event', 'Video Page', 'click', 'Jump to Reverse Cowgirl');">
Reverse Cowgirl </a>
&nbsp;
<var>18:37</var>
</li>
</ul>
<ul class="actionTagList full-width margin-none">
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1182), ga('send', 'event', 'Video Page', 'click', 'Jump to Cowgirl');">
Cowgirl </a>
&nbsp;
<var>19:42</var>
</li>
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1625), ga('send', 'event', 'Video Page', 'click', 'Jump to Cowgirl');">
Cowgirl </a>
&nbsp;
<var>27:05</var>
</li>
</ul>
<ul class="actionTagList full-width margin-none">
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1822), ga('send', 'event', 'Video Page', 'click', 'Jump to Doggystyle');">
Doggystyle </a>
&nbsp;
<var>30:22</var>
</li>
</ul>
</div>
<div class="display-grid col-4 gap-row-none sortBy tag">
<ul class="actionTagList full-width margin-none">
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(862), ga('send', 'event', 'Video Page', 'click', 'Jump to Blowjob');">
Blowjob </a>
&nbsp;
<var>14:22</var>
</li>
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1117), ga('send', 'event', 'Video Page', 'click', 'Jump to Reverse Cowgirl');">
Reverse Cowgirl </a>
&nbsp;
<var>18:37</var>
</li>
</ul>
<ul class="actionTagList full-width margin-none">
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1182), ga('send', 'event', 'Video Page', 'click', 'Jump to Cowgirl');">
Cowgirl </a>
&nbsp;
<var>19:42</var>
</li>
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1625), ga('send', 'event', 'Video Page', 'click', 'Jump to Cowgirl');">
Cowgirl </a>
&nbsp;
<var>27:05</var>
</li>
</ul>
<ul class="actionTagList full-width margin-none">
<li>
<a class="js-triggerJumpCat"
onclick="jumpToAction(1822), ga('send', 'event', 'Video Page', 'click', 'Jump to Doggystyle');">
Doggystyle </a>
&nbsp;
<var>30:22</var>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>`
func makeSceneXPathConfig() xpathScraper {
common := make(commonXPathConfig)
common["$performerElem"] = `//div[@class="pornstarsWrapper"]/a[@data-mxptype="Pornstar"]`
common["$studioElem"] = `//div[@data-type="channel"]/a`
config := make(xpathScraperConfig)
config["Title"] = `//meta[@property="og:title"]/@content`
// this needs post-processing
config["Date"] = `//script[@type="application/ld+json"]`
tagConfig := make(map[interface{}]interface{})
tagConfig["Name"] = `//div[@class="categoriesWrapper"]//a[not(@class="add-btn-small ")]`
config["Tags"] = tagConfig
performerConfig := make(map[interface{}]interface{})
performerConfig["Name"] = `$performerElem/@data-mxptext`
performerConfig["URL"] = `$performerElem/@href`
config["Performers"] = performerConfig
studioConfig := make(map[interface{}]interface{})
studioConfig["Name"] = `$studioElem`
studioConfig["URL"] = `$studioElem/@href`
config["Studio"] = studioConfig
scraper := xpathScraper{
Scene: config,
Common: common,
}
return scraper
}
func verifyTags(t *testing.T, expectedTagNames []string, actualTags []*models.ScrapedSceneTag) {
t.Helper()
i := 0
for i < len(expectedTagNames) || i < len(actualTags) {
expectedTag := ""
actualTag := ""
if i < len(expectedTagNames) {
expectedTag = expectedTagNames[i]
}
if i < len(actualTags) {
actualTag = actualTags[i].Name
}
if expectedTag != actualTag {
t.Errorf("Expected tag %s, got %s", expectedTag, actualTag)
}
i++
}
}
func verifyPerformers(t *testing.T, expectedNames []string, expectedURLs []string, actualPerformers []*models.ScrapedScenePerformer) {
t.Helper()
i := 0
for i < len(expectedNames) || i < len(actualPerformers) {
expectedName := ""
actualName := ""
expectedURL := ""
actualURL := ""
if i < len(expectedNames) {
expectedName = expectedNames[i]
}
if i < len(expectedURLs) {
expectedURL = expectedURLs[i]
}
if i < len(actualPerformers) {
actualName = actualPerformers[i].Name
if actualPerformers[i].URL != nil {
actualURL = *actualPerformers[i].URL
}
}
if expectedName != actualName {
t.Errorf("Expected performer name %s, got %s", expectedName, actualName)
}
if expectedURL != actualURL {
t.Errorf("Expected perfromer URL %s, got %s", expectedName, actualName)
}
i++
}
}
func TestApplySceneXPathConfig(t *testing.T) {
reader := strings.NewReader(sceneHTML)
doc, err := htmlquery.Parse(reader)
if err != nil {
t.Errorf("Error loading document: %s", err.Error())
return
}
scraper := makeSceneXPathConfig()
scene, err := scraper.scrapeScene(doc)
if err != nil {
t.Errorf("Error scraping scene: %s", err.Error())
return
}
const title = "Test Video"
verifyField(t, title, scene.Title, "Title")
// verify tags
expectedTags := []string{
"Amateur",
"Babe",
"Blowjob",
"Exclusive",
"HD Porn",
"Pornstar",
"Public",
"Pussy Licking",
"Threesome",
"Verified Models",
}
verifyTags(t, expectedTags, scene.Tags)
expectedPerformerNames := []string{
"Alex D",
"Mia Malkova",
"Riley Reid",
}
expectedPerformerURLs := []string{
"/pornstar/alex-d",
"/pornstar/mia-malkova",
"/pornstar/riley-reid",
}
verifyPerformers(t, expectedPerformerNames, expectedPerformerURLs, scene.Performers)
const expectedStudioName = "Sis Loves Me"
const expectedStudioURL = "/channels/sis-loves-me"
verifyField(t, expectedStudioName, &scene.Studio.Name, "Studio.Name")
verifyField(t, expectedStudioURL, scene.Studio.URL, "Studio.URL")
}
func TestLoadXPathScraperFromYAML(t *testing.T) {
const yamlStr = `name: Test
performerByURL:
- action: scrapeXPath
url:
- test.com
scraper: performerScraper
xPathScrapers:
performerScraper:
performer:
name: //h1[@itemprop="name"]
`
config := &scraperConfig{}
err := yaml.Unmarshal([]byte(yamlStr), &config)
if err != nil {
t.Errorf("Error loading yaml: %s", err.Error())
return
}
}

View File

@@ -5,7 +5,7 @@ import (
"time"
)
// GetVTTTime returns a timestamp appropriate for VTT files (hh:mm:ss)
// GetVTTTime returns a timestamp appropriate for VTT files (hh:mm:ss.mmm)
func GetVTTTime(totalSeconds float64) (s string) {
totalSecondsString := strconv.FormatFloat(totalSeconds, 'f', -1, 64)
secondsDuration, _ := time.ParseDuration(totalSecondsString + "s")
@@ -34,5 +34,8 @@ func GetVTTTime(totalSeconds float64) (s string) {
}
s += strconv.Itoa(seconds)
// videojs requires milliseconds
s += ".000"
return
}

View File

@@ -1,9 +1,10 @@
#!/bin/sh
DATE=`go run scripts/getDate.go`
GITHASH=`git rev-parse --short HEAD`
VERSION_FLAGS="-X 'github.com/stashapp/stash/pkg/api.buildstamp=$DATE' -X 'github.com/stashapp/stash/pkg/api.githash=$GITHASH'"
STASH_VERSION="$1"
DATE=`go run -mod=vendor scripts/getDate.go`
GITHASH=`git rev-parse --short HEAD`
VERSION_FLAGS="-X 'github.com/stashapp/stash/pkg/api.version=$STASH_VERSION' -X 'github.com/stashapp/stash/pkg/api.buildstamp=$DATE' -X 'github.com/stashapp/stash/pkg/api.githash=$GITHASH'"
SETUP="export GO111MODULE=on; export CGO_ENABLED=1;"
WINDOWS="GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ packr2 build -o dist/stash-win.exe -ldflags \"-extldflags '-static' $VERSION_FLAGS\" -tags extended -v -mod=vendor;"
DARWIN="GOOS=darwin GOARCH=amd64 CC=o64-clang CXX=o64-clang++ packr2 build -o dist/stash-osx -ldflags \"$VERSION_FLAGS\" -tags extended -v -mod=vendor;"
@@ -12,4 +13,4 @@ RASPPI="GOOS=linux GOARCH=arm GOARM=5 CC=arm-linux-gnueabi-gcc packr2 build -o d
COMMAND="$SETUP $WINDOWS $DARWIN $LINUX $RASPPI"
docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash stashapp/stash:compiler /bin/bash -c "$COMMAND"
docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash stashapp/compiler:develop /bin/bash -c "$COMMAND"

View File

@@ -0,0 +1,14 @@
#!/bin/sh
# assumes cross-compile.sh has already been run successfully
uploadFile()
{
FILE=$1
BASENAME="$(basename "${FILE}")"
uploadedTo=`curl --upload-file $FILE "https://transfer.sh/$BASENAME"`
echo "$BASENAME uploaded to url: $uploadedTo"
}
uploadFile "dist/stash-osx"
uploadFile "dist/stash-win.exe"
uploadFile "dist/stash-linux"

7
tools.go Normal file
View File

@@ -0,0 +1,7 @@
// +build tools
package main
import (
_ "github.com/99designs/gqlgen"
)

View File

@@ -31,10 +31,9 @@
"react-jw-player": "1.19.0",
"react-photo-gallery": "7.0.2",
"react-router-dom": "5.0.0",
"react-scripts": "3.0.1",
"react-scripts": "3.3.0",
"react-use": "9.1.2",
"subscriptions-transport-ws": "^0.9.16",
"video.js": "^7.6.0"
"subscriptions-transport-ws": "^0.9.16"
},
"scripts": {
"start": "react-scripts start",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
JW Player version 8.9.1
JW Player version 8.11.5
Copyright (c) 2019, JW Player, All Rights Reserved
https://github.com/jwplayer/jwplayer/blob/v8.9.1/README.md
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
https://www.jwplayer.com/tos/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
/*!
JW Player version 8.9.1
JW Player version 8.11.5
Copyright (c) 2019, JW Player, All Rights Reserved
https://github.com/jwplayer/jwplayer/blob/v8.9.1/README.md
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
https://www.jwplayer.com/tos/
@@ -92,4 +92,4 @@ COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQ
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.
*/
(window.webpackJsonpjwplayer=window.webpackJsonpjwplayer||[]).push([[10],{91:function(t,e,r){"use strict";r.r(e);var n=r(41),i=r(63),s=/^(\d+):(\d{2})(:\d{2})?\.(\d{3})/,a=/^-?\d+$/,u=/\r\n|\n/,o=/^NOTE($|[ \t])/,c=/^[^\sa-zA-Z-]+/,l=/:/,f=/\s/,h=/^\s+/,g=/-->/,d=/^WEBVTT([ \t].*)?$/,p=function(t,e){this.window=t,this.state="INITIAL",this.buffer="",this.decoder=e||new b,this.regionList=[],this.maxCueBatch=1e3};function b(){return{decode:function(t){if(!t)return"";if("string"!=typeof t)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(t))}}}function v(){this.values=Object.create(null)}v.prototype={set:function(t,e){this.get(t)||""===e||(this.values[t]=e)},get:function(t,e,r){return r?this.has(t)?this.values[t]:e[r]:this.has(t)?this.values[t]:e},has:function(t){return t in this.values},alt:function(t,e,r){for(var n=0;n<r.length;++n)if(e===r[n]){this.set(t,e);break}},integer:function(t,e){a.test(e)&&this.set(t,parseInt(e,10))},percent:function(t,e){return(e=parseFloat(e))>=0&&e<=100&&(this.set(t,e),!0)}};var E=new i.a(0,0,0),w="middle"===E.align?"middle":"center";function T(t,e,r){var n=t;function i(){var e=function(t){function e(t,e,r,n){return 3600*(0|t)+60*(0|e)+(0|r)+(0|n)/1e3}var r=t.match(s);return r?r[3]?e(r[1],r[2],r[3].replace(":",""),r[4]):r[1]>59?e(r[1],r[2],0,r[4]):e(0,r[1],r[2],r[4]):null}(t);if(null===e)throw new Error("Malformed timestamp: "+n);return t=t.replace(c,""),e}function a(){t=t.replace(h,"")}if(a(),e.startTime=i(),a(),"--\x3e"!==t.substr(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+n);t=t.substr(3),a(),e.endTime=i(),a(),function(t,e){var n=new v;!function(t,e,r,n){for(var i=n?t.split(n):[t],s=0;s<=i.length;s+=1)if("string"==typeof i[s]){var a=i[s].split(r);2===a.length&&e(a[0],a[1])}}(t,function(t,e){switch(t){case"region":for(var i=r.length-1;i>=0;i--)if(r[i].id===e){n.set(t,r[i].region);break}break;case"vertical":n.alt(t,e,["rl","lr"]);break;case"line":var s=e.split(","),a=s[0];n.integer(t,a),n.percent(t,a)&&n.set("snapToLines",!1),n.alt(t,a,["auto"]),2===s.length&&n.alt("lineAlign",s[1],["start",w,"end"]);break;case"position":var u=e.split(",");n.percent(t,u[0]),2===u.length&&n.alt("positionAlign",u[1],["start",w,"end","line-left","line-right","auto"]);break;case"size":n.percent(t,e);break;case"align":n.alt(t,e,["start",w,"end","left","right"])}},l,f),e.region=n.get("region",null),e.vertical=n.get("vertical","");var i=n.get("line","auto");"auto"===i&&-1===E.line&&(i=-1),e.line=i,e.lineAlign=n.get("lineAlign","start"),e.snapToLines=n.get("snapToLines",!0),e.size=n.get("size",100),e.align=n.get("align",w);var s=n.get("position","auto");"auto"===s&&50===E.position&&(s="start"===e.align||"left"===e.align?0:"end"===e.align||"right"===e.align?100:50),e.position=s}(t,e)}p.prototype={parse:function(t,e){var r,s=this;function a(){for(var t=s.buffer,e=0;e<t.length&&"\r"!==t[e]&&"\n"!==t[e];)++e;var r=t.substr(0,e);return"\r"===t[e]&&++e,"\n"===t[e]&&++e,s.buffer=t.substr(e),r}function c(){"CUETEXT"===s.state&&s.cue&&s.oncue&&s.oncue(s.cue),s.cue=null,s.state="INITIAL"===s.state?"BADWEBVTT":"BADCUE"}t&&(s.buffer+=s.decoder.decode(t,{stream:!0}));try{if("INITIAL"===s.state){if(!u.test(s.buffer))return this;var f=(r=a()).match(d);if(!f||!f[0])throw new Error("Malformed WebVTT signature.");s.state="HEADER"}}catch(t){return c(),this}var h=!1,p=0;!function t(){try{for(;s.buffer&&p<=s.maxCueBatch;){if(!u.test(s.buffer))return s.flush(),this;switch(h?h=!1:r=a(),s.state){case"HEADER":l.test(r)||r||(s.state="ID");break;case"NOTE":r||(s.state="ID");break;case"ID":if(o.test(r)){s.state="NOTE";break}if(!r)break;if(s.cue=new i.a(0,0,""),s.state="CUE",!g.test(r)){s.cue.id=r;break}case"CUE":try{T(r,s.cue,s.regionList)}catch(t){s.cue=null,s.state="BADCUE";break}s.state="CUETEXT";break;case"CUETEXT":var f=g.test(r);if(!r||f&&(h=!0)){s.oncue&&(p+=1,s.oncue(s.cue)),s.cue=null,s.state="ID";break}s.cue.text&&(s.cue.text+="\n"),s.cue.text+=r;break;case"BADCUE":r||(s.state="ID")}}if(p=0,s.buffer)Object(n.b)(t);else if(!e)return s.flush(),this}catch(t){return c(),this}}()},flush:function(){try{if(this.buffer+=this.decoder.decode(),(this.cue||"HEADER"===this.state)&&(this.buffer+="\n\n",this.parse(void 0,!0)),"INITIAL"===this.state)throw new Error("Malformed WebVTT signature.")}catch(t){throw t}return this.onflush&&this.onflush(),this}},e.default=p}}]);
(window.webpackJsonpjwplayer=window.webpackJsonpjwplayer||[]).push([[10],{97:function(t,e,r){"use strict";r.r(e);var n=r(42),i=r(67),s=/^(\d+):(\d{2})(:\d{2})?\.(\d{3})/,a=/^-?\d+$/,u=/\r\n|\n/,o=/^NOTE($|[ \t])/,c=/^[^\sa-zA-Z-]+/,l=/:/,f=/\s/,h=/^\s+/,g=/-->/,d=/^WEBVTT([ \t].*)?$/,p=function(t,e){this.window=t,this.state="INITIAL",this.buffer="",this.decoder=e||new b,this.regionList=[],this.maxCueBatch=1e3};function b(){return{decode:function(t){if(!t)return"";if("string"!=typeof t)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(t))}}}function v(){this.values=Object.create(null)}v.prototype={set:function(t,e){this.get(t)||""===e||(this.values[t]=e)},get:function(t,e,r){return r?this.has(t)?this.values[t]:e[r]:this.has(t)?this.values[t]:e},has:function(t){return t in this.values},alt:function(t,e,r){for(var n=0;n<r.length;++n)if(e===r[n]){this.set(t,e);break}},integer:function(t,e){a.test(e)&&this.set(t,parseInt(e,10))},percent:function(t,e){return(e=parseFloat(e))>=0&&e<=100&&(this.set(t,e),!0)}};var E=new i.a(0,0,0),w="middle"===E.align?"middle":"center";function T(t,e,r){var n=t;function i(){var e=function(t){function e(t,e,r,n){return 3600*(0|t)+60*(0|e)+(0|r)+(0|n)/1e3}var r=t.match(s);return r?r[3]?e(r[1],r[2],r[3].replace(":",""),r[4]):r[1]>59?e(r[1],r[2],0,r[4]):e(0,r[1],r[2],r[4]):null}(t);if(null===e)throw new Error("Malformed timestamp: "+n);return t=t.replace(c,""),e}function a(){t=t.replace(h,"")}if(a(),e.startTime=i(),a(),"--\x3e"!==t.substr(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+n);t=t.substr(3),a(),e.endTime=i(),a(),function(t,e){var n=new v;!function(t,e,r,n){for(var i=n?t.split(n):[t],s=0;s<=i.length;s+=1)if("string"==typeof i[s]){var a=i[s].split(r);if(2===a.length)e(a[0],a[1])}}(t,(function(t,e){switch(t){case"region":for(var i=r.length-1;i>=0;i--)if(r[i].id===e){n.set(t,r[i].region);break}break;case"vertical":n.alt(t,e,["rl","lr"]);break;case"line":var s=e.split(","),a=s[0];n.integer(t,a),n.percent(t,a)&&n.set("snapToLines",!1),n.alt(t,a,["auto"]),2===s.length&&n.alt("lineAlign",s[1],["start",w,"end"]);break;case"position":var u=e.split(",");n.percent(t,u[0]),2===u.length&&n.alt("positionAlign",u[1],["start",w,"end","line-left","line-right","auto"]);break;case"size":n.percent(t,e);break;case"align":n.alt(t,e,["start",w,"end","left","right"])}}),l,f),e.region=n.get("region",null),e.vertical=n.get("vertical","");var i=n.get("line","auto");"auto"===i&&-1===E.line&&(i=-1),e.line=i,e.lineAlign=n.get("lineAlign","start"),e.snapToLines=n.get("snapToLines",!0),e.size=n.get("size",100),e.align=n.get("align",w);var s=n.get("position","auto");"auto"===s&&50===E.position&&(s="start"===e.align||"left"===e.align?0:"end"===e.align||"right"===e.align?100:50),e.position=s}(t,e)}p.prototype={parse:function(t,e){var r,s=this;function a(){for(var t=s.buffer,e=0;e<t.length&&"\r"!==t[e]&&"\n"!==t[e];)++e;var r=t.substr(0,e);return"\r"===t[e]&&++e,"\n"===t[e]&&++e,s.buffer=t.substr(e),r}function c(){"CUETEXT"===s.state&&s.cue&&s.oncue&&s.oncue(s.cue),s.cue=null,s.state="INITIAL"===s.state?"BADWEBVTT":"BADCUE"}t&&(s.buffer+=s.decoder.decode(t,{stream:!0}));try{if("INITIAL"===s.state){if(!u.test(s.buffer))return this;var f=(r=a()).match(d);if(!f||!f[0])throw new Error("Malformed WebVTT signature.");s.state="HEADER"}}catch(t){return c(),this}var h=!1,p=0;!function t(){try{for(;s.buffer&&p<=s.maxCueBatch;){if(!u.test(s.buffer))return s.flush(),this;switch(h?h=!1:r=a(),s.state){case"HEADER":l.test(r)||r||(s.state="ID");break;case"NOTE":r||(s.state="ID");break;case"ID":if(o.test(r)){s.state="NOTE";break}if(!r)break;if(s.cue=new i.a(0,0,""),s.state="CUE",!g.test(r)){s.cue.id=r;break}case"CUE":try{T(r,s.cue,s.regionList)}catch(t){s.cue=null,s.state="BADCUE";break}s.state="CUETEXT";break;case"CUETEXT":var f=g.test(r);if(!r||f&&(h=!0)){s.oncue&&(p+=1,s.oncue(s.cue)),s.cue=null,s.state="ID";break}s.cue.text&&(s.cue.text+="\n"),s.cue.text+=r;break;case"BADCUE":r||(s.state="ID")}}if(p=0,s.buffer)Object(n.b)(t);else if(!e)return s.flush(),this}catch(t){return c(),this}}()},flush:function(){try{if(this.buffer+=this.decoder.decode(),(this.cue||"HEADER"===this.state)&&(this.buffer+="\n\n",this.parse(void 0,!0)),"INITIAL"===this.state)throw new Error("Malformed WebVTT signature.")}catch(t){throw t}return this.onflush&&this.onflush(),this}},e.default=p}}]);

View File

@@ -1,4 +1,4 @@
import React, { FunctionComponent, useEffect } from "react";
import React, { FunctionComponent, useState } from "react";
import { Route, Switch } from "react-router-dom";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Galleries from "./components/Galleries/Galleries";
@@ -11,26 +11,80 @@ import { Stats } from "./components/Stats";
import Studios from "./components/Studios/Studios";
import Tags from "./components/Tags/Tags";
import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser";
import { Sidebar } from "./components/Sidebar";
import { IconName } from "@blueprintjs/core";
export interface IMenuItem {
icon: IconName
text: string
href: string
}
interface IProps {}
export const App: FunctionComponent<IProps> = (props: IProps) => {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
function getSidebarClosedClass() {
if (!menuOpen) {
return " sidebar-closed";
}
return "";
}
const menuItems: IMenuItem[] = [
{
icon: "video",
text: "Scenes",
href: "/scenes"
},
{
href: "/scenes/markers",
icon: "map-marker",
text: "Markers"
},
{
href: "/galleries",
icon: "media",
text: "Galleries"
},
{
href: "/performers",
icon: "person",
text: "Performers"
},
{
href: "/studios",
icon: "mobile-video",
text: "Studios"
},
{
href: "/tags",
icon: "tag",
text: "Tags"
}
];
return (
<div className="bp3-dark">
<ErrorBoundary>
<MainNavbar />
<Switch>
<Route exact={true} path="/" component={Stats} />
<Route path="/scenes" component={Scenes} />
{/* <Route path="/scenes/:id" component={Scene} /> */}
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route component={PageNotFound} />
</Switch>
<MainNavbar onMenuToggle={() => setMenuOpen(!menuOpen)} menuItems={menuItems}/>
<Sidebar className={getSidebarClosedClass()} menuItems={menuItems}/>
<div className={"main" + getSidebarClosedClass()}>
<Switch>
<Route exact={true} path="/" component={Stats} />
<Route path="/scenes" component={Scenes} />
{/* <Route path="/scenes/:id" component={Scene} /> */}
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route component={PageNotFound} />
</Switch>
</div>
</ErrorBoundary>
</div>
);

View File

@@ -3,12 +3,17 @@ import {
NavbarDivider,
NavbarGroup,
NavbarHeading,
Button,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { Link, NavLink } from "react-router-dom";
import useLocation from "react-use/lib/useLocation";
import { IMenuItem } from "../App";
interface IProps {}
interface IProps {
onMenuToggle() : void
menuItems: IMenuItem[]
}
export const MainNavbar: FunctionComponent<IProps> = (props) => {
const [newButtonPath, setNewButtonPath] = useState<string | undefined>(undefined);
@@ -46,76 +51,38 @@ export const MainNavbar: FunctionComponent<IProps> = (props) => {
}
return (
<Navbar fixedToTop={true}>
<div>
<NavbarGroup align="left">
<NavbarHeading><Link to="/" className="bp3-button bp3-minimal">Stash</Link></NavbarHeading>
<NavbarDivider />
<>
<Navbar fixedToTop={true}>
<div>
<NavbarGroup align="left">
<Button className="menu-button" icon="menu" onClick={() => props.onMenuToggle()}/>
<NavbarHeading><Link to="/" className="bp3-button bp3-minimal">Stash</Link></NavbarHeading>
<NavbarDivider />
<NavLink
exact={true}
to="/scenes"
className="bp3-button bp3-minimal bp3-icon-video"
activeClassName="bp3-active"
>
Scenes
</NavLink>
<NavLink
exact={true}
to="/scenes/markers"
className="bp3-button bp3-minimal bp3-icon-map-marker"
activeClassName="bp3-active"
>
Markers
</NavLink>
<NavLink
exact={true}
to="/galleries"
className="bp3-button bp3-minimal bp3-icon-media"
activeClassName="bp3-active"
>
Galleries
</NavLink>
<NavLink
exact={true}
to="/performers"
className="bp3-button bp3-minimal bp3-icon-person"
activeClassName="bp3-active"
>
Performers
</NavLink>
<NavLink
exact={true}
to="/studios"
className="bp3-button bp3-minimal bp3-icon-mobile-video"
activeClassName="bp3-active"
>
Studios
</NavLink>
<NavLink
exact={true}
to="/tags"
className="bp3-button bp3-minimal bp3-icon-tag"
activeClassName="bp3-active"
>
Tags
</NavLink>
</NavbarGroup>
<NavbarGroup align="right">
{renderNewButton()}
<NavLink
exact={true}
to="/settings"
className="bp3-button bp3-minimal bp3-icon-cog"
activeClassName="bp3-active"
/>
</NavbarGroup>
</div>
</Navbar>
{props.menuItems.map((i) => {
return (
<NavLink
exact={true}
to={i.href}
className={"bp3-button bp3-minimal collapsible-navlink bp3-icon-" + i.icon}
activeClassName="bp3-active"
>
{i.text}
</NavLink>
);
})}
</NavbarGroup>
<NavbarGroup align="right">
{renderNewButton()}
<NavLink
exact={true}
to="/settings"
className="bp3-button bp3-minimal bp3-icon-cog"
activeClassName="bp3-active"
/>
</NavbarGroup>
</div>
</Navbar>
</>
);
};

View File

@@ -16,12 +16,23 @@ interface IProps {}
export const SettingsAboutPanel: FunctionComponent<IProps> = (props: IProps) => {
const { data, error, loading } = StashService.useVersion();
function maybeRenderTag() {
if (!data || !data.version || !data.version.version) { return; }
return (
<tr>
<td>Version:</td>
<td>{data.version.version}</td>
</tr>
);
}
function renderVersion() {
if (!data || !data.version) { return; }
return (
<>
<HTMLTable>
<tbody>
{maybeRenderTag()}
<tr>
<td>Build hash:</td>
<td>{data.version.hash}</td>

View File

@@ -1,4 +1,5 @@
import {
AnchorButton,
Button,
Divider,
FormGroup,
@@ -33,6 +34,7 @@ export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IPr
const [logOut, setLogOut] = useState<boolean>(true);
const [logLevel, setLogLevel] = useState<string>("Info");
const [logAccess, setLogAccess] = useState<boolean>(true);
const [excludes, setExcludes] = useState<(string)[]>([]);
const { data, error, loading } = StashService.useConfiguration();
@@ -48,6 +50,8 @@ export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IPr
logOut,
logLevel,
logAccess,
excludes,
});
useEffect(() => {
@@ -65,6 +69,7 @@ export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IPr
setLogOut(conf.general.logOut);
setLogLevel(conf.general.logLevel);
setLogAccess(conf.general.logAccess);
setExcludes(conf.general.excludes);
}
}, [data]);
@@ -72,6 +77,28 @@ export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IPr
setStashes(directories);
}
function excludeRegexChanged(idx: number, value: string) {
const newExcludes = excludes.map((regex, i)=> {
const ret = ( idx !== i ) ? regex : value ;
return ret
})
setExcludes(newExcludes);
}
function excludeRemoveRegex(idx: number) {
const newExcludes = excludes.filter((regex, i) => i!== idx );
setExcludes(newExcludes);
}
function excludeAddRegex() {
const demo = "sample\\.mp4$"
const newExcludes = excludes.concat(demo);
setExcludes(newExcludes);
}
async function onSave() {
try {
const result = await updateGeneralConfig();
@@ -148,6 +175,35 @@ export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IPr
>
<InputGroup value={generatedPath} onChange={(e: any) => setGeneratedPath(e.target.value)} />
</FormGroup>
<FormGroup
label="Excluded Patterns"
>
{ (excludes) ? excludes.map((regexp, i) => {
return(
<InputGroup
value={regexp}
onChange={(e: any) => excludeRegexChanged(i, e.target.value)}
rightElement={<Button icon="minus" minimal={true} intent="danger" onClick={(e: any) => excludeRemoveRegex(i)} />}
/>
);
}) : null
}
<Button icon="plus" minimal={true} onClick={(e: any) => excludeAddRegex()} />
<div>
<p>
<AnchorButton
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rightIcon="help"
text="Regexps of files/paths to exclude from Scan and add to Clean"
minimal={true}
target="_blank"
/>
</p>
</div>
</FormGroup>
</FormGroup>
<Divider />

View File

@@ -5,11 +5,11 @@ import {
FormGroup,
H4,
Spinner,
TextArea
TextArea,
NumericInput
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react";
import { useInterfaceLocalForage } from "../../hooks/LocalForage";
import { StashService } from "../../core/StashService";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
@@ -17,12 +17,21 @@ import { ToastUtils } from "../../utils/toasts";
interface IProps {}
export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
const {data, setData} = useInterfaceLocalForage();
const config = StashService.useConfiguration();
const [soundOnPreview, setSoundOnPreview] = useState<boolean>();
const [wallShowTitle, setWallShowTitle] = useState<boolean>();
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>();
const [showStudioAsText, setShowStudioAsText] = useState<boolean>();
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>();
const updateInterfaceConfig = StashService.useConfigureInterface({
soundOnPreview,
wallShowTitle,
maximumLoopDuration,
autostartVideo,
showStudioAsText,
css,
cssEnabled
});
@@ -30,6 +39,12 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
useEffect(() => {
if (!config.data || !config.data.configuration || !!config.error) { return; }
if (!!config.data.configuration.interface) {
let iCfg = config.data.configuration.interface;
setSoundOnPreview(iCfg.soundOnPreview !== undefined ? iCfg.soundOnPreview : true);
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true);
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0);
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false);
setShowStudioAsText(iCfg.showStudioAsText !== undefined ? iCfg.showStudioAsText : false);
setCSS(config.data.configuration.interface.css || "");
setCSSEnabled(config.data.configuration.interface.cssEnabled || false);
}
@@ -55,26 +70,53 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
helperText="Configuration for wall items"
>
<Checkbox
checked={!!data ? data.wall.textContainerEnabled : true}
checked={wallShowTitle}
label="Display title and tags"
onChange={() => {
if (!data) { return; }
const newSettings = _.cloneDeep(data);
newSettings.wall.textContainerEnabled = !data.wall.textContainerEnabled;
setData(newSettings);
}}
onChange={() => setWallShowTitle(!wallShowTitle)}
/>
<Checkbox
checked={!!data ? data.wall.soundEnabled : true}
checked={soundOnPreview}
label="Enable sound"
onChange={() => setSoundOnPreview(!soundOnPreview)}
/>
</FormGroup>
<FormGroup
label="Scene List"
>
<Checkbox
checked={showStudioAsText}
label="Show Studios as text"
onChange={() => {
if (!data) { return; }
const newSettings = _.cloneDeep(data);
newSettings.wall.soundEnabled = !data.wall.soundEnabled;
setData(newSettings);
setShowStudioAsText(!showStudioAsText)
}}
/>
</FormGroup>
<FormGroup
label="Scene Player"
>
<Checkbox
checked={autostartVideo}
label="Auto-start video"
onChange={() => {
setAutostartVideo(!autostartVideo)
}}
/>
<FormGroup
label="Maximum loop duration"
helperText="Maximum scene duration - in seconds - where scene player will loop the video - 0 to disable"
>
<NumericInput
value={maximumLoopDuration}
type="number"
onValueChange={(value: number) => setMaximumLoopDuration(value)}
min={0}
minorStepSize={1}
/>
</FormGroup>
</FormGroup>
<FormGroup
label="Custom CSS"
@@ -94,10 +136,10 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
fill={true}
rows={16}>
</TextArea>
<Divider />
<Button intent="primary" onClick={() => onSave()}>Save</Button>
</FormGroup>
<Divider />
<Button intent="primary" onClick={() => onSave()}>Save</Button>
</>
);
};

View File

@@ -21,10 +21,14 @@ interface IProps {}
export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) => {
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
const [nameFromMetadata, setNameFromMetadata] = useState<boolean>(true);
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
const [status, setStatus] = useState<string>("");
const [progress, setProgress] = useState<number | undefined>(undefined);
const [autoTagPerformers, setAutoTagPerformers] = useState<boolean>(true);
const [autoTagStudios, setAutoTagStudios] = useState<boolean>(true);
const [autoTagTags, setAutoTagTags] = useState<boolean>(true);
const jobStatus = StashService.useJobStatus();
const metadataUpdate = StashService.useMetadataUpdate();
@@ -42,6 +46,8 @@ export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) =>
return "Exporting to JSON";
case "Import":
return "Importing from JSON";
case "Auto Tag":
return "Auto tagging scenes";
}
return "Idle";
@@ -122,7 +128,7 @@ export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) =>
async function onScan() {
try {
await StashService.queryMetadataScan({nameFromMetadata});
await StashService.queryMetadataScan({useFileMetadata: useFileMetadata});
ToastUtils.success("Started scan");
jobStatus.refetch();
} catch (e) {
@@ -130,6 +136,25 @@ export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) =>
}
}
function getAutoTagInput() {
var wildcard = ["*"];
return {
performers: autoTagPerformers ? wildcard : [],
studios: autoTagStudios ? wildcard : [],
tags: autoTagTags ? wildcard : []
}
}
async function onAutoTag() {
try {
await StashService.queryMetadataAutoTag(getAutoTagInput());
ToastUtils.success("Started auto tagging");
jobStatus.refetch();
} catch (e) {
ErrorUtils.handle(e);
}
}
function maybeRenderStop() {
if (!status || status === "Idle") {
return undefined;
@@ -174,17 +199,44 @@ export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) =>
inline={true}
>
<Checkbox
checked={nameFromMetadata}
label="Set name from metadata (if present)"
onChange={() => setNameFromMetadata(!nameFromMetadata)}
checked={useFileMetadata}
label="Set name, date, details from metadata (if present)"
onChange={() => setUseFileMetadata(!useFileMetadata)}
/>
<Button id="scan" text="Scan" onClick={() => onScan()} />
</FormGroup>
<Divider />
<H4>Auto Tagging</H4>
<FormGroup
helperText="Auto-tag content based on filenames."
labelFor="autoTag"
inline={true}
>
<Checkbox
checked={autoTagPerformers}
label="Performers"
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
/>
<Checkbox
checked={autoTagStudios}
label="Studios"
onChange={() => setAutoTagStudios(!autoTagStudios)}
/>
<Checkbox
checked={autoTagTags}
label="Tags"
onChange={() => setAutoTagTags(!autoTagTags)}
/>
<Button id="autoTag" text="Auto Tag" onClick={() => onAutoTag()} />
</FormGroup>
<FormGroup>
<Link className="bp3-button" to={"/sceneFilenameParser"}>
Scene Filename Parser
</Link>
<FormGroup>
</FormGroup>
<Divider />

View File

@@ -22,10 +22,12 @@ interface IProps {
onToggleEdit: () => void;
onSave: () => void;
onDelete: () => void;
onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
// TODO: only for performers. make generic
onDisplayFreeOnesDialog?: () => void;
scrapers?: GQL.ListPerformerScrapersListPerformerScrapers[];
onDisplayScraperDialog?: (scraper: GQL.ListPerformerScrapersListPerformerScrapers) => void;
}
export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
@@ -57,15 +59,21 @@ export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
return <FileInput text="Choose image..." onInputChange={props.onImageChange} inputProps={{accept: ".jpg,.jpeg"}} />;
}
function renderScraperMenuItem(scraper : GQL.ListPerformerScrapersListPerformerScrapers) {
return (
<MenuItem
text={scraper.name}
onClick={() => { if (props.onDisplayScraperDialog) { props.onDisplayScraperDialog(scraper); }}}
/>
);
}
function renderScraperMenu() {
if (!props.performer) { return; }
if (!props.isEditing) { return; }
const scraperMenu = (
<Menu>
<MenuItem
text="FreeOnes"
onClick={() => { if (props.onDisplayFreeOnesDialog) { props.onDisplayFreeOnesDialog(); }}}
/>
{props.scrapers ? props.scrapers.map((s) => renderScraperMenuItem(s)) : undefined}
</Menu>
);
return (
@@ -75,6 +83,15 @@ export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
);
}
function renderAutoTagButton() {
if (props.isNew || props.isEditing) { return; }
if (!!props.onAutoTag) {
return (<Button text="Auto Tag" onClick={() => {
if (props.onAutoTag) { props.onAutoTag() }
}}></Button>)
}
}
function renderScenesButton() {
if (props.isEditing) { return; }
let linkSrc: string = "#";
@@ -129,6 +146,7 @@ export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
{renderImageInput()}
{renderSaveButton()}
{renderAutoTagButton()}
{renderScenesButton()}
{renderDeleteButton()}
</Navbar.Group>

View File

@@ -0,0 +1,127 @@
import React, { FunctionComponent, useState, useEffect } from "react";
import { InputGroup, ButtonGroup, Button, IInputGroupProps, HTMLInputProps, ControlGroup } from "@blueprintjs/core";
import { TextUtils } from "../../utils/text";
import { FIXED, NUMERIC_INPUT } from "@blueprintjs/core/lib/esm/common/classes";
interface IProps {
disabled?: boolean
numericValue: number
onValueChange(valueAsNumber: number): void
onReset?(): void
}
export const DurationInput: FunctionComponent<HTMLInputProps & IProps> = (props: IProps) => {
const [value, setValue] = useState<string>(secondsToString(props.numericValue));
useEffect(() => {
setValue(secondsToString(props.numericValue));
}, [props.numericValue]);
function secondsToString(seconds : number) {
let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) {
ret = ret.substr(3);
if (ret.startsWith("0")) {
ret = ret.substr(1);
}
}
return ret;
}
function stringToSeconds(v : string) {
if (!v) {
return 0;
}
let splits = v.split(":");
if (splits.length > 3) {
return 0;
}
let seconds = 0;
let factor = 1;
while(splits.length > 0) {
let thisSplit = splits.pop();
if (thisSplit == undefined) {
return 0;
}
let thisInt = parseInt(thisSplit, 10);
if (isNaN(thisInt)) {
return 0;
}
seconds += factor * thisInt;
factor *= 60;
}
return seconds;
}
function increment() {
let seconds = stringToSeconds(value);
seconds += 1;
props.onValueChange(seconds);
}
function decrement() {
let seconds = stringToSeconds(value);
seconds -= 1;
props.onValueChange(seconds);
}
function renderButtons() {
return (
<ButtonGroup
vertical={true}
className={FIXED}
>
<Button
icon="chevron-up"
disabled={props.disabled}
onClick={() => increment()}
/>
<Button
icon="chevron-down"
disabled={props.disabled}
onClick={() => decrement()}
/>
</ButtonGroup>
)
}
function onReset() {
if (props.onReset) {
props.onReset();
}
}
function maybeRenderReset() {
if (props.onReset) {
return (
<Button
icon="time"
onClick={() => onReset()}
/>
)
}
}
return (
<ControlGroup className={NUMERIC_INPUT}>
<InputGroup
disabled={props.disabled}
value={value}
onChange={(e : any) => setValue(e.target.value)}
onBlur={() => props.onValueChange(stringToSeconds(value))}
placeholder="hh:mm:ss"
rightElement={maybeRenderReset()}
/>
{renderButtons()}
</ControlGroup>
)
};

View File

@@ -0,0 +1,32 @@
import {
MenuItem,
Menu,
IconName,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { IMenuItem } from "../App";
interface IProps {
className: string
menuItems: IMenuItem[]
}
export const Sidebar: FunctionComponent<IProps> = (props) => {
return (
<>
<div className={"sidebar" + props.className}>
<Menu large={true}>
{props.menuItems.map((i) => {
return (
<MenuItem
icon={i.icon}
text={i.text}
href={i.href}
/>
)
})}
</Menu>
</div>
</>
);
};

View File

@@ -15,6 +15,8 @@ import { IBaseProps } from "../../../models";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ToastUtils } from "../../../utils/toasts";
import { ImageUtils } from "../../../utils/image";
interface IProps extends IBaseProps {}
@@ -61,6 +63,13 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
}
}, [studio]);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setImage(this.result as string);
}
ImageUtils.addPasteImageHook(onImageLoad);
if (!isNew && !isEditing) {
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
if (!!error) { return <>error...</>; }
@@ -96,6 +105,18 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
setIsLoading(false);
}
async function onAutoTag() {
if (!studio || !studio.id) {
return;
}
try {
await StashService.queryMetadataAutoTag({ studios: [studio.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onDelete() {
setIsLoading(true);
try {
@@ -110,14 +131,7 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
const file: File = (event.target as any).files[0];
const reader: FileReader = new FileReader();
reader.onloadend = (e) => {
setImagePreview(reader.result as string);
setImage(reader.result as string);
};
reader.readAsDataURL(file);
ImageUtils.onImageChange(event, onImageLoad);
}
// TODO: CSS class
@@ -135,6 +149,7 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
onToggleEdit={() => { setIsEditing(!isEditing); updateStudioEditState(studio); }}
onSave={onSave}
onDelete={onDelete}
onAutoTag={onAutoTag}
onImageChange={onImageChange}
/>
<h1 className="bp3-heading">
@@ -148,7 +163,7 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
<HTMLTable style={{width: "100%"}}>
<tbody>
{TableUtils.renderEditableTextTableRow({title: "URL", value: url, isEditing, onChange: setUrl})}
{TableUtils.renderInputGroup({title: "URL", value: url, isEditing, onChange: setUrl})}
</tbody>
</HTMLTable>
</div>

View File

@@ -77,6 +77,18 @@ export const TagList: FunctionComponent<IProps> = (props: IProps) => {
}
}
async function onAutoTag(tag : GQL.TagDataFragment) {
if (!tag) {
return;
}
try {
await StashService.queryMetadataAutoTag({ tags: [tag.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onDelete() {
try {
await deleteTag();
@@ -115,6 +127,7 @@ export const TagList: FunctionComponent<IProps> = (props: IProps) => {
<div key={tag.id} className="tag-list-row">
<span onClick={() => setEditingTag(tag)}>{tag.name}</span>
<div style={{float: "right"}}>
<Button text="Auto Tag" onClick={() => onAutoTag(tag)}></Button>
<Link className="bp3-button" to={NavigationUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
<Link className="bp3-button" to={NavigationUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count}

View File

@@ -45,6 +45,7 @@
width: 100%;
height: 100%;
transition: transform .5s;
max-height: 253px;
}
.scene-wall-item-container video {
@@ -87,7 +88,7 @@
.wall.grid-item video, .wall.grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
}
.wall.grid-item {

View File

@@ -2,10 +2,10 @@ import _ from "lodash";
import React, { FunctionComponent, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { useInterfaceLocalForage } from "../../hooks/LocalForage";
import { VideoHoverHook } from "../../hooks/VideoHover";
import { TextUtils } from "../../utils/text";
import { NavigationUtils } from "../../utils/navigation";
import { StashService } from "../../core/StashService";
interface IWallItemProps {
scene?: GQL.SlimSceneDataFragment;
@@ -21,9 +21,9 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
const [screenshotPath, setScreenshotPath] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<JSX.Element[]>([]);
const config = StashService.useConfiguration();
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: true});
const interfaceSettings = useInterfaceLocalForage();
const showTextContainer = !!interfaceSettings.data ? interfaceSettings.data.wall.textContainerEnabled : true;
const showTextContainer = !!config.data && !!config.data.configuration ? config.data.configuration.interface.wallShowTitle : true;
function onMouseEnter() {
VideoHoverHook.onMouseEnter(videoHoverHook);

View File

@@ -5,6 +5,7 @@ import {
FormGroup,
HTMLSelect,
InputGroup,
Tooltip,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
@@ -188,7 +189,19 @@ export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterP
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
return (
<>
<Button onClick={() => onToggle()} active={isOpen} large={true}>Filter</Button>
<Tooltip
hoverOpenDelay={200}
content="Filter"
>
<Button
icon="filter"
onClick={() => onToggle()}
active={isOpen}
large={true}
>
</Button>
</Tooltip>
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
<div className="dialog-content">
{maybeRenderFilterSelect()}

View File

@@ -1,22 +1,27 @@
import {
AnchorButton,
Button,
ButtonGroup,
ControlGroup,
HTMLSelect,
InputGroup,
Menu,
MenuItem,
Popover,
Tag,
Tooltip,
Slider,
} from "@blueprintjs/core";
import { debounce } from "lodash";
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
import React, { FunctionComponent, SyntheticEvent, useEffect, useState } from "react";
import { Criterion } from "../../models/list-filter/criteria/criterion";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode } from "../../models/list-filter/types";
import { AddFilter } from "./AddFilter";
interface IListFilterOperation {
text: string;
onClick: () => void;
}
interface IListFilterProps {
onChangePageSize: (pageSize: number) => void;
onChangeQuery: (query: string) => void;
@@ -25,8 +30,11 @@ interface IListFilterProps {
onChangeDisplayMode: (displayMode: DisplayMode) => void;
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onRemoveCriterion: (criterion: Criterion) => void;
zoomIndex?: number;
onChangeZoom?: (zoomIndex: number) => void;
onSelectAll?: () => void;
onSelectNone?: () => void;
otherOperations?: IListFilterOperation[];
filter: ListFilterModel;
}
@@ -111,13 +119,14 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
}
}
return props.filter.displayModeOptions.map((option) => (
<Button
key={option}
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
icon={getIcon(option)}
text={getLabel(option)}
/>
<Tooltip content={getLabel(option)} hoverOpenDelay={200}>
<Button
key={option}
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
icon={getIcon(option)}
/>
</Tooltip>
));
}
@@ -150,23 +159,70 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
function renderSelectAll() {
if (props.onSelectAll) {
return <Button onClick={() => onSelectAll()} text="Select All"/>;
return <MenuItem onClick={() => onSelectAll()} text="Select All" />;
}
}
function renderSelectNone() {
if (props.onSelectNone) {
return <Button onClick={() => onSelectNone()} text="Select None"/>;
return <MenuItem onClick={() => onSelectNone()} text="Select None" />;
}
}
function renderSelectAllNone() {
return (
<>
{renderSelectAll()}
{renderSelectNone()}
</>
);
function renderMore() {
let options = [];
options.push(renderSelectAll());
options.push(renderSelectNone());
if (props.otherOperations) {
props.otherOperations.forEach((o) => {
options.push(<MenuItem onClick={o.onClick} text={o.text} />);
});
}
options = options.filter((o) => !!o);
let menuItems = options as JSX.Element[];
function renderMoreOptions() {
return (
<>
{menuItems}
</>
)
}
if (menuItems.length > 0) {
return (
<Popover position="bottom">
<Button icon="more"/>
<Menu>{renderMoreOptions()}</Menu>
</Popover>
);
}
}
function onChangeZoom(v : number) {
if (props.onChangeZoom) {
props.onChangeZoom(v);
}
}
function maybeRenderZoom() {
if (props.onChangeZoom) {
return (
<span className="zoom-slider">
<Slider
min={0}
value={props.zoomIndex}
initialValue={props.zoomIndex}
max={3}
labelRenderer={false}
onChange={(v) => onChangeZoom(v)}
/>
</span>
);
}
}
function render() {
@@ -188,18 +244,23 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
value={props.filter.itemsPerPage}
className="filter-item"
/>
<ControlGroup className="filter-item">
<AnchorButton
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
onClick={onChangeSortDirection}
>
{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
</AnchorButton>
<ButtonGroup className="filter-item">
<Popover position="bottom">
<Button large={true}>{props.filter.sortBy}</Button>
<Menu>{renderSortByOptions()}</Menu>
</Popover>
</ControlGroup>
<Tooltip
content={props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
hoverOpenDelay={200}
>
<Button
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
onClick={onChangeSortDirection}
/>
</Tooltip>
</ButtonGroup>
<AddFilter
filter={props.filter}
@@ -212,8 +273,10 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
{renderDisplayModeOptions()}
</ButtonGroup>
{maybeRenderZoom()}
<ButtonGroup className="filter-item">
{renderSelectAllNone()}
{renderMore()}
</ButtonGroup>
</div>
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>

View File

@@ -1,10 +1,10 @@
import {
Button,
Classes,
Dialog,
EditableText,
HTMLTable,
Spinner,
Tabs,
Tab,
Button,
AnchorButton,
IconName,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react";
@@ -12,71 +12,29 @@ import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { IBaseProps } from "../../../models";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { FreeOnesPerformerSuggest } from "../../select/FreeOnesPerformerSuggest";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { TextUtils } from "../../../utils/text";
import Lightbox from "react-images";
interface IPerformerProps extends IBaseProps {}
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
const isNew = props.match.params.id === "new";
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<"freeones" | undefined>(undefined);
const [scrapePerformerName, setScrapePerformerName] = useState<string>("");
// Editing performer state
const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [aliases, setAliases] = useState<string | undefined>(undefined);
const [favorite, setFavorite] = useState<boolean | undefined>(undefined);
const [birthdate, setBirthdate] = useState<string | undefined>(undefined);
const [ethnicity, setEthnicity] = useState<string | undefined>(undefined);
const [country, setCountry] = useState<string | undefined>(undefined);
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
const [height, setHeight] = useState<string | undefined>(undefined);
const [measurements, setMeasurements] = useState<string | undefined>(undefined);
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
const [careerLength, setCareerLength] = useState<string | undefined>(undefined);
const [tattoos, setTattoos] = useState<string | undefined>(undefined);
const [piercings, setPiercings] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
const [twitter, setTwitter] = useState<string | undefined>(undefined);
const [instagram, setInstagram] = useState<string | undefined>(undefined);
// Performer state
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
// Network state
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindPerformer(props.match.params.id);
const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput);
const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput);
const deletePerformer = StashService.usePerformerDestroy(getPerformerInput() as GQL.PerformerDestroyInput);
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
setFavorite((state as GQL.PerformerDataFragment).favorite);
}
setName(state.name);
setAliases(state.aliases);
setBirthdate(state.birthdate);
setEthnicity(state.ethnicity);
setCountry(state.country);
setEyeColor(state.eye_color);
setHeight(state.height);
setMeasurements(state.measurements);
setFakeTits(state.fake_tits);
setCareerLength(state.career_length);
setTattoos(state.tattoos);
setPiercings(state.piercings);
setUrl(state.url);
setTwitter(state.twitter);
setInstagram(state.instagram);
}
const updatePerformer = StashService.usePerformerUpdate();
const createPerformer = StashService.usePerformerCreate();
const deletePerformer = StashService.usePerformerDestroy();
useEffect(() => {
setIsLoading(loading);
@@ -86,53 +44,25 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
useEffect(() => {
setImagePreview(performer.image_path);
setImage(undefined);
updatePerformerEditState(performer);
if (!isNew) {
setIsEditing(false);
}
}, [performer]);
if (!isNew && !isEditing) {
if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
if (!!error) { return <>error...</>; }
function onImageChange(image: string) {
setImagePreview(image);
}
function getPerformerInput() {
const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = {
name,
aliases,
favorite,
birthdate,
ethnicity,
country,
eye_color: eyeColor,
height,
measurements,
fake_tits: fakeTits,
career_length: careerLength,
tattoos,
piercings,
url,
twitter,
instagram,
image,
};
if (!isNew) {
(performerInput as GQL.PerformerUpdateInput).id = props.match.params.id;
}
return performerInput;
if ((!isNew && (!data || !data.findPerformer)) || isLoading) {
return <Spinner size={Spinner.SIZE_LARGE} />;
}
if (!!error) { return <>error...</>; }
async function onSave() {
async function onSave(performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) {
setIsLoading(true);
try {
if (!isNew) {
const result = await updatePerformer();
const result = await updatePerformer({variables: performer as GQL.PerformerUpdateInput});
setPerformer(result.data.performerUpdate);
} else {
const result = await createPerformer();
const result = await createPerformer({variables: performer as GQL.PerformerCreateInput});
setPerformer(result.data.performerCreate);
props.history.push(`/performers/${result.data.performerCreate.id}`);
}
@@ -145,7 +75,7 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
async function onDelete() {
setIsLoading(true);
try {
const result = await deletePerformer();
await deletePerformer({variables: {id: props.match.params.id}});
} catch (e) {
ErrorUtils.handle(e);
}
@@ -155,146 +85,164 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
props.history.push(`/performers`);
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
const file: File = (event.target as any).files[0];
const reader: FileReader = new FileReader();
reader.onloadend = (e) => {
setImagePreview(reader.result as string);
setImage(reader.result as string);
};
reader.readAsDataURL(file);
}
function onDisplayFreeOnesDialog() {
setIsDisplayingScraperDialog("freeones");
}
async function onScrapeFreeOnes() {
setIsLoading(true);
try {
if (!scrapePerformerName) { return; }
const result = await StashService.queryScrapeFreeones(scrapePerformerName);
if (!result.data || !result.data.scrapeFreeones) { return; }
updatePerformerEditState(result.data.scrapeFreeones);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setIsDisplayingScraperDialog(undefined);
function renderTabs() {
function renderEditPanel() {
return (
<PerformerDetailsPanel
performer={performer}
isEditing={true}
isNew={isNew}
onDelete={onDelete}
onSave={onSave}
onImageChange={onImageChange}
/>
);
}
// render tabs if not new
if (!isNew) {
return (
<>
<Tabs
renderActiveTabPanelOnly={true}
large={true}
>
<Tab id="performer-details-panel" title="Details" panel={<PerformerDetailsPanel performer={performer} isEditing={false}/>} />
<Tab id="performer-scenes-panel" title="Scenes" panel={<PerformerScenesPanel performer={performer} base={props} />} />
<Tab id="performer-edit-panel" title="Edit" panel={renderEditPanel()} />
<Tab id="performer-operations-panel" title="Operations" panel={<PerformerOperationsPanel performer={performer} />} />
</Tabs>
</>
);
} else {
return renderEditPanel();
}
setIsLoading(false);
}
function renderEthnicity() {
return TableUtils.renderHtmlSelect({
title: "Ethnicity",
value: ethnicity,
isEditing,
onChange: (value: string) => setEthnicity(value),
selectOptions: ["white", "black", "asian", "hispanic"],
});
}
function renderScraperDialog() {
return (
<Dialog
isOpen={!!isDisplayingScraperDialog}
onClose={() => setIsDisplayingScraperDialog(undefined)}
title="Scrape"
>
<div className="dialog-content">
<FreeOnesPerformerSuggest
placeholder="Performer name"
style={{width: "100%"}}
onQueryChange={(query) => setScrapePerformerName(query)}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={() => onScrapeFreeOnes()}>Scrape</Button>
function maybeRenderAge() {
if (performer && performer.birthdate) {
// calculate the age from birthdate. In future, this should probably be
// provided by the server
return (
<>
<div>
<span className="age">{TextUtils.age(performer.birthdate)}</span>
<span className="age-tail"> years old</span>
</div>
</div>
</Dialog>
</>
);
}
}
function maybeRenderAliases() {
if (performer && performer.aliases) {
return (
<>
<div>
<span className="alias-head">Also known as </span>
<span className="alias">{performer.aliases}</span>
</div>
</>
);
}
}
function setFavorite(v : boolean) {
performer.favorite = v;
onSave(performer);
}
function renderIcons() {
function maybeRenderURL(url?: string, icon?: IconName) {
if (performer.url) {
if (!icon) {
icon = "link";
}
return (
<>
<AnchorButton
icon={icon}
href={performer.url}
minimal={true}
/>
</>
)
}
}
return (
<>
<span className="name-icons">
<Button
icon="heart"
className={performer.favorite ? "favorite" : "not-favorite"}
onClick={() => setFavorite(!performer.favorite)}
minimal={true}
/>
{maybeRenderURL(performer.url)}
{/* TODO - render instagram and twitter links with icons */}
</span>
</>
);
}
return (
<>
{renderScraperDialog()}
function renderNewView() {
return (
<div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container">
<img className="performer" src={imagePreview} />
</div>
<div className="column is-half details-detail-container">
<DetailsEditNavbar
performer={performer}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }}
onSave={onSave}
onDelete={onDelete}
onImageChange={onImageChange}
onDisplayFreeOnesDialog={onDisplayFreeOnesDialog}
/>
<h1 className="bp3-heading">
<EditableText
disabled={!isEditing}
value={name}
placeholder="Name"
onChange={(value) => setName(value)}
/>
</h1>
<h6 className="bp3-heading">
<span style={{fontWeight: 300}}>Aliases: </span>
<EditableText
disabled={!isEditing}
value={aliases}
placeholder="Aliases"
onChange={(value) => setAliases(value)}
/>
</h6>
<div>
<span style={{fontWeight: 300}}>Favorite:</span>
<Button
icon="heart"
disabled={!isEditing}
className={favorite ? "favorite" : undefined}
onClick={() => setFavorite(!favorite)}
minimal={true}
/>
</div>
<HTMLTable style={{width: "100%"}}>
<tbody>
{TableUtils.renderEditableTextTableRow(
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing, onChange: setBirthdate})}
{renderEthnicity()}
{TableUtils.renderEditableTextTableRow(
{title: "Eye Color", value: eyeColor, isEditing, onChange: setEyeColor})}
{TableUtils.renderEditableTextTableRow(
{title: "Country", value: country, isEditing, onChange: setCountry})}
{TableUtils.renderEditableTextTableRow(
{title: "Height (CM)", value: height, isEditing, onChange: setHeight})}
{TableUtils.renderEditableTextTableRow(
{title: "Measurements", value: measurements, isEditing, onChange: setMeasurements})}
{TableUtils.renderEditableTextTableRow(
{title: "Fake Tits", value: fakeTits, isEditing, onChange: setFakeTits})}
{TableUtils.renderEditableTextTableRow(
{title: "Career Length", value: careerLength, isEditing, onChange: setCareerLength})}
{TableUtils.renderEditableTextTableRow(
{title: "Tattoos", value: tattoos, isEditing, onChange: setTattoos})}
{TableUtils.renderEditableTextTableRow(
{title: "Piercings", value: piercings, isEditing, onChange: setPiercings})}
{TableUtils.renderEditableTextTableRow(
{title: "URL", value: url, isEditing, onChange: setUrl})}
{TableUtils.renderEditableTextTableRow(
{title: "Twitter", value: twitter, isEditing, onChange: setTwitter})}
{TableUtils.renderEditableTextTableRow(
{title: "Instagram", value: instagram, isEditing, onChange: setInstagram})}
</tbody>
</HTMLTable>
{renderTabs()}
</div>
</div>
);
}
const photos = [{src: imagePreview || "", caption: "Image"}];
function openLightbox() {
setLightboxIsOpen(true);
}
function closeLightbox() {
setLightboxIsOpen(false);
}
if (isNew) {
return renderNewView();
}
return (
<>
<div id="performer-page">
<div className="details-image-container">
<img className="performer" src={imagePreview} onClick={openLightbox} />
</div>
<div className="performer-head">
<h1 className="bp3-heading">
{performer.name}
{renderIcons()}
</h1>
{maybeRenderAliases()}
{maybeRenderAge()}
</div>
<div className="performer-body">
<div className="details-detail-container">
{renderTabs()}
</div>
</div>
</div>
<Lightbox
images={photos}
onClose={closeLightbox}
currentImage={0}
isOpen={lightboxIsOpen}
onClickImage={() => window.open(imagePreview, "_blank")}
width={9999}
/>
</>
);
};

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