mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
115 Commits
issue-5298
...
issues/657
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb941fec5 | ||
|
|
c8a8154e83 | ||
|
|
3ae3ea6102 | ||
|
|
6ef599e894 | ||
|
|
d1479ca4e5 | ||
|
|
26db935fad | ||
|
|
7aa7276fa3 | ||
|
|
5628fbc5d3 | ||
|
|
5cf41c8c8e | ||
|
|
07b483038a | ||
|
|
8dec195c2d | ||
|
|
d64b3b711c | ||
|
|
2b38361a26 | ||
|
|
b278525647 | ||
|
|
f629191b28 | ||
|
|
9eda7c2f60 | ||
|
|
b5de30a295 | ||
|
|
88eb46380c | ||
|
|
ed0fb53ae0 | ||
|
|
cf5d60f511 | ||
|
|
b76edffc5d | ||
|
|
badf9ec35e | ||
|
|
0e54a5ceb0 | ||
|
|
fe85b1eff9 | ||
|
|
d252a416d0 | ||
|
|
244d70e20e | ||
|
|
6f5a7d1f0a | ||
|
|
b8c5e15217 | ||
|
|
a05500342a | ||
|
|
6bb22146b2 | ||
|
|
09044b92bf | ||
|
|
2c8e7d709f | ||
|
|
bef4e3fbd5 | ||
|
|
5b3785f164 | ||
|
|
ed3a239366 | ||
|
|
2a5b59a96a | ||
|
|
d7d7530c78 | ||
|
|
211f06963e | ||
|
|
0fa132cf60 | ||
|
|
77d0008c6d | ||
|
|
b4969add27 | ||
|
|
6049b21d22 | ||
|
|
deada580e5 | ||
|
|
95b1bce917 | ||
|
|
579fc66275 | ||
|
|
c9fa3b76d9 | ||
|
|
cf3489efdc | ||
|
|
9b709ef614 | ||
|
|
c0260781a5 | ||
|
|
fa80454891 | ||
|
|
1e6bf74385 | ||
|
|
3b5e1db2aa | ||
|
|
6eed5390e1 | ||
|
|
81e8ccb5a9 | ||
|
|
45dc892a54 | ||
|
|
dc7ebadb16 | ||
|
|
956af44a29 | ||
|
|
09ba41b2bb | ||
|
|
91e1ec520f | ||
|
|
56822dbdc5 | ||
|
|
39d3e63cbf | ||
|
|
66ceceeaf1 | ||
|
|
65e82a0cf6 | ||
|
|
d962247016 | ||
|
|
08b87431c3 | ||
|
|
b23b0267ad | ||
|
|
772c69c359 | ||
|
|
e9f5e7d6b4 | ||
|
|
af11189718 | ||
|
|
5b62cc66d4 | ||
|
|
857e673d3e | ||
|
|
b2df819283 | ||
|
|
f71d0ac2dd | ||
|
|
b23c3cd618 | ||
|
|
1691280d1b | ||
|
|
7a8a2c7687 | ||
|
|
f64cd5bfac | ||
|
|
65327a6102 | ||
|
|
62babfb332 | ||
|
|
67b1dd8dd0 | ||
|
|
25fdf676d2 | ||
|
|
1580cf9bd9 | ||
|
|
badebfd8f9 | ||
|
|
f1e54bfc73 | ||
|
|
ebfe5c4b5c | ||
|
|
11417590ee | ||
|
|
0980daa99e | ||
|
|
5f0d4e811d | ||
|
|
a4816b4cc9 | ||
|
|
ba0102f2a6 | ||
|
|
fe41561dfe | ||
|
|
7fded66bfa | ||
|
|
945d679158 | ||
|
|
7db394bbea | ||
|
|
eb9d0705bc | ||
|
|
0fd7a2ac20 | ||
|
|
e2dff05081 | ||
|
|
061d21dede | ||
|
|
88a149c085 | ||
|
|
d994df2900 | ||
|
|
39fd8a6550 | ||
|
|
877491e62b | ||
|
|
3d044896ad | ||
|
|
e92a0cb126 | ||
|
|
49ee2b1cf0 | ||
|
|
714afd98b4 | ||
|
|
ab77a9334c | ||
|
|
d7bc248cf4 | ||
|
|
22dc0bbf77 | ||
|
|
be8f57d6ca | ||
|
|
c3702c5bd2 | ||
|
|
38ade2b4b6 | ||
|
|
c7b53777dc | ||
|
|
8fe32fd778 | ||
|
|
1ced75a45e |
@@ -5,20 +5,39 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/hash/imagephash"
|
||||
"github.com/stashapp/stash/pkg/hash/videophash"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func customUsage() {
|
||||
fmt.Fprintf(os.Stderr, "Usage:\n")
|
||||
fmt.Fprintf(os.Stderr, "%s [OPTIONS] VIDEOFILE...\n\nOptions:\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "%s [OPTIONS] FILE...\n\nOptions:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
|
||||
// Determine if this is a video or image file based on extension
|
||||
ext := filepath.Ext(inputfile)
|
||||
ext = ext[1:] // remove the leading dot
|
||||
|
||||
// Common image extensions
|
||||
imageExts := map[string]bool{
|
||||
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, "avif": true,
|
||||
}
|
||||
|
||||
if imageExts[ext] {
|
||||
return printImagePhash(ff, inputfile, quiet)
|
||||
}
|
||||
|
||||
return printVideoPhash(ff, ffp, inputfile, quiet)
|
||||
}
|
||||
|
||||
func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
|
||||
ffvideoFile, err := ffp.NewVideoFile(inputfile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -46,6 +65,24 @@ func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet
|
||||
return nil
|
||||
}
|
||||
|
||||
func printImagePhash(ff *ffmpeg.FFMpeg, inputfile string, quiet *bool) error {
|
||||
imgFile := &models.ImageFile{
|
||||
BaseFile: &models.BaseFile{Path: inputfile},
|
||||
}
|
||||
|
||||
phash, err := imagephash.Generate(ff, imgFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *quiet {
|
||||
fmt.Printf("%x\n", *phash)
|
||||
} else {
|
||||
fmt.Printf("%x %v\n", *phash, imgFile.Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPaths() (string, string) {
|
||||
ffmpegPath, _ := exec.LookPath("ffmpeg")
|
||||
ffprobePath, _ := exec.LookPath("ffprobe")
|
||||
@@ -67,7 +104,7 @@ func main() {
|
||||
args := flag.Args()
|
||||
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "Missing VIDEOFILE argument.\n")
|
||||
fmt.Fprintf(os.Stderr, "Missing FILE argument.\n")
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -87,4 +124,5 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ func main() {
|
||||
defer pprof.StopCPUProfile()
|
||||
}
|
||||
|
||||
// initialise desktop.IsDesktop here so that it doesn't get affected by
|
||||
// ffmpeg hardware checks later on
|
||||
desktop.InitIsDesktop()
|
||||
|
||||
mgr, err := manager.Initialize(cfg, l)
|
||||
if err != nil {
|
||||
exitError(fmt.Errorf("manager initialization error: %w", err))
|
||||
|
||||
15
go.mod
15
go.mod
@@ -7,10 +7,10 @@ require (
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
|
||||
github.com/Yamashou/gqlgenc v0.32.1
|
||||
github.com/anacrolix/dms v1.2.2
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
github.com/antchfx/htmlquery v1.3.5
|
||||
github.com/asticode/go-astisub v0.25.1
|
||||
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617
|
||||
github.com/chromedp/chromedp v0.9.2
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/corona10/goimagehash v1.1.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
|
||||
@@ -69,20 +69,21 @@ require (
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/antchfx/xpath v1.2.3 // indirect
|
||||
github.com/antchfx/xpath v1.3.5 // indirect
|
||||
github.com/asticode/go-astikit v0.20.0 // indirect
|
||||
github.com/asticode/go-astits v1.8.0 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
@@ -90,10 +91,8 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
|
||||
78
go.sum
78
go.sum
@@ -85,10 +85,10 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
|
||||
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
|
||||
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
|
||||
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
@@ -116,13 +116,12 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w=
|
||||
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
|
||||
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
@@ -206,6 +205,8 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
@@ -224,9 +225,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -286,6 +286,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -379,8 +380,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@@ -432,8 +431,6 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
@@ -664,6 +661,10 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -707,6 +708,10 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -757,7 +762,12 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -789,6 +799,11 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -869,14 +884,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -889,7 +915,12 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -956,6 +987,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -165,6 +165,12 @@ type Query {
|
||||
input: ScrapeSingleStudioInput!
|
||||
): [ScrapedStudio!]!
|
||||
|
||||
"Scrape for a single tag"
|
||||
scrapeSingleTag(
|
||||
source: ScraperSourceInput!
|
||||
input: ScrapeSingleTagInput!
|
||||
): [ScrapedTag!]!
|
||||
|
||||
"Scrape for a single performer"
|
||||
scrapeSinglePerformer(
|
||||
source: ScraperSourceInput!
|
||||
@@ -367,6 +373,7 @@ type Mutation {
|
||||
performerDestroy(input: PerformerDestroyInput!): Boolean!
|
||||
performersDestroy(ids: [ID!]!): Boolean!
|
||||
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
|
||||
performerMerge(input: PerformerMergeInput!): Performer!
|
||||
|
||||
studioCreate(input: StudioCreateInput!): Studio
|
||||
studioUpdate(input: StudioUpdateInput!): Studio
|
||||
@@ -415,6 +422,8 @@ type Mutation {
|
||||
"""
|
||||
moveFiles(input: MoveFilesInput!): Boolean!
|
||||
deleteFiles(ids: [ID!]!): Boolean!
|
||||
"Deletes file entries from the database without deleting the files from the filesystem"
|
||||
destroyFiles(ids: [ID!]!): Boolean!
|
||||
|
||||
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@ input ConfigDisableDropdownCreateInput {
|
||||
tag: Boolean
|
||||
studio: Boolean
|
||||
movie: Boolean
|
||||
gallery: Boolean
|
||||
}
|
||||
|
||||
enum ImageLightboxDisplayMode {
|
||||
@@ -394,6 +395,9 @@ input ConfigInterfaceInput {
|
||||
customLocales: String
|
||||
customLocalesEnabled: Boolean
|
||||
|
||||
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
|
||||
disableCustomizations: Boolean
|
||||
|
||||
"Interface language"
|
||||
language: String
|
||||
|
||||
@@ -419,6 +423,7 @@ type ConfigDisableDropdownCreate {
|
||||
tag: Boolean!
|
||||
studio: Boolean!
|
||||
movie: Boolean!
|
||||
gallery: Boolean!
|
||||
}
|
||||
|
||||
type ConfigInterfaceResult {
|
||||
@@ -467,6 +472,9 @@ type ConfigInterfaceResult {
|
||||
customLocales: String
|
||||
customLocalesEnabled: Boolean
|
||||
|
||||
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
|
||||
disableCustomizations: Boolean
|
||||
|
||||
"Interface language"
|
||||
language: String
|
||||
|
||||
|
||||
@@ -75,22 +75,48 @@ input OrientationCriterionInput {
|
||||
value: [OrientationEnum!]!
|
||||
}
|
||||
|
||||
input PHashDuplicationCriterionInput {
|
||||
duplicated: Boolean
|
||||
"Currently unimplemented"
|
||||
input DuplicationCriterionInput {
|
||||
duplicated: Boolean @deprecated(reason: "Use phash field instead")
|
||||
"Currently unimplemented. Intended for phash distance matching."
|
||||
distance: Int
|
||||
"Filter by phash duplication"
|
||||
phash: Boolean
|
||||
"Filter by URL duplication"
|
||||
url: Boolean
|
||||
"Filter by Stash ID duplication"
|
||||
stash_id: Boolean
|
||||
"Filter by title duplication"
|
||||
title: Boolean
|
||||
}
|
||||
|
||||
input FileDuplicationCriterionInput {
|
||||
duplicated: Boolean @deprecated(reason: "Use phash field instead")
|
||||
"Currently unimplemented. Intended for phash distance matching."
|
||||
distance: Int
|
||||
"Filter by phash duplication"
|
||||
phash: Boolean
|
||||
}
|
||||
|
||||
input StashIDCriterionInput {
|
||||
"""
|
||||
If present, this value is treated as a predicate.
|
||||
That is, it will filter based on stash_ids with the matching endpoint
|
||||
That is, it will filter based on stash_id with the matching endpoint
|
||||
"""
|
||||
endpoint: String
|
||||
stash_id: String
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
input StashIDsCriterionInput {
|
||||
"""
|
||||
If present, this value is treated as a predicate.
|
||||
That is, it will filter based on stash_ids with the matching endpoint
|
||||
"""
|
||||
endpoint: String
|
||||
stash_ids: [String]
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
input CustomFieldCriterionInput {
|
||||
field: String!
|
||||
value: [Any!]
|
||||
@@ -156,6 +182,9 @@ input PerformerFilterType {
|
||||
o_counter: IntCriterionInput
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
"Filter by StashIDs"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
# rating expressed as 1-100
|
||||
rating100: IntCriterionInput
|
||||
"Filter by url"
|
||||
@@ -248,8 +277,8 @@ input SceneFilterType {
|
||||
organized: Boolean
|
||||
"Filter by o-counter"
|
||||
o_counter: IntCriterionInput
|
||||
"Filter Scenes that have an exact phash match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
"Filter Scenes by duplication criteria"
|
||||
duplicated: DuplicationCriterionInput
|
||||
"Filter by resolution"
|
||||
resolution: ResolutionCriterionInput
|
||||
"Filter by orientation"
|
||||
@@ -292,6 +321,11 @@ input SceneFilterType {
|
||||
performer_count: IntCriterionInput
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
"Filter by StashIDs"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
"Filter by StashID count"
|
||||
stash_id_count: IntCriterionInput
|
||||
"Filter by url"
|
||||
url: StringCriterionInput
|
||||
"Filter by interactive"
|
||||
@@ -432,6 +466,9 @@ input StudioFilterType {
|
||||
parents: MultiCriterionInput
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
"Filter by StashIDs"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
"Filter to only include studios with these tags"
|
||||
tags: HierarchicalMultiCriterionInput
|
||||
"Filter to only include studios missing this property"
|
||||
@@ -466,6 +503,8 @@ input StudioFilterType {
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
|
||||
custom_fields: [CustomFieldCriterionInput!]
|
||||
}
|
||||
|
||||
input GalleryFilterType {
|
||||
@@ -606,18 +645,31 @@ input TagFilterType {
|
||||
"Filter by autotag ignore value"
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
"Filter by StashID"
|
||||
stash_id_endpoint: StashIDCriterionInput
|
||||
@deprecated(reason: "use stash_ids_endpoint instead")
|
||||
|
||||
"Filter by StashID"
|
||||
stash_ids_endpoint: StashIDsCriterionInput
|
||||
|
||||
"Filter by related scenes that meet this criteria"
|
||||
scenes_filter: SceneFilterType
|
||||
"Filter by related images that meet this criteria"
|
||||
images_filter: ImageFilterType
|
||||
"Filter by related galleries that meet this criteria"
|
||||
galleries_filter: GalleryFilterType
|
||||
"Filter by related performers that meet this criteria"
|
||||
performers_filter: PerformerFilterType
|
||||
"Filter by related studios that meet this criteria"
|
||||
studios_filter: StudioFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
|
||||
custom_fields: [CustomFieldCriterionInput!]
|
||||
}
|
||||
|
||||
input ImageFilterType {
|
||||
@@ -632,6 +684,8 @@ input ImageFilterType {
|
||||
id: IntCriterionInput
|
||||
"Filter by file checksum"
|
||||
checksum: StringCriterionInput
|
||||
"Filter by file phash distance"
|
||||
phash_distance: PhashDistanceCriterionInput
|
||||
"Filter by path"
|
||||
path: StringCriterionInput
|
||||
"Filter by file count"
|
||||
@@ -706,8 +760,8 @@ input FileFilterType {
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
"Filter files that have an exact match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
"Filter files by duplication criteria (only phash applies to files)"
|
||||
duplicated: FileDuplicationCriterionInput
|
||||
|
||||
"find files based on hash"
|
||||
hashes: [FingerprintFilterInput!]
|
||||
|
||||
@@ -100,6 +100,8 @@ input GalleryDestroyInput {
|
||||
"""
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
type FindGalleriesResultType {
|
||||
|
||||
@@ -82,12 +82,16 @@ input ImageDestroyInput {
|
||||
id: ID!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
input ImagesDestroyInput {
|
||||
ids: [ID!]!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
type FindImagesResultType {
|
||||
|
||||
@@ -10,8 +10,11 @@ input GenerateMetadataInput {
|
||||
transcodes: Boolean
|
||||
"Generate transcodes even if not required"
|
||||
forceTranscodes: Boolean
|
||||
"Generate video phashes during scan"
|
||||
phashes: Boolean
|
||||
interactiveHeatmapsSpeeds: Boolean
|
||||
"Generate image phashes during scan"
|
||||
imagePhashes: Boolean
|
||||
imageThumbnails: Boolean
|
||||
clipPreviews: Boolean
|
||||
|
||||
@@ -19,6 +22,10 @@ input GenerateMetadataInput {
|
||||
sceneIDs: [ID!]
|
||||
"marker ids to generate for"
|
||||
markerIDs: [ID!]
|
||||
"image ids to generate for"
|
||||
imageIDs: [ID!]
|
||||
"gallery ids to generate for"
|
||||
galleryIDs: [ID!]
|
||||
|
||||
"overwrite existing media"
|
||||
overwrite: Boolean
|
||||
@@ -85,8 +92,10 @@ input ScanMetadataInput {
|
||||
scanGenerateImagePreviews: Boolean
|
||||
"Generate sprites during scan"
|
||||
scanGenerateSprites: Boolean
|
||||
"Generate phashes during scan"
|
||||
"Generate video phashes during scan"
|
||||
scanGeneratePhashes: Boolean
|
||||
"Generate image phashes during scan"
|
||||
scanGenerateImagePhashes: Boolean
|
||||
"Generate image thumbnails during scan"
|
||||
scanGenerateThumbnails: Boolean
|
||||
"Generate image clip previews during scan"
|
||||
@@ -107,8 +116,10 @@ type ScanMetadataOptions {
|
||||
scanGenerateImagePreviews: Boolean!
|
||||
"Generate sprites during scan"
|
||||
scanGenerateSprites: Boolean!
|
||||
"Generate phashes during scan"
|
||||
"Generate video phashes during scan"
|
||||
scanGeneratePhashes: Boolean!
|
||||
"Generate image phashes during scan"
|
||||
scanGenerateImagePhashes: Boolean
|
||||
"Generate image thumbnails during scan"
|
||||
scanGenerateThumbnails: Boolean!
|
||||
"Generate image clip previews during scan"
|
||||
@@ -204,7 +215,9 @@ input IdentifyMetadataOptionsInput {
|
||||
setCoverImage: Boolean
|
||||
setOrganized: Boolean
|
||||
"defaults to true if not provided"
|
||||
includeMalePerformers: Boolean
|
||||
includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders")
|
||||
"Filter to only include performers with these genders. If not provided, all genders are included."
|
||||
performerGenders: [GenderEnum!]
|
||||
"defaults to true if not provided"
|
||||
skipMultipleMatches: Boolean
|
||||
"tag to tag skipped multiple matches with"
|
||||
@@ -249,7 +262,9 @@ type IdentifyMetadataOptions {
|
||||
setCoverImage: Boolean
|
||||
setOrganized: Boolean
|
||||
"defaults to true if not provided"
|
||||
includeMalePerformers: Boolean
|
||||
includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders")
|
||||
"Filter to only include performers with these genders. If not provided, all genders are included."
|
||||
performerGenders: [GenderEnum!]
|
||||
"defaults to true if not provided"
|
||||
skipMultipleMatches: Boolean
|
||||
"tag to tag skipped multiple matches with"
|
||||
|
||||
@@ -80,6 +80,7 @@ input PerformerCreateInput {
|
||||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
alias_list: [String!]
|
||||
twitter: String @deprecated(reason: "Use urls")
|
||||
instagram: String @deprecated(reason: "Use urls")
|
||||
@@ -118,6 +119,7 @@ input PerformerUpdateInput {
|
||||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
alias_list: [String!]
|
||||
twitter: String @deprecated(reason: "Use urls")
|
||||
instagram: String @deprecated(reason: "Use urls")
|
||||
@@ -161,6 +163,7 @@ input BulkPerformerUpdateInput {
|
||||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
|
||||
alias_list: BulkUpdateStrings
|
||||
twitter: String @deprecated(reason: "Use urls")
|
||||
instagram: String @deprecated(reason: "Use urls")
|
||||
@@ -185,3 +188,10 @@ type FindPerformersResultType {
|
||||
count: Int!
|
||||
performers: [Performer!]!
|
||||
}
|
||||
|
||||
input PerformerMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: PerformerUpdateInput
|
||||
}
|
||||
|
||||
@@ -196,12 +196,16 @@ input SceneDestroyInput {
|
||||
id: ID!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
input ScenesDestroyInput {
|
||||
ids: [ID!]!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
"If true, delete the file entry from the database if the file is not assigned to any other objects"
|
||||
destroy_file_entry: Boolean
|
||||
}
|
||||
|
||||
type FindScenesResultType {
|
||||
|
||||
@@ -198,6 +198,13 @@ input ScrapeSingleStudioInput {
|
||||
query: String
|
||||
}
|
||||
|
||||
input ScrapeSingleTagInput {
|
||||
"""
|
||||
Query can be either a name or a Stash ID
|
||||
"""
|
||||
query: String
|
||||
}
|
||||
|
||||
input ScrapeSinglePerformerInput {
|
||||
"Instructs to query by string"
|
||||
query: String
|
||||
|
||||
@@ -26,6 +26,8 @@ type Studio {
|
||||
groups: [Group!]!
|
||||
movies: [Movie!]! @deprecated(reason: "use groups instead")
|
||||
o_counter: Int
|
||||
|
||||
custom_fields: Map!
|
||||
}
|
||||
|
||||
input StudioCreateInput {
|
||||
@@ -40,9 +42,12 @@ input StudioCreateInput {
|
||||
rating100: Int
|
||||
favorite: Boolean
|
||||
details: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
tag_ids: [ID!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input StudioUpdateInput {
|
||||
@@ -58,9 +63,12 @@ input StudioUpdateInput {
|
||||
rating100: Int
|
||||
favorite: Boolean
|
||||
details: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
tag_ids: [ID!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input BulkStudioUpdateInput {
|
||||
|
||||
@@ -24,6 +24,7 @@ type Tag {
|
||||
|
||||
parent_count: Int! # Resolver
|
||||
child_count: Int! # Resolver
|
||||
custom_fields: Map!
|
||||
}
|
||||
|
||||
input TagCreateInput {
|
||||
@@ -31,6 +32,7 @@ input TagCreateInput {
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
favorite: Boolean
|
||||
@@ -40,6 +42,8 @@ input TagCreateInput {
|
||||
|
||||
parent_ids: [ID!]
|
||||
child_ids: [ID!]
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input TagUpdateInput {
|
||||
@@ -48,6 +52,7 @@ input TagUpdateInput {
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
favorite: Boolean
|
||||
@@ -57,6 +62,8 @@ input TagUpdateInput {
|
||||
|
||||
parent_ids: [ID!]
|
||||
child_ids: [ID!]
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input TagDestroyInput {
|
||||
@@ -71,11 +78,14 @@ type FindTagsResultType {
|
||||
input TagsMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: TagUpdateInput
|
||||
}
|
||||
|
||||
input BulkTagUpdateInput {
|
||||
ids: [ID!]
|
||||
description: String
|
||||
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
|
||||
aliases: BulkUpdateStrings
|
||||
ignore_auto_tag: Boolean
|
||||
favorite: Boolean
|
||||
|
||||
@@ -120,18 +120,6 @@ fragment SceneFragment on Scene {
|
||||
}
|
||||
}
|
||||
|
||||
query FindSceneByFingerprint($fingerprint: FingerprintQueryInput!) {
|
||||
findSceneByFingerprint(fingerprint: $fingerprint) {
|
||||
...SceneFragment
|
||||
}
|
||||
}
|
||||
|
||||
query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) {
|
||||
findScenesByFullFingerprints(fingerprints: $fingerprints) {
|
||||
...SceneFragment
|
||||
}
|
||||
}
|
||||
|
||||
query FindScenesBySceneFingerprints(
|
||||
$fingerprints: [[FingerprintQueryInput!]!]!
|
||||
) {
|
||||
@@ -170,6 +158,21 @@ query FindStudio($id: ID, $name: String) {
|
||||
}
|
||||
}
|
||||
|
||||
query FindTag($id: ID, $name: String) {
|
||||
findTag(id: $id, name: $name) {
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
|
||||
query QueryTags($input: TagQueryInput!) {
|
||||
queryTags(input: $input) {
|
||||
count
|
||||
tags {
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation SubmitFingerprint($input: FingerprintSubmission!) {
|
||||
submitFingerprint(input: $input)
|
||||
}
|
||||
|
||||
35
internal/api/input.go
Normal file
35
internal/api/input.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
// TODO - apply handleIDs to other resolvers that accept ID lists
|
||||
|
||||
// handleIDList validates and converts a list of string IDs to integers
|
||||
func handleIDList(idList []string, field string) ([]int, error) {
|
||||
if err := validateIDList(idList); err != nil {
|
||||
return nil, fmt.Errorf("validating %s: %w", field, err)
|
||||
}
|
||||
|
||||
ids, err := stringslice.StringSliceToIntSlice(idList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting %s: %w", field, err)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// validateIDList returns an error if there are any duplicate ids in the list
|
||||
func validateIDList(ids []string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, id := range ids {
|
||||
if _, exists := seen[id]; exists {
|
||||
return fmt.Errorf("duplicate id found: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -59,11 +59,14 @@ type Loaders struct {
|
||||
PerformerByID *PerformerLoader
|
||||
PerformerCustomFields *CustomFieldsLoader
|
||||
|
||||
StudioByID *StudioLoader
|
||||
TagByID *TagLoader
|
||||
GroupByID *GroupLoader
|
||||
FileByID *FileLoader
|
||||
FolderByID *FolderLoader
|
||||
StudioByID *StudioLoader
|
||||
StudioCustomFields *CustomFieldsLoader
|
||||
|
||||
TagByID *TagLoader
|
||||
TagCustomFields *CustomFieldsLoader
|
||||
GroupByID *GroupLoader
|
||||
FileByID *FileLoader
|
||||
FolderByID *FolderLoader
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
@@ -99,6 +102,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchPerformerCustomFields(ctx),
|
||||
},
|
||||
StudioCustomFields: &CustomFieldsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchStudioCustomFields(ctx),
|
||||
},
|
||||
StudioByID: &StudioLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -109,6 +117,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchTags(ctx),
|
||||
},
|
||||
TagCustomFields: &CustomFieldsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchTagCustomFields(ctx),
|
||||
},
|
||||
GroupByID: &GroupLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -253,6 +266,18 @@ func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*model
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
|
||||
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys)
|
||||
return err
|
||||
})
|
||||
|
||||
return ret, toErrorSlice(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) {
|
||||
return func(keys []int) (ret []*models.Tag, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
@@ -264,6 +289,18 @@ func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.T
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
|
||||
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys)
|
||||
return err
|
||||
})
|
||||
|
||||
return ret, toErrorSlice(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) {
|
||||
return func(keys []int) (ret []*models.Group, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
|
||||
@@ -207,6 +207,19 @@ func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) {
|
||||
m, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deprecated
|
||||
func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {
|
||||
return r.Groups(ctx, obj)
|
||||
|
||||
@@ -181,3 +181,16 @@ func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int,
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *tagResolver) CustomFields(ctx context.Context, obj *models.Tag) (map[string]interface{}, error) {
|
||||
m, err := loaders.From(ctx).TagCustomFields.Load(obj.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -515,12 +515,15 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
|
||||
|
||||
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
|
||||
|
||||
r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)
|
||||
|
||||
if input.DisableDropdownCreate != nil {
|
||||
ddc := input.DisableDropdownCreate
|
||||
r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)
|
||||
r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio)
|
||||
r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag)
|
||||
r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie)
|
||||
r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery)
|
||||
}
|
||||
|
||||
r.setConfigString(config.HandyKey, input.HandyKey)
|
||||
|
||||
@@ -210,6 +210,58 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) {
|
||||
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("converting ids: %w", err)
|
||||
}
|
||||
|
||||
destroyer := &file.ZipDestroyer{
|
||||
FileDestroyer: r.repository.File,
|
||||
FolderDestroyer: r.repository.Folder,
|
||||
}
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.File
|
||||
|
||||
for _, fileIDInt := range fileIDs {
|
||||
fileID := models.FileID(fileIDInt)
|
||||
f, err := qb.Find(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(f) == 0 {
|
||||
return fmt.Errorf("file with id %d not found", fileID)
|
||||
}
|
||||
|
||||
path := f[0].Base().Path
|
||||
|
||||
// ensure not a primary file
|
||||
isPrimary, err := qb.IsPrimary(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking if file %s is primary: %w", path, err)
|
||||
}
|
||||
|
||||
if isPrimary {
|
||||
return fmt.Errorf("cannot destroy primary file entry %s", path)
|
||||
}
|
||||
|
||||
// destroy DB entries only (no filesystem deletion)
|
||||
const deleteFile = false
|
||||
if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil {
|
||||
return fmt.Errorf("destroying file entry %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) {
|
||||
fileIDInt, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,7 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
|
||||
newGallery.Details = translator.string(input.Details)
|
||||
newGallery.Photographer = translator.string(input.Photographer)
|
||||
newGallery.Rating = input.Rating100
|
||||
newGallery.Organized = translator.bool(input.Organized)
|
||||
|
||||
var err error
|
||||
|
||||
@@ -345,6 +346,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
|
||||
|
||||
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
|
||||
deleteFile := utils.IsTrue(input.DeleteFile)
|
||||
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Gallery
|
||||
@@ -365,7 +367,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
|
||||
|
||||
galleries = append(galleries, gallery)
|
||||
|
||||
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile)
|
||||
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
|
||||
return fmt.Errorf("image with id %d not found", imageID)
|
||||
}
|
||||
|
||||
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile))
|
||||
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry))
|
||||
}); err != nil {
|
||||
fileDeleter.Rollback()
|
||||
return false, err
|
||||
@@ -372,7 +372,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
|
||||
|
||||
images = append(images, i)
|
||||
|
||||
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)); err != nil {
|
||||
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
@@ -40,7 +43,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
|
||||
newPerformer.Name = strings.TrimSpace(input.Name)
|
||||
newPerformer.Disambiguation = translator.string(input.Disambiguation)
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList))
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
|
||||
newPerformer.Gender = input.Gender
|
||||
newPerformer.Ethnicity = translator.string(input.Ethnicity)
|
||||
newPerformer.Country = translator.string(input.Country)
|
||||
@@ -136,7 +139,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
return r.getPerformer(ctx, newPerformer.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if translator.hasField("url") {
|
||||
return fmt.Errorf("url field must not be included if urls is included")
|
||||
@@ -151,7 +154,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
// we need to be careful with URL/Twitter/Instagram
|
||||
@@ -170,23 +173,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs := p.URLs.List()
|
||||
|
||||
// performer partial URLs should be empty
|
||||
if legacyURL.Set {
|
||||
if legacyURLs.URL.Set {
|
||||
replaced := false
|
||||
for i, url := range existingURLs {
|
||||
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
|
||||
existingURLs[i] = legacyURL.Value
|
||||
existingURLs[i] = legacyURLs.URL.Value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
existingURLs = append(existingURLs, legacyURL.Value)
|
||||
existingURLs = append(existingURLs, legacyURLs.URL.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if legacyTwitter.Set {
|
||||
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
|
||||
if legacyURLs.Twitter.Set {
|
||||
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
|
||||
found := false
|
||||
// find and replace the first twitter URL
|
||||
for i, url := range existingURLs {
|
||||
@@ -201,9 +204,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs = append(existingURLs, value)
|
||||
}
|
||||
}
|
||||
if legacyInstagram.Set {
|
||||
if legacyURLs.Instagram.Set {
|
||||
found := false
|
||||
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
|
||||
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
|
||||
// find and replace the first instagram URL
|
||||
for i, url := range existingURLs {
|
||||
if performer.IsInstagramURL(url) {
|
||||
@@ -226,16 +229,25 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
type legacyPerformerURLs struct {
|
||||
URL models.OptionalString
|
||||
Twitter models.OptionalString
|
||||
Instagram models.OptionalString
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
func (u *legacyPerformerURLs) AnySet() bool {
|
||||
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
|
||||
}
|
||||
|
||||
func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {
|
||||
return legacyPerformerURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
}
|
||||
|
||||
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
|
||||
// Populate performer from the input
|
||||
updatedPerformer := models.NewPerformerPartial()
|
||||
|
||||
@@ -260,19 +272,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
||||
|
||||
var err error
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting birthdate: %w", err)
|
||||
@@ -299,6 +309,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
|
||||
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
|
||||
|
||||
return &updatedPerformer, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
updatedPerformer, err := performerPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
legacyURLs := legacyPerformerURLsFromInput(input, translator)
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
@@ -312,17 +342,38 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
|
||||
if updatedPerformer.Aliases != nil {
|
||||
p, err := qb.Find(ctx, performerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p != nil {
|
||||
if err := p.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())
|
||||
name := p.Name
|
||||
if updatedPerformer.Name.Set {
|
||||
name = updatedPerformer.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedPerformer.Aliases.Values = sanitized
|
||||
updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = qb.UpdatePartial(ctx, performerID, updatedPerformer)
|
||||
_, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -379,16 +430,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
legacyURLs := legacyPerformerURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
@@ -425,8 +478,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
qb := r.repository.Performer
|
||||
|
||||
for _, performerID := range performerIDs {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -506,3 +559,87 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
|
||||
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting source ids: %w", err)
|
||||
}
|
||||
|
||||
// ensure source ids are unique
|
||||
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
|
||||
|
||||
destID, err := strconv.Atoi(input.Destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting destination id: %w", err)
|
||||
}
|
||||
|
||||
// ensure destination is not in source list
|
||||
if slices.Contains(srcIDs, destID) {
|
||||
return nil, errors.New("destination performer cannot be in source list")
|
||||
}
|
||||
|
||||
var values *models.PerformerPartial
|
||||
var imageData []byte
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = performerPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
|
||||
if legacyURLs.AnySet() {
|
||||
return nil, errors.New("Merging legacy performer URLs is not supported")
|
||||
}
|
||||
|
||||
if input.Values.Image != nil {
|
||||
var err error
|
||||
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := models.NewPerformerPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var dest *models.Performer
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
dest, err = qb.Find(ctx, destID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding destination performer ID %d: %w", destID, err)
|
||||
}
|
||||
|
||||
// ensure source performers exist
|
||||
if _, err := qb.FindMany(ctx, srcIDs); err != nil {
|
||||
return fmt.Errorf("finding source performers: %w", err)
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {
|
||||
return fmt.Errorf("updating performer: %w", err)
|
||||
}
|
||||
|
||||
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
|
||||
return fmt.Errorf("merging performers: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
@@ -297,6 +297,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
coverImageIncluded := translator.hasField("cover_image")
|
||||
if input.CoverImage != nil {
|
||||
var err error
|
||||
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
|
||||
@@ -310,21 +311,21 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
|
||||
return nil, err
|
||||
if coverImageIncluded {
|
||||
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
|
||||
if len(coverImageData) > 0 {
|
||||
qb := r.repository.Scene
|
||||
qb := r.repository.Scene
|
||||
|
||||
// update cover table
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
// update cover table - empty data will clear the cover
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -440,6 +441,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||
|
||||
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
|
||||
deleteFile := utils.IsTrue(input.DeleteFile)
|
||||
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Scene
|
||||
@@ -456,7 +458,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||
// kill any running encoders
|
||||
manager.KillRunningStreams(s, fileNamingAlgo)
|
||||
|
||||
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile)
|
||||
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
}); err != nil {
|
||||
fileDeleter.Rollback()
|
||||
return false, err
|
||||
@@ -494,6 +496,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||
|
||||
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
|
||||
deleteFile := utils.IsTrue(input.DeleteFile)
|
||||
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Scene
|
||||
@@ -512,7 +515,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||
// kill any running encoders
|
||||
manager.KillRunningStreams(scene, fileNamingAlgo)
|
||||
|
||||
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile); err != nil {
|
||||
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -621,7 +624,12 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
return fmt.Errorf("scene with id %d not found", destID)
|
||||
}
|
||||
|
||||
return r.sceneUpdateCoverImage(ctx, ret, coverImageData)
|
||||
// only update cover image if one was provided
|
||||
if len(coverImageData) > 0 {
|
||||
return r.sceneUpdateCoverImage(ctx, ret, coverImageData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,14 +31,14 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
}
|
||||
|
||||
// Populate a new studio from the input
|
||||
newStudio := models.NewStudio()
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
|
||||
newStudio.Name = strings.TrimSpace(input.Name)
|
||||
newStudio.Rating = input.Rating100
|
||||
newStudio.Favorite = translator.bool(input.Favorite)
|
||||
newStudio.Details = translator.string(input.Details)
|
||||
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))
|
||||
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
|
||||
|
||||
var err error
|
||||
@@ -61,6 +61,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
@@ -134,7 +135,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -152,6 +153,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
updatedStudio.CustomFields = input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full)
|
||||
updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
@@ -167,6 +173,28 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Studio
|
||||
|
||||
if updatedStudio.Aliases != nil {
|
||||
s, err := qb.Find(ctx, studioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
if err := s.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())
|
||||
name := s.Name
|
||||
if updatedStudio.Name.Set {
|
||||
name = updatedStudio.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedStudio.Aliases.Values = sanitized
|
||||
updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -211,7 +239,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
@@ -31,11 +30,14 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
}
|
||||
|
||||
// Populate a new tag from the input
|
||||
newTag := models.NewTag()
|
||||
newTag := models.CreateTagInput{
|
||||
Tag: &models.Tag{},
|
||||
}
|
||||
*newTag.Tag = models.NewTag()
|
||||
|
||||
newTag.Name = strings.TrimSpace(input.Name)
|
||||
newTag.SortName = translator.string(input.SortName)
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name))
|
||||
newTag.Favorite = translator.bool(input.Favorite)
|
||||
newTag.Description = translator.string(input.Description)
|
||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
@@ -60,6 +62,8 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
return nil, fmt.Errorf("converting child tag ids: %w", err)
|
||||
}
|
||||
|
||||
newTag.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
if input.Image != nil {
|
||||
@@ -73,7 +77,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
||||
if err := tag.ValidateCreate(ctx, newTag, qb); err != nil {
|
||||
if err := tag.ValidateCreate(ctx, *newTag.Tag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -98,17 +102,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
return r.getTag(ctx, newTag.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
|
||||
tagID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
// Populate tag from the input
|
||||
func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) {
|
||||
updatedTag := models.NewTagPartial()
|
||||
|
||||
updatedTag.Name = translator.optionalString(input.Name, "name")
|
||||
@@ -127,6 +121,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||
}
|
||||
updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids")
|
||||
|
||||
var err error
|
||||
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting parent tag ids: %w", err)
|
||||
@@ -137,6 +132,32 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||
return nil, fmt.Errorf("converting child tag ids: %w", err)
|
||||
}
|
||||
|
||||
if input.CustomFields != nil {
|
||||
updatedTag.CustomFields = *input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedTag.CustomFields.Full = convertMapJSONNumbers(updatedTag.CustomFields.Full)
|
||||
updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial)
|
||||
}
|
||||
|
||||
return &updatedTag, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
|
||||
tagID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
// Populate tag from the input
|
||||
updatedTag, err := tagPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
@@ -151,11 +172,33 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||
if updatedTag.Aliases != nil {
|
||||
t, err := qb.Find(ctx, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t != nil {
|
||||
if err := t.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newAliases := updatedTag.Aliases.Apply(t.Aliases.List())
|
||||
name := t.Name
|
||||
if updatedTag.Name.Set {
|
||||
name = updatedTag.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(newAliases, name)
|
||||
updatedTag.Aliases.Values = sanitized
|
||||
updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t, err = qb.UpdatePartial(ctx, tagID, updatedTag)
|
||||
t, err = qb.UpdatePartial(ctx, tagID, *updatedTag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -303,6 +346,31 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var values *models.TagPartial
|
||||
var imageData []byte
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = tagPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.Values.Image != nil {
|
||||
var err error
|
||||
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := models.NewTagPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var t *models.Tag
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
@@ -317,28 +385,22 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
||||
return fmt.Errorf("tag with id %d not found", destination)
|
||||
}
|
||||
|
||||
parents, children, err := tag.MergeHierarchy(ctx, destination, source, qb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = qb.Merge(ctx, source, destination); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = qb.UpdateParentTags(ctx, destination, parents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = qb.UpdateChildTags(ctx, destination, children)
|
||||
if err != nil {
|
||||
if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb)
|
||||
if err != nil {
|
||||
logger.Errorf("Error merging tag: %s", err)
|
||||
return err
|
||||
if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil {
|
||||
return fmt.Errorf("updating tag: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destination, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -156,6 +156,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
||||
javascriptEnabled := config.GetJavascriptEnabled()
|
||||
customLocales := config.GetCustomLocales()
|
||||
customLocalesEnabled := config.GetCustomLocalesEnabled()
|
||||
disableCustomizations := config.GetDisableCustomizations()
|
||||
language := config.GetLanguage()
|
||||
handyKey := config.GetHandyKey()
|
||||
scriptOffset := config.GetFunscriptOffset()
|
||||
@@ -183,6 +184,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
||||
JavascriptEnabled: &javascriptEnabled,
|
||||
CustomLocales: &customLocales,
|
||||
CustomLocalesEnabled: &customLocalesEnabled,
|
||||
DisableCustomizations: &disableCustomizations,
|
||||
Language: &language,
|
||||
|
||||
ImageLightbox: &imageLightboxOptions,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
|
||||
@@ -49,7 +48,7 @@ func (r *queryResolver) FindFolders(
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
folderIDsInt, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) {
|
||||
@@ -25,7 +24,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) {
|
||||
@@ -25,7 +24,7 @@ func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.G
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) {
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {
|
||||
@@ -55,7 +54,7 @@ func (r *queryResolver) FindImages(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindImagesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
imageIds, err = stringslice.StringSliceToIntSlice(ids)
|
||||
imageIds, err = handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) {
|
||||
@@ -25,7 +24,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.G
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) {
|
||||
@@ -26,7 +25,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
|
||||
|
||||
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
performerIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
performerIDs, err = handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
|
||||
@@ -83,7 +82,7 @@ func (r *queryResolver) FindScenes(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindScenesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
sceneIDs, err = handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) {
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {
|
||||
@@ -26,7 +25,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) {
|
||||
@@ -25,7 +24,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -350,7 +350,46 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_index must be set")
|
||||
return nil, errors.New("stash_box_endpoint must be set")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) {
|
||||
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
|
||||
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := r.newStashBoxClient(*b)
|
||||
|
||||
var ret []*models.ScrapedTag
|
||||
out, err := client.QueryTag(ctx, *input.Query)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if out != nil {
|
||||
ret = append(ret, out...)
|
||||
}
|
||||
|
||||
if len(ret) > 0 {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
for _, tag := range ret {
|
||||
if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_endpoint must be set")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/file/video"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
@@ -243,6 +244,12 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
||||
// if default flag is set, return the default image
|
||||
if r.URL.Query().Get("default") == "true" {
|
||||
utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))
|
||||
return
|
||||
}
|
||||
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
ss := manager.SceneServer{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -255,6 +256,9 @@ func Initialize() (*Server, error) {
|
||||
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
|
||||
}
|
||||
|
||||
// handle favicon override
|
||||
r.HandleFunc("/favicon.ico", handleFavicon(staticUI))
|
||||
|
||||
// Serve the web app
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
ext := path.Ext(r.URL.Path)
|
||||
@@ -295,6 +299,31 @@ func Initialize() (*Server, error) {
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {
|
||||
mgr := manager.GetInstance()
|
||||
cfg := mgr.Config
|
||||
|
||||
// check if favicon.ico exists in the config directory
|
||||
// if so, use that
|
||||
// otherwise, use the embedded one
|
||||
iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico")
|
||||
exists, _ := fsutil.FileExists(iconPath)
|
||||
|
||||
if exists {
|
||||
logger.Debugf("Using custom favicon at %s", iconPath)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if exists {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
} else {
|
||||
staticUI.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the server. It listens on the configured address and port.
|
||||
// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.
|
||||
// Calls to Start are blocked until the server is shutdown.
|
||||
@@ -421,7 +450,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var paths []string
|
||||
|
||||
if c.GetCSSEnabled() {
|
||||
if c.GetCSSEnabled() && !c.GetDisableCustomizations() {
|
||||
// search for custom.css in current directory, then $HOME/.stash
|
||||
fn := c.GetCSSPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
@@ -439,7 +468,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var paths []string
|
||||
|
||||
if c.GetJavascriptEnabled() {
|
||||
if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {
|
||||
// search for custom.js in current directory, then $HOME/.stash
|
||||
fn := c.GetJavascriptPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
@@ -457,7 +486,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
buffer := bytes.Buffer{}
|
||||
|
||||
if c.GetCustomLocalesEnabled() {
|
||||
if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {
|
||||
// search for custom-locales.json in current directory, then $HOME/.stash
|
||||
path := c.GetCustomLocalesPath()
|
||||
exists, _ := fsutil.FileExists(path)
|
||||
|
||||
@@ -101,16 +101,15 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
|
||||
|
||||
func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) {
|
||||
// create the studio
|
||||
studio := models.Studio{
|
||||
Name: name,
|
||||
}
|
||||
studio := models.NewCreateStudioInput()
|
||||
studio.Name = name
|
||||
|
||||
err := qb.Create(ctx, &studio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &studio, nil
|
||||
return studio.Studio, nil
|
||||
}
|
||||
|
||||
func createTag(ctx context.Context, qb models.TagWriter) error {
|
||||
@@ -119,7 +118,7 @@ func createTag(ctx context.Context, qb models.TagWriter) error {
|
||||
Name: testName,
|
||||
}
|
||||
|
||||
err := qb.Create(ctx, &tag)
|
||||
err := qb.Create(ctx, &models.CreateTagInput{Tag: &tag})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var isDesktop bool
|
||||
|
||||
// InitIsDesktop sets the value of isDesktop.
|
||||
// Changed IsDesktop to be evaluated once at startup because if it is
|
||||
// checked while there are open terminal sessions (such as the ffmpeg hardware
|
||||
// encoding checks), it may return false.
|
||||
func InitIsDesktop() {
|
||||
isDesktop = isDesktopCheck()
|
||||
}
|
||||
|
||||
type FaviconProvider interface {
|
||||
GetFavicon() []byte
|
||||
GetFaviconPng() []byte
|
||||
@@ -59,22 +69,33 @@ func SendNotification(title string, text string) {
|
||||
}
|
||||
|
||||
func IsDesktop() bool {
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
// isDesktop tries to determine if the application is running in a desktop environment
|
||||
// where desktop features like system tray and notifications should be enabled.
|
||||
func isDesktopCheck() bool {
|
||||
if isDoubleClickLaunched() {
|
||||
logger.Debug("Detected double-click launch")
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if running under root
|
||||
if os.Getuid() == 0 {
|
||||
logger.Debug("Running as root, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
// Check if stdin is a terminal
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
logger.Debug("Running in terminal, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
if isService() {
|
||||
logger.Debug("Running as a service, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
if IsServerDockerized() {
|
||||
logger.Debug("Running in docker, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -58,12 +59,12 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
|
||||
func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) {
|
||||
favicon := faviconProvider.GetFavicon()
|
||||
systray.SetTemplateIcon(favicon, favicon)
|
||||
systray.SetTooltip("🟢 Stash is Running.")
|
||||
c := config.GetInstance()
|
||||
systray.SetTooltip(fmt.Sprintf("🟢 Stash is Running on port %d.", c.GetPort()))
|
||||
|
||||
openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash")
|
||||
var menuItems []string
|
||||
systray.AddSeparator()
|
||||
c := config.GetInstance()
|
||||
if !c.IsNewSystem() {
|
||||
menuItems = c.GetMenuItems()
|
||||
for _, item := range menuItems {
|
||||
|
||||
333
internal/dlna/activity.go
Normal file
333
internal/dlna/activity.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultSessionTimeout is the time after which a session is considered complete
|
||||
// if no new requests are received.
|
||||
// This is set high (5 minutes) because DLNA clients buffer aggressively and may not
|
||||
// send any HTTP requests for extended periods while the user is still watching.
|
||||
DefaultSessionTimeout = 5 * time.Minute
|
||||
|
||||
// monitorInterval is how often we check for expired sessions.
|
||||
monitorInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
// ActivityConfig provides configuration options for DLNA activity tracking.
|
||||
type ActivityConfig interface {
|
||||
// GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled.
|
||||
// If not implemented, defaults to true.
|
||||
GetDLNAActivityTrackingEnabled() bool
|
||||
|
||||
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
||||
// watched before incrementing the play count. Uses UI setting if available.
|
||||
GetMinimumPlayPercent() int
|
||||
}
|
||||
|
||||
// SceneActivityWriter provides methods for saving scene activity.
|
||||
type SceneActivityWriter interface {
|
||||
SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)
|
||||
AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)
|
||||
}
|
||||
|
||||
// streamSession represents an active DLNA streaming session.
|
||||
type streamSession struct {
|
||||
SceneID int
|
||||
ClientIP string
|
||||
StartTime time.Time
|
||||
LastActivity time.Time
|
||||
VideoDuration float64
|
||||
PlayCountAdded bool
|
||||
}
|
||||
|
||||
// sessionKey generates a unique key for a session based on client IP and scene ID.
|
||||
func sessionKey(clientIP string, sceneID int) string {
|
||||
return fmt.Sprintf("%s:%d", clientIP, sceneID)
|
||||
}
|
||||
|
||||
// percentWatched calculates the estimated percentage of video watched.
|
||||
// Uses a time-based approach since DLNA clients buffer aggressively and byte
|
||||
// positions don't correlate with actual playback position.
|
||||
//
|
||||
// The key insight: you cannot have watched more of the video than time has elapsed.
|
||||
// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%.
|
||||
func (s *streamSession) percentWatched() float64 {
|
||||
if s.VideoDuration <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate elapsed time from session start to last activity
|
||||
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Maximum possible percent is based on elapsed time
|
||||
// You can't watch more of the video than time has passed
|
||||
timeBasedPercent := (elapsed / s.VideoDuration) * 100
|
||||
|
||||
// Cap at 100%
|
||||
if timeBasedPercent > 100 {
|
||||
return 100
|
||||
}
|
||||
|
||||
return timeBasedPercent
|
||||
}
|
||||
|
||||
// estimatedResumeTime calculates the estimated resume time based on elapsed time.
|
||||
// Since DLNA clients buffer aggressively, byte positions don't correlate with playback.
|
||||
// Instead, we estimate based on how long the session has been active.
|
||||
// Returns the time in seconds, or 0 if the video is nearly complete (>=98%).
|
||||
func (s *streamSession) estimatedResumeTime() float64 {
|
||||
if s.VideoDuration <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate elapsed time from session start
|
||||
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior)
|
||||
if elapsed >= s.VideoDuration*0.98 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resume time is approximately where the user was watching
|
||||
// Capped by video duration
|
||||
if elapsed > s.VideoDuration {
|
||||
elapsed = s.VideoDuration
|
||||
}
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// ActivityTracker tracks DLNA streaming activity and saves it to the database.
|
||||
type ActivityTracker struct {
|
||||
txnManager txn.Manager
|
||||
sceneWriter SceneActivityWriter
|
||||
config ActivityConfig
|
||||
sessionTimeout time.Duration
|
||||
|
||||
sessions map[string]*streamSession
|
||||
mutex sync.RWMutex
|
||||
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewActivityTracker creates a new ActivityTracker.
|
||||
func NewActivityTracker(
|
||||
txnManager txn.Manager,
|
||||
sceneWriter SceneActivityWriter,
|
||||
config ActivityConfig,
|
||||
) *ActivityTracker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: txnManager,
|
||||
sceneWriter: sceneWriter,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
ctx: ctx,
|
||||
cancelFunc: cancel,
|
||||
}
|
||||
|
||||
// Start the session monitor goroutine
|
||||
tracker.wg.Add(1)
|
||||
go tracker.monitorSessions()
|
||||
|
||||
return tracker
|
||||
}
|
||||
|
||||
// Stop stops the activity tracker and processes any remaining sessions.
|
||||
func (t *ActivityTracker) Stop() {
|
||||
t.cancelFunc()
|
||||
t.wg.Wait()
|
||||
|
||||
// Process any remaining sessions
|
||||
t.mutex.Lock()
|
||||
sessions := make([]*streamSession, 0, len(t.sessions))
|
||||
for _, session := range t.sessions {
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
t.sessions = make(map[string]*streamSession)
|
||||
t.mutex.Unlock()
|
||||
|
||||
for _, session := range sessions {
|
||||
t.processCompletedSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest records a streaming request for activity tracking.
|
||||
// Each request updates the session's LastActivity time, which is used for
|
||||
// time-based tracking of watch progress.
|
||||
func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) {
|
||||
if !t.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
key := sessionKey(clientIP, sceneID)
|
||||
now := time.Now()
|
||||
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
session, exists := t.sessions[key]
|
||||
if !exists {
|
||||
session = &streamSession{
|
||||
SceneID: sceneID,
|
||||
ClientIP: clientIP,
|
||||
StartTime: now,
|
||||
VideoDuration: videoDuration,
|
||||
}
|
||||
t.sessions[key] = session
|
||||
logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP)
|
||||
}
|
||||
|
||||
session.LastActivity = now
|
||||
}
|
||||
|
||||
// monitorSessions periodically checks for expired sessions and processes them.
|
||||
func (t *ActivityTracker) monitorSessions() {
|
||||
defer t.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(monitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
t.processExpiredSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processExpiredSessions finds and processes sessions that have timed out.
|
||||
func (t *ActivityTracker) processExpiredSessions() {
|
||||
now := time.Now()
|
||||
var expiredSessions []*streamSession
|
||||
|
||||
t.mutex.Lock()
|
||||
for key, session := range t.sessions {
|
||||
timeSinceStart := now.Sub(session.StartTime)
|
||||
timeSinceActivity := now.Sub(session.LastActivity)
|
||||
|
||||
// Must have no HTTP activity for the full timeout period
|
||||
if timeSinceActivity <= t.sessionTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
// DLNA clients buffer aggressively - they fetch most/all of the video quickly,
|
||||
// then play from cache with NO further HTTP requests.
|
||||
//
|
||||
// Two scenarios:
|
||||
// 1. User watched the whole video: timeSinceStart >= videoDuration
|
||||
// -> Set LastActivity to when timeout began (they finished watching)
|
||||
// 2. User stopped early: timeSinceStart < videoDuration
|
||||
// -> Keep LastActivity as-is (best estimate of when they stopped)
|
||||
|
||||
videoDuration := time.Duration(session.VideoDuration) * time.Second
|
||||
if timeSinceStart >= videoDuration && videoDuration > 0 {
|
||||
// User likely watched the whole video, then it timed out
|
||||
// Estimate they watched until the timeout period started
|
||||
session.LastActivity = now.Add(-t.sessionTimeout)
|
||||
}
|
||||
// else: User stopped early - LastActivity is already our best estimate
|
||||
|
||||
expiredSessions = append(expiredSessions, session)
|
||||
delete(t.sessions, key)
|
||||
}
|
||||
t.mutex.Unlock()
|
||||
|
||||
for _, session := range expiredSessions {
|
||||
t.processCompletedSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// processCompletedSession saves activity data for a completed streaming session.
|
||||
func (t *ActivityTracker) processCompletedSession(session *streamSession) {
|
||||
percentWatched := session.percentWatched()
|
||||
resumeTime := session.estimatedResumeTime()
|
||||
|
||||
logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs",
|
||||
session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime)
|
||||
|
||||
// Only save if there was meaningful activity (at least 1% watched)
|
||||
if percentWatched < 1 {
|
||||
logger.Debugf("[DLNA Activity] Session too short, skipping save")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip DB operations if txnManager is nil (for testing)
|
||||
if t.txnManager == nil {
|
||||
logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine what needs to be saved
|
||||
shouldSaveResume := resumeTime > 0
|
||||
shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())
|
||||
|
||||
// Nothing to save
|
||||
if !shouldSaveResume && !shouldAddView {
|
||||
return
|
||||
}
|
||||
|
||||
// Save everything in a single transaction
|
||||
ctx := context.Background()
|
||||
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
|
||||
// Save resume time only. DLNA clients buffer aggressively and don't report
|
||||
// playback position, so we can't accurately track play duration - saving
|
||||
// guesses would corrupt analytics. Resume time is still useful as a
|
||||
// "continue watching" hint even if imprecise.
|
||||
if shouldSaveResume {
|
||||
if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil {
|
||||
return fmt.Errorf("save resume time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Increment play count (also updates last_played_at via view date)
|
||||
if shouldAddView {
|
||||
if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil {
|
||||
return fmt.Errorf("add view: %w", err)
|
||||
}
|
||||
session.PlayCountAdded = true
|
||||
logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)",
|
||||
session.SceneID, percentWatched)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// isEnabled returns true if activity tracking is enabled.
|
||||
func (t *ActivityTracker) isEnabled() bool {
|
||||
if t.config == nil {
|
||||
return true // Default to enabled
|
||||
}
|
||||
return t.config.GetDLNAActivityTrackingEnabled()
|
||||
}
|
||||
|
||||
// getMinimumPlayPercent returns the minimum play percentage for incrementing play count.
|
||||
func (t *ActivityTracker) getMinimumPlayPercent() int {
|
||||
if t.config == nil {
|
||||
return 0 // Default: any play increments count (matches frontend default)
|
||||
}
|
||||
return t.config.GetMinimumPlayPercent()
|
||||
}
|
||||
420
internal/dlna/activity_test.go
Normal file
420
internal/dlna/activity_test.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockSceneWriter is a mock implementation of SceneActivityWriter
|
||||
type mockSceneWriter struct {
|
||||
mu sync.Mutex
|
||||
saveActivityCalls []saveActivityCall
|
||||
addViewsCalls []addViewsCall
|
||||
}
|
||||
|
||||
type saveActivityCall struct {
|
||||
sceneID int
|
||||
resumeTime *float64
|
||||
playDuration *float64
|
||||
}
|
||||
|
||||
type addViewsCall struct {
|
||||
sceneID int
|
||||
dates []time.Time
|
||||
}
|
||||
|
||||
func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {
|
||||
m.mu.Lock()
|
||||
m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{
|
||||
sceneID: sceneID,
|
||||
resumeTime: resumeTime,
|
||||
playDuration: playDuration,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {
|
||||
m.mu.Lock()
|
||||
m.addViewsCalls = append(m.addViewsCalls, addViewsCall{
|
||||
sceneID: sceneID,
|
||||
dates: dates,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
// mockConfig is a mock implementation of ActivityConfig
|
||||
type mockConfig struct {
|
||||
enabled bool
|
||||
minPlayPercent int
|
||||
}
|
||||
|
||||
func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool {
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
func (c *mockConfig) GetMinimumPlayPercent() int {
|
||||
return c.minPlayPercent
|
||||
}
|
||||
|
||||
func TestStreamSession_PercentWatched(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
lastActivity time.Time
|
||||
videoDuration float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "no video duration",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "half watched",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50%
|
||||
expected: 50.0,
|
||||
},
|
||||
{
|
||||
name: "fully watched",
|
||||
startTime: now.Add(-120 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100%
|
||||
expected: 100.0,
|
||||
},
|
||||
{
|
||||
name: "quarter watched",
|
||||
startTime: now.Add(-30 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25%
|
||||
expected: 25.0,
|
||||
},
|
||||
{
|
||||
name: "elapsed exceeds duration - capped at 100%",
|
||||
startTime: now.Add(-180 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100%
|
||||
expected: 100.0,
|
||||
},
|
||||
{
|
||||
name: "no elapsed time",
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
session := &streamSession{
|
||||
StartTime: tt.startTime,
|
||||
LastActivity: tt.lastActivity,
|
||||
VideoDuration: tt.videoDuration,
|
||||
}
|
||||
result := session.percentWatched()
|
||||
assert.InDelta(t, tt.expected, result, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSession_EstimatedResumeTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
lastActivity time.Time
|
||||
videoDuration float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "no elapsed time",
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "half way through",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s
|
||||
expected: 60.0,
|
||||
},
|
||||
{
|
||||
name: "quarter way through",
|
||||
startTime: now.Add(-30 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s
|
||||
expected: 30.0,
|
||||
},
|
||||
{
|
||||
name: "98% complete - should reset to 0",
|
||||
startTime: now.Add(-118 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 98.3% elapsed, should reset
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "100% complete - should reset to 0",
|
||||
startTime: now.Add(-120 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "elapsed exceeds duration - capped and reset to 0",
|
||||
startTime: now.Add(-180 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "no video duration",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
session := &streamSession{
|
||||
StartTime: tt.startTime,
|
||||
LastActivity: tt.lastActivity,
|
||||
VideoDuration: tt.videoDuration,
|
||||
}
|
||||
result := session.estimatedResumeTime()
|
||||
assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKey(t *testing.T) {
|
||||
key := sessionKey("192.168.1.100", 42)
|
||||
assert.Equal(t, "192.168.1.100:42", key)
|
||||
}
|
||||
|
||||
func TestActivityTracker_RecordRequest(t *testing.T) {
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil, // Don't need DB for this test
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Record first request - should create new session
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
session := tracker.sessions["192.168.1.100:42"]
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.NotNil(t, session)
|
||||
assert.Equal(t, 42, session.SceneID)
|
||||
assert.Equal(t, "192.168.1.100", session.ClientIP)
|
||||
assert.Equal(t, 120.0, session.VideoDuration)
|
||||
assert.False(t, session.StartTime.IsZero())
|
||||
assert.False(t, session.LastActivity.IsZero())
|
||||
|
||||
// Record second request - should update LastActivity
|
||||
firstActivity := session.LastActivity
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
session = tracker.sessions["192.168.1.100:42"]
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.True(t, session.LastActivity.After(firstActivity))
|
||||
}
|
||||
|
||||
func TestActivityTracker_DisabledTracking(t *testing.T) {
|
||||
config := &mockConfig{enabled: false, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Record request - should be ignored when tracking is disabled
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
sessionCount := len(tracker.sessions)
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 0, sessionCount)
|
||||
}
|
||||
|
||||
func TestActivityTracker_SessionExpiration(t *testing.T) {
|
||||
// For this test, we'll test the session expiration logic directly
|
||||
// without the full transaction manager integration
|
||||
|
||||
sceneWriter := &mockSceneWriter{}
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
||||
|
||||
// Create a tracker with nil txnManager - we'll test processCompletedSession separately
|
||||
// Here we just verify the session management logic
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil, // Skip DB calls for this test
|
||||
sceneWriter: sceneWriter,
|
||||
config: config,
|
||||
sessionTimeout: 100 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Manually add a session
|
||||
// Use a short video duration (1 second) so the test can verify expiration quickly.
|
||||
now := time.Now()
|
||||
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
||||
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
||||
VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
assert.Len(t, tracker.sessions, 1)
|
||||
|
||||
// Process expired sessions - this will try to save activity but txnManager is nil
|
||||
// so it will skip the DB calls but still remove the session
|
||||
tracker.processExpiredSessions()
|
||||
|
||||
// Verify session was removed (even though DB calls were skipped)
|
||||
assert.Len(t, tracker.sessions, 0)
|
||||
}
|
||||
|
||||
func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) {
|
||||
// Test that sessions expire when user stops watching early (before video ends)
|
||||
// This was a bug where sessions wouldn't expire until video duration passed
|
||||
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: 100 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// User started watching a 30-minute video but stopped after 5 seconds
|
||||
now := time.Now()
|
||||
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
||||
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
||||
VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time
|
||||
}
|
||||
|
||||
assert.Len(t, tracker.sessions, 1)
|
||||
|
||||
// Session should expire because timeSinceActivity > timeout
|
||||
// Even though the video is 30 minutes and only 5 seconds have passed
|
||||
tracker.processExpiredSessions()
|
||||
|
||||
// Verify session was expired
|
||||
assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration")
|
||||
}
|
||||
|
||||
func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) {
|
||||
// Test the threshold logic without full transaction integration
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold
|
||||
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: 50 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Test that getMinimumPlayPercent returns the configured value
|
||||
assert.Equal(t, 75, tracker.getMinimumPlayPercent())
|
||||
|
||||
// Create a session with 30% watched (36 seconds of a 120 second video)
|
||||
now := time.Now()
|
||||
session := &streamSession{
|
||||
SceneID: 42,
|
||||
StartTime: now.Add(-36 * time.Second),
|
||||
LastActivity: now,
|
||||
VideoDuration: 120.0,
|
||||
}
|
||||
|
||||
// 30% is below 75% threshold
|
||||
percentWatched := session.percentWatched()
|
||||
assert.InDelta(t, 30.0, percentWatched, 0.1)
|
||||
assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent()))
|
||||
}
|
||||
|
||||
func TestActivityTracker_MultipleSessions(t *testing.T) {
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Different clients watching same scene
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
tracker.RecordRequest(42, "192.168.1.101", 120.0)
|
||||
|
||||
// Same client watching different scenes
|
||||
tracker.RecordRequest(43, "192.168.1.100", 180.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
assert.Len(t, tracker.sessions, 3)
|
||||
tracker.mutex.RUnlock()
|
||||
}
|
||||
|
||||
func TestActivityTracker_ShortSessionIgnored(t *testing.T) {
|
||||
// Test that short sessions are ignored
|
||||
// Create a session with only ~0.8% watched (1 second of a 120 second video)
|
||||
now := time.Now()
|
||||
session := &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-1 * time.Second), // Only 1 second
|
||||
LastActivity: now,
|
||||
VideoDuration: 120.0, // 2 minutes
|
||||
}
|
||||
|
||||
// Verify percent watched is below threshold (1s / 120s = 0.83%)
|
||||
assert.InDelta(t, 0.83, session.percentWatched(), 0.1)
|
||||
|
||||
// Verify elapsed time is short
|
||||
elapsed := session.LastActivity.Sub(session.StartTime).Seconds()
|
||||
assert.InDelta(t, 1.0, elapsed, 0.5)
|
||||
|
||||
// Both are below the minimum thresholds (1% and 5 seconds)
|
||||
percentWatched := session.percentWatched()
|
||||
shouldSkip := percentWatched < 1 && elapsed < 5
|
||||
assert.True(t, shouldSkip, "Short session should be skipped")
|
||||
}
|
||||
@@ -278,6 +278,7 @@ type Server struct {
|
||||
repository Repository
|
||||
sceneServer sceneServer
|
||||
ipWhitelistManager *ipWhitelistManager
|
||||
activityTracker *ActivityTracker
|
||||
VideoSortOrder string
|
||||
|
||||
subscribeLock sync.Mutex
|
||||
@@ -596,6 +597,7 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
sceneId := r.URL.Query().Get("scene")
|
||||
var scene *models.Scene
|
||||
var videoDuration float64
|
||||
repo := me.repository
|
||||
err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error {
|
||||
sceneIdInt, err := strconv.Atoi(sceneId)
|
||||
@@ -603,6 +605,15 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
return nil
|
||||
}
|
||||
scene, _ = repo.SceneFinder.Find(ctx, sceneIdInt)
|
||||
if scene != nil {
|
||||
// Load primary file to get duration for activity tracking
|
||||
if err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil {
|
||||
logger.Debugf("failed to load primary file for scene %d: %v", sceneIdInt, err)
|
||||
}
|
||||
if f := scene.Files.Primary(); f != nil {
|
||||
videoDuration = f.Duration
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -615,6 +626,14 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
|
||||
w.Header().Set("transferMode.dlna.org", "Streaming")
|
||||
w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000")
|
||||
|
||||
// Track activity - uses time-based tracking, updated on each request
|
||||
if me.activityTracker != nil {
|
||||
sceneIdInt, _ := strconv.Atoi(sceneId)
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
me.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration)
|
||||
}
|
||||
|
||||
me.sceneServer.StreamSceneDirect(scene, w, r)
|
||||
})
|
||||
mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -77,13 +77,29 @@ type Config interface {
|
||||
GetDLNADefaultIPWhitelist() []string
|
||||
GetVideoSortOrder() string
|
||||
GetDLNAPortAsString() string
|
||||
GetDLNAActivityTrackingEnabled() bool
|
||||
}
|
||||
|
||||
// activityConfig wraps Config to implement ActivityConfig.
|
||||
type activityConfig struct {
|
||||
config Config
|
||||
minPlayPercent int // cached from UI config
|
||||
}
|
||||
|
||||
func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool {
|
||||
return c.config.GetDLNAActivityTrackingEnabled()
|
||||
}
|
||||
|
||||
func (c *activityConfig) GetMinimumPlayPercent() int {
|
||||
return c.minPlayPercent
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
config Config
|
||||
sceneServer sceneServer
|
||||
ipWhitelistMgr *ipWhitelistManager
|
||||
repository Repository
|
||||
config Config
|
||||
sceneServer sceneServer
|
||||
ipWhitelistMgr *ipWhitelistManager
|
||||
activityTracker *ActivityTracker
|
||||
|
||||
server *Server
|
||||
running bool
|
||||
@@ -155,6 +171,7 @@ func (s *Service) init() error {
|
||||
repository: s.repository,
|
||||
sceneServer: s.sceneServer,
|
||||
ipWhitelistManager: s.ipWhitelistMgr,
|
||||
activityTracker: s.activityTracker,
|
||||
Interfaces: interfaces,
|
||||
HTTPConn: func() net.Listener {
|
||||
conn, err := net.Listen("tcp", dmsConfig.Http)
|
||||
@@ -215,7 +232,14 @@ func (s *Service) init() error {
|
||||
// }
|
||||
|
||||
// NewService initialises and returns a new DLNA service.
|
||||
func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
|
||||
// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter).
|
||||
// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count.
|
||||
func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service {
|
||||
activityCfg := &activityConfig{
|
||||
config: cfg,
|
||||
minPlayPercent: minPlayPercent,
|
||||
}
|
||||
|
||||
ret := &Service{
|
||||
repository: repo,
|
||||
sceneServer: sceneServer,
|
||||
@@ -223,7 +247,8 @@ func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
|
||||
ipWhitelistMgr: &ipWhitelistManager{
|
||||
config: cfg,
|
||||
},
|
||||
mutex: sync.Mutex{},
|
||||
activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg),
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
|
||||
return ret
|
||||
@@ -283,6 +308,12 @@ func (s *Service) Stop(duration *time.Duration) {
|
||||
|
||||
if s.running {
|
||||
logger.Info("Stopping DLNA")
|
||||
|
||||
// Stop activity tracker first to process any pending sessions
|
||||
if s.activityTracker != nil {
|
||||
s.activityTracker.Stop()
|
||||
}
|
||||
|
||||
err := s.server.Close()
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
|
||||
@@ -147,6 +147,9 @@ func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions {
|
||||
if source.Options.IncludeMalePerformers != nil {
|
||||
options.IncludeMalePerformers = source.Options.IncludeMalePerformers
|
||||
}
|
||||
if source.Options.PerformerGenders != nil {
|
||||
options.PerformerGenders = source.Options.PerformerGenders
|
||||
}
|
||||
if source.Options.SkipMultipleMatches != nil {
|
||||
options.SkipMultipleMatches = source.Options.SkipMultipleMatches
|
||||
}
|
||||
@@ -204,13 +207,23 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
|
||||
ret.Partial.StudioID = models.NewOptionalInt(*studioID)
|
||||
}
|
||||
|
||||
includeMalePerformers := true
|
||||
if options.IncludeMalePerformers != nil {
|
||||
includeMalePerformers = *options.IncludeMalePerformers
|
||||
// Determine allowed genders for performer filtering
|
||||
var allowedGenders []models.GenderEnum
|
||||
if options.PerformerGenders != nil {
|
||||
// New field takes precedence
|
||||
allowedGenders = options.PerformerGenders
|
||||
} else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers {
|
||||
// Legacy: if includeMalePerformers is false, include all genders except male
|
||||
for _, g := range models.AllGenderEnum {
|
||||
if g != models.GenderEnumMale {
|
||||
allowedGenders = append(allowedGenders, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
// nil allowedGenders means include all performers
|
||||
|
||||
addSkipSingleNamePerformerTag := false
|
||||
performerIDs, err := rel.performers(ctx, !includeMalePerformers)
|
||||
performerIDs, err := rel.performers(ctx, allowedGenders)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSkipSingleNamePerformer) {
|
||||
addSkipSingleNamePerformerTag = true
|
||||
|
||||
@@ -60,9 +60,15 @@ func TestSceneIdentifier_Identify(t *testing.T) {
|
||||
)
|
||||
|
||||
defaultOptions := &MetadataOptions{
|
||||
SetOrganized: &boolFalse,
|
||||
SetCoverImage: &boolFalse,
|
||||
IncludeMalePerformers: &boolFalse,
|
||||
SetOrganized: &boolFalse,
|
||||
SetCoverImage: &boolFalse,
|
||||
PerformerGenders: []models.GenderEnum{
|
||||
models.GenderEnumFemale,
|
||||
models.GenderEnumTransgenderFemale,
|
||||
models.GenderEnumTransgenderMale,
|
||||
models.GenderEnumIntersex,
|
||||
models.GenderEnumNonBinary,
|
||||
},
|
||||
SkipSingleNamePerformers: &boolFalse,
|
||||
}
|
||||
sources := []ScraperSource{
|
||||
@@ -216,9 +222,15 @@ func TestSceneIdentifier_modifyScene(t *testing.T) {
|
||||
|
||||
boolFalse := false
|
||||
defaultOptions := &MetadataOptions{
|
||||
SetOrganized: &boolFalse,
|
||||
SetCoverImage: &boolFalse,
|
||||
IncludeMalePerformers: &boolFalse,
|
||||
SetOrganized: &boolFalse,
|
||||
SetCoverImage: &boolFalse,
|
||||
PerformerGenders: []models.GenderEnum{
|
||||
models.GenderEnumFemale,
|
||||
models.GenderEnumTransgenderFemale,
|
||||
models.GenderEnumTransgenderMale,
|
||||
models.GenderEnumIntersex,
|
||||
models.GenderEnumNonBinary,
|
||||
},
|
||||
SkipSingleNamePerformers: &boolFalse,
|
||||
}
|
||||
tr := &SceneIdentifier{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scraper"
|
||||
)
|
||||
|
||||
@@ -32,7 +33,10 @@ type MetadataOptions struct {
|
||||
SetCoverImage *bool `json:"setCoverImage"`
|
||||
SetOrganized *bool `json:"setOrganized"`
|
||||
// defaults to true if not provided
|
||||
// Deprecated: use PerformerGenders instead
|
||||
IncludeMalePerformers *bool `json:"includeMalePerformers"`
|
||||
// Filter to only include performers with these genders. If not provided, all genders are included.
|
||||
PerformerGenders []models.GenderEnum `json:"performerGenders"`
|
||||
// defaults to true if not provided
|
||||
SkipMultipleMatches *bool `json:"skipMultipleMatches"`
|
||||
// ID of tag to tag skipped multiple matches with
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -69,7 +70,7 @@ func (g sceneRelationships) studio(ctx context.Context) (*int, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]int, error) {
|
||||
func (g sceneRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) {
|
||||
fieldStrategy := g.fieldOptions["performers"]
|
||||
scraped := g.result.result.Performers
|
||||
|
||||
@@ -97,8 +98,11 @@ func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]
|
||||
singleNamePerformerSkipped := false
|
||||
|
||||
for _, p := range scraped {
|
||||
if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) {
|
||||
continue
|
||||
if allowedGenders != nil && p.Gender != nil {
|
||||
gender := models.GenderEnum(strings.ToUpper(*p.Gender))
|
||||
if !slices.Contains(allowedGenders, gender) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers)
|
||||
@@ -167,7 +171,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
|
||||
} else if createMissing {
|
||||
newTag := t.ToTag(endpoint, nil)
|
||||
|
||||
err := g.tagCreator.Create(ctx, newTag)
|
||||
err := g.tagCreator.Create(ctx, &models.CreateTagInput{
|
||||
Tag: newTag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tag: %w", err)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func Test_sceneRelationships_studio(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
db.Studio.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.ID = validStoredIDInt
|
||||
}).Return(nil)
|
||||
|
||||
@@ -183,13 +183,13 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scene *models.Scene
|
||||
fieldOptions *FieldOptions
|
||||
scraped []*models.ScrapedPerformer
|
||||
ignoreMale bool
|
||||
want []int
|
||||
wantErr bool
|
||||
name string
|
||||
scene *models.Scene
|
||||
fieldOptions *FieldOptions
|
||||
scraped []*models.ScrapedPerformer
|
||||
allowedGenders []models.GenderEnum
|
||||
want []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"ignore",
|
||||
@@ -202,7 +202,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
@@ -211,7 +211,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
emptyScene,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedPerformer{},
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
@@ -225,7 +225,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
StoredID: &existingPerformerStr,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
@@ -239,7 +239,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
[]int{existingPerformerID, validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
@@ -254,7 +254,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
Gender: &male,
|
||||
},
|
||||
},
|
||||
true,
|
||||
[]models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
@@ -270,7 +270,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
[]int{validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
@@ -287,7 +287,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
Gender: &female,
|
||||
},
|
||||
},
|
||||
true,
|
||||
[]models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary},
|
||||
[]int{validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
@@ -304,7 +304,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
StoredID: &invalidStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
@@ -319,7 +319,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, err := tr.performers(testCtx, tt.ignoreMale)
|
||||
got, err := tr.performers(testCtx, tt.allowedGenders)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -368,14 +368,14 @@ func Test_sceneRelationships_tags(t *testing.T) {
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.Tag) bool {
|
||||
return p.Name == validName
|
||||
db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool {
|
||||
return p.Tag.Name == validName
|
||||
})).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.Tag)
|
||||
t.ID = validStoredIDInt
|
||||
t := args.Get(1).(*models.CreateTagInput)
|
||||
t.Tag.ID = validStoredIDInt
|
||||
}).Return(nil)
|
||||
db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.Tag) bool {
|
||||
return p.Name == invalidName
|
||||
db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool {
|
||||
return p.Tag.Name == invalidName
|
||||
})).Return(errors.New("error creating tag"))
|
||||
|
||||
tr := sceneRelationships{
|
||||
|
||||
@@ -21,13 +21,13 @@ func Test_createMissingStudio(t *testing.T) {
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool {
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {
|
||||
return p.Name == validName
|
||||
})).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.ID = createdID
|
||||
}).Return(nil)
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool {
|
||||
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {
|
||||
return p.Name == invalidName
|
||||
})).Return(errors.New("error creating studio"))
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ const (
|
||||
CSSEnabled = "cssenabled"
|
||||
JavascriptEnabled = "javascriptenabled"
|
||||
CustomLocalesEnabled = "customlocalesenabled"
|
||||
DisableCustomizations = "disable_customizations"
|
||||
|
||||
ShowScrubber = "show_scrubber"
|
||||
showScrubberDefault = true
|
||||
@@ -219,6 +220,7 @@ const (
|
||||
DisableDropdownCreateStudio = "disable_dropdown_create.studio"
|
||||
DisableDropdownCreateTag = "disable_dropdown_create.tag"
|
||||
DisableDropdownCreateMovie = "disable_dropdown_create.movie"
|
||||
DisableDropdownCreateGallery = "disable_dropdown_create.gallery"
|
||||
|
||||
HandyKey = "handy_key"
|
||||
FunscriptOffset = "funscript_offset"
|
||||
@@ -1311,6 +1313,7 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate {
|
||||
Studio: i.getBool(DisableDropdownCreateStudio),
|
||||
Tag: i.getBool(DisableDropdownCreateTag),
|
||||
Movie: i.getBool(DisableDropdownCreateMovie),
|
||||
Gallery: i.getBool(DisableDropdownCreateGallery),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1321,6 +1324,26 @@ func (i *Config) GetUIConfiguration() map[string]interface{} {
|
||||
return i.forKey(UI).Cut(UI).Raw()
|
||||
}
|
||||
|
||||
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
||||
// watched before incrementing the play count. Returns 0 if not configured.
|
||||
func (i *Config) GetMinimumPlayPercent() int {
|
||||
uiConfig := i.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
return 0
|
||||
}
|
||||
if val, ok := uiConfig["minimumPlayPercent"]; ok {
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (i *Config) SetUIConfiguration(v map[string]interface{}) {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
@@ -1457,6 +1480,13 @@ func (i *Config) GetCustomLocalesEnabled() bool {
|
||||
return i.getBool(CustomLocalesEnabled)
|
||||
}
|
||||
|
||||
// GetDisableCustomizations returns true if all customizations (plugins, custom CSS,
|
||||
// custom JavaScript, and custom locales) should be disabled. This is useful for
|
||||
// troubleshooting issues without permanently disabling individual customizations.
|
||||
func (i *Config) GetDisableCustomizations() bool {
|
||||
return i.getBool(DisableCustomizations)
|
||||
}
|
||||
|
||||
func (i *Config) GetHandyKey() string {
|
||||
return i.getString(HandyKey)
|
||||
}
|
||||
@@ -1613,6 +1643,22 @@ func (i *Config) GetDLNAPortAsString() string {
|
||||
return ":" + strconv.Itoa(i.GetDLNAPort())
|
||||
}
|
||||
|
||||
// GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled.
|
||||
// This uses the same "trackActivity" UI setting that controls frontend play history tracking.
|
||||
// When enabled, scenes played via DLNA will have their play count and duration tracked.
|
||||
func (i *Config) GetDLNAActivityTrackingEnabled() bool {
|
||||
uiConfig := i.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
return true // Default to enabled
|
||||
}
|
||||
if val, ok := uiConfig["trackActivity"]; ok {
|
||||
if v, ok := val.(bool); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return true // Default to enabled
|
||||
}
|
||||
|
||||
// GetVideoSortOrder returns the sort order to display videos. If
|
||||
// empty, videos will be sorted by titles.
|
||||
func (i *Config) GetVideoSortOrder() string {
|
||||
|
||||
@@ -11,8 +11,10 @@ type ScanMetadataOptions struct {
|
||||
ScanGenerateImagePreviews bool `json:"scanGenerateImagePreviews"`
|
||||
// Generate sprites during scan
|
||||
ScanGenerateSprites bool `json:"scanGenerateSprites"`
|
||||
// Generate phashes during scan
|
||||
// Generate video phashes during scan
|
||||
ScanGeneratePhashes bool `json:"scanGeneratePhashes"`
|
||||
// Generate image phashes during scan
|
||||
ScanGenerateImagePhashes bool `json:"scanGenerateImagePhashes"`
|
||||
// Generate image thumbnails during scan
|
||||
ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"`
|
||||
// Generate image thumbnails during scan
|
||||
|
||||
@@ -105,4 +105,5 @@ type ConfigDisableDropdownCreate struct {
|
||||
Tag bool `json:"tag"`
|
||||
Studio bool `json:"studio"`
|
||||
Movie bool `json:"movie"`
|
||||
Gallery bool `json:"gallery"`
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
|
||||
}
|
||||
|
||||
dlnaRepository := dlna.NewRepository(repo)
|
||||
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer)
|
||||
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer, repo.Scene, cfg.GetMinimumPlayPercent())
|
||||
|
||||
mgr := &Manager{
|
||||
Config: cfg,
|
||||
@@ -313,6 +313,7 @@ func (s *Manager) RefreshFFMpeg(ctx context.Context) {
|
||||
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
|
||||
s.FFProbe = ffmpeg.NewFFProbe(ffprobePath)
|
||||
|
||||
s.FFMpeg.InitHWSupport(ctx)
|
||||
// initialise hardware support with background context
|
||||
s.FFMpeg.InitHWSupport(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +219,11 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
|
||||
// paths since they must not be relative. The config file property is
|
||||
// resolved to an absolute path when stash is run normally, so convert
|
||||
// relative paths to absolute paths during setup.
|
||||
configFile, _ := filepath.Abs(input.ConfigLocation)
|
||||
|
||||
// #6287 - this should no longer be necessary since the ffmpeg code
|
||||
// converts to absolute paths. Converting the config location to
|
||||
// absolute means that scraper and plugin paths default to absolute
|
||||
// which we don't want.
|
||||
configFile := input.ConfigLocation
|
||||
configDir := filepath.Dir(configFile)
|
||||
|
||||
if exists, _ := fsutil.DirExists(configDir); !exists {
|
||||
|
||||
@@ -100,6 +100,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cfg := config.GetInstance()
|
||||
|
||||
scanner := &file.Scanner{
|
||||
Repository: file.NewRepository(s.Repository),
|
||||
FileDecorators: []file.Decorator{
|
||||
@@ -118,6 +120,10 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
|
||||
},
|
||||
FingerprintCalculator: &fingerprintCalculator{s.Config},
|
||||
FS: &file.OsFS{},
|
||||
ZipFileExtensions: cfg.GetGalleryExtensions(),
|
||||
// ScanFilters is set in ScanJob.Execute
|
||||
// HandlerRequiredFilters is set in ScanJob.Execute
|
||||
Rescan: input.Rescan,
|
||||
}
|
||||
|
||||
scanJob := ScanJob{
|
||||
|
||||
@@ -13,14 +13,14 @@ type SceneService interface {
|
||||
Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error)
|
||||
AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error
|
||||
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
|
||||
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
|
||||
|
||||
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
sceneFingerprintGetter
|
||||
}
|
||||
|
||||
type ImageService interface {
|
||||
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
|
||||
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type GalleryService interface {
|
||||
SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error
|
||||
ResetCover(ctx context.Context, g *models.Gallery) error
|
||||
|
||||
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
|
||||
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error)
|
||||
|
||||
ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error
|
||||
|
||||
|
||||
@@ -300,7 +300,10 @@ func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *fil
|
||||
// only delete if the scene has no other files
|
||||
if len(scene.Files.List()) <= 1 {
|
||||
logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName())
|
||||
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, true, false); err != nil {
|
||||
const deleteGenerated = true
|
||||
const deleteFile = false
|
||||
const destroyFileEntry = false
|
||||
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -421,7 +424,10 @@ func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *fil
|
||||
|
||||
if len(i.Files.List()) <= 1 {
|
||||
logger.Infof("Deleting image %q since it has no other related files", i.DisplayName())
|
||||
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, true, false); err != nil {
|
||||
const deleteGenerated = true
|
||||
const deleteFile = false
|
||||
const destroyFileEntry = false
|
||||
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type GenerateMetadataInput struct {
|
||||
// Generate transcodes even if not required
|
||||
ForceTranscodes bool `json:"forceTranscodes"`
|
||||
Phashes bool `json:"phashes"`
|
||||
ImagePhashes bool `json:"imagePhashes"`
|
||||
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
|
||||
ClipPreviews bool `json:"clipPreviews"`
|
||||
ImageThumbnails bool `json:"imageThumbnails"`
|
||||
@@ -36,6 +37,10 @@ type GenerateMetadataInput struct {
|
||||
SceneIDs []string `json:"sceneIDs"`
|
||||
// marker ids to generate for
|
||||
MarkerIDs []string `json:"markerIDs"`
|
||||
// image ids to generate for
|
||||
ImageIDs []string `json:"imageIDs"`
|
||||
// gallery ids to generate for
|
||||
GalleryIDs []string `json:"galleryIDs"`
|
||||
// overwrite existing media
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
@@ -73,6 +78,7 @@ type totalsGenerate struct {
|
||||
markers int64
|
||||
transcodes int64
|
||||
phashes int64
|
||||
imagePhashes int64
|
||||
interactiveHeatmapSpeeds int64
|
||||
clipPreviews int64
|
||||
imageThumbnails int64
|
||||
@@ -82,8 +88,9 @@ type totalsGenerate struct {
|
||||
|
||||
func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
var scenes []*models.Scene
|
||||
var err error
|
||||
var markers []*models.SceneMarker
|
||||
var images []*models.Image
|
||||
var err error
|
||||
|
||||
j.overwrite = j.input.Overwrite
|
||||
j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||
@@ -105,6 +112,14 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
imageIDs, err := stringslice.StringSliceToIntSlice(j.input.ImageIDs)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
galleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
|
||||
g := &generate.Generator{
|
||||
Encoder: instance.FFMpeg,
|
||||
@@ -118,7 +133,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
r := j.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Scene
|
||||
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 {
|
||||
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 {
|
||||
j.queueTasks(ctx, g, queue)
|
||||
} else {
|
||||
if len(j.input.SceneIDs) > 0 {
|
||||
@@ -141,6 +156,33 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
j.queueMarkerJob(g, m, queue)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.input.ImageIDs) > 0 {
|
||||
images, err = r.Image.FindMany(ctx, imageIDs)
|
||||
for _, i := range images {
|
||||
if err := i.LoadFiles(ctx, r.Image); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
j.queueImageJob(g, i, queue)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.input.GalleryIDs) > 0 {
|
||||
for _, galleryID := range galleryIDs {
|
||||
imgs, err := r.Image.FindByGalleryID(ctx, galleryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, img := range imgs {
|
||||
if err := img.LoadFiles(ctx, r.Image); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
j.queueImageJob(g, img, queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -172,14 +214,17 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
|
||||
if j.input.Phashes {
|
||||
logMsg += fmt.Sprintf(" %d phashes", totals.phashes)
|
||||
}
|
||||
if j.input.ImagePhashes {
|
||||
logMsg += fmt.Sprintf(" %d image phashes", totals.imagePhashes)
|
||||
}
|
||||
if j.input.InteractiveHeatmapsSpeeds {
|
||||
logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds)
|
||||
}
|
||||
if j.input.ClipPreviews {
|
||||
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
|
||||
logMsg += fmt.Sprintf(" %d image clip previews", totals.clipPreviews)
|
||||
}
|
||||
if j.input.ImageThumbnails {
|
||||
logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails)
|
||||
logMsg += fmt.Sprintf(" %d image thumbnails", totals.imageThumbnails)
|
||||
}
|
||||
if logMsg == "Generating" {
|
||||
logMsg = "Nothing selected to generate"
|
||||
@@ -284,7 +329,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato
|
||||
|
||||
r := j.repository
|
||||
|
||||
for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; {
|
||||
for more := j.input.ClipPreviews || j.input.ImageThumbnails || j.input.ImagePhashes; more; {
|
||||
if job.IsCancelled(ctx) {
|
||||
return
|
||||
}
|
||||
@@ -411,12 +456,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||
}
|
||||
}
|
||||
|
||||
if j.input.Markers {
|
||||
if j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots {
|
||||
task := &GenerateMarkersTask{
|
||||
repository: r,
|
||||
Scene: scene,
|
||||
Overwrite: j.overwrite,
|
||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||
VideoPreview: j.input.Markers,
|
||||
ImagePreview: j.input.MarkerImagePreviews,
|
||||
Screenshot: j.input.MarkerScreenshots,
|
||||
|
||||
@@ -488,6 +534,9 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
|
||||
Marker: marker,
|
||||
Overwrite: j.overwrite,
|
||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||
VideoPreview: j.input.Markers,
|
||||
ImagePreview: j.input.MarkerImagePreviews,
|
||||
Screenshot: j.input.MarkerScreenshots,
|
||||
generator: g,
|
||||
}
|
||||
j.totals.markers++
|
||||
@@ -521,4 +570,23 @@ func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image,
|
||||
queue <- task
|
||||
}
|
||||
}
|
||||
|
||||
if j.input.ImagePhashes {
|
||||
// generate for all files in image
|
||||
for _, f := range image.Files.List() {
|
||||
if imageFile, ok := f.(*models.ImageFile); ok {
|
||||
task := &GenerateImagePhashTask{
|
||||
repository: j.repository,
|
||||
File: imageFile,
|
||||
Overwrite: j.overwrite,
|
||||
}
|
||||
|
||||
if task.required() {
|
||||
j.totals.imagePhashes++
|
||||
j.totals.tasks++
|
||||
queue <- task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
103
internal/manager/task_generate_image_phash.go
Normal file
103
internal/manager/task_generate_image_phash.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/hash/imagephash"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type GenerateImagePhashTask struct {
|
||||
repository models.Repository
|
||||
File *models.ImageFile
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) GetDescription() string {
|
||||
return fmt.Sprintf("Generating phash for %s", t.File.Path)
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) Start(ctx context.Context) {
|
||||
if !t.required() {
|
||||
return
|
||||
}
|
||||
|
||||
var hash int64
|
||||
set := false
|
||||
|
||||
// #4393 - if there is a file with the same md5, we can use the same phash
|
||||
// only use this if we're not overwriting
|
||||
if !t.Overwrite {
|
||||
existing, err := t.findExistingPhash(ctx)
|
||||
if err != nil {
|
||||
logger.Warnf("Error finding existing phash: %v", err)
|
||||
} else if existing != nil {
|
||||
logger.Infof("Using existing phash for %s", t.File.Path)
|
||||
hash = existing.(int64)
|
||||
set = true
|
||||
}
|
||||
}
|
||||
|
||||
if !set {
|
||||
generated, err := imagephash.Generate(instance.FFMpeg, t.File)
|
||||
if err != nil {
|
||||
logger.Errorf("Error generating phash for %q: %v", t.File.Path, err)
|
||||
logErrorOutput(err)
|
||||
return
|
||||
}
|
||||
|
||||
hash = int64(*generated)
|
||||
}
|
||||
|
||||
r := t.repository
|
||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{
|
||||
Type: models.FingerprintTypePhash,
|
||||
Fingerprint: hash,
|
||||
})
|
||||
|
||||
return r.File.Update(ctx, t.File)
|
||||
}); err != nil && ctx.Err() == nil {
|
||||
logger.Errorf("Error setting phash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) {
|
||||
r := t.repository
|
||||
var ret interface{}
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
md5 := t.File.Fingerprints.Get(models.FingerprintTypeMD5)
|
||||
|
||||
// find other files with the same md5
|
||||
files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Fingerprint: md5,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding files by md5: %w", err)
|
||||
}
|
||||
|
||||
// find the first file with a phash
|
||||
for _, file := range files {
|
||||
if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil {
|
||||
ret = phash
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *GenerateImagePhashTask) required() bool {
|
||||
if t.Overwrite {
|
||||
return true
|
||||
}
|
||||
|
||||
return t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type GenerateMarkersTask struct {
|
||||
Overwrite bool
|
||||
fileNamingAlgorithm models.HashAlgorithm
|
||||
|
||||
VideoPreview bool
|
||||
ImagePreview bool
|
||||
Screenshot bool
|
||||
|
||||
@@ -115,9 +116,11 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene
|
||||
|
||||
g := t.generator
|
||||
|
||||
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
|
||||
logger.Errorf("[generator] failed to generate marker video: %v", err)
|
||||
logErrorOutput(err)
|
||||
if t.VideoPreview {
|
||||
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
|
||||
logger.Errorf("[generator] failed to generate marker video: %v", err)
|
||||
logErrorOutput(err)
|
||||
}
|
||||
}
|
||||
|
||||
if t.ImagePreview {
|
||||
@@ -164,7 +167,7 @@ func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bo
|
||||
return false
|
||||
}
|
||||
|
||||
videoExists := t.videoExists(sceneChecksum, seconds)
|
||||
videoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds)
|
||||
imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds)
|
||||
screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func (t *GeneratePhashTask) Start(ctx context.Context) {
|
||||
if !set {
|
||||
generated, err := videophash.Generate(instance.FFMpeg, t.File)
|
||||
if err != nil {
|
||||
logger.Errorf("Error generating phash: %v", err)
|
||||
logger.Errorf("Error generating phash for %q: %v", t.File.Path, err)
|
||||
logErrorOutput(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler/lru"
|
||||
"github.com/remeh/sizedwaitgroup"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/file/video"
|
||||
@@ -24,14 +28,13 @@ import (
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
)
|
||||
|
||||
type scanner interface {
|
||||
Scan(ctx context.Context, handlers []file.Handler, options file.ScanOptions, progressReporter file.ProgressReporter)
|
||||
}
|
||||
|
||||
type ScanJob struct {
|
||||
scanner scanner
|
||||
scanner *file.Scanner
|
||||
input ScanMetadataInput
|
||||
subscriptions *subscriptionManager
|
||||
|
||||
fileQueue chan file.ScannedFile
|
||||
count int
|
||||
}
|
||||
|
||||
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
@@ -55,22 +58,22 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
nTasks := cfg.GetParallelTasksWithAutoDetection()
|
||||
|
||||
const taskQueueSize = 200000
|
||||
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, cfg.GetParallelTasksWithAutoDetection())
|
||||
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, nTasks)
|
||||
|
||||
var minModTime time.Time
|
||||
if j.input.Filter != nil && j.input.Filter.MinModTime != nil {
|
||||
minModTime = *j.input.Filter.MinModTime
|
||||
}
|
||||
|
||||
j.scanner.Scan(ctx, getScanHandlers(j.input, taskQueue, progress), file.ScanOptions{
|
||||
Paths: paths,
|
||||
ScanFilters: []file.PathFilter{newScanFilter(c, repo, minModTime)},
|
||||
ZipFileExtensions: cfg.GetGalleryExtensions(),
|
||||
ParallelTasks: cfg.GetParallelTasksWithAutoDetection(),
|
||||
HandlerRequiredFilters: []file.Filter{newHandlerRequiredFilter(cfg, repo)},
|
||||
Rescan: j.input.Rescan,
|
||||
}, progress)
|
||||
// HACK - these should really be set in the scanner initialization
|
||||
j.scanner.FileHandlers = getScanHandlers(j.input, taskQueue, progress)
|
||||
j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)}
|
||||
j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)}
|
||||
|
||||
j.runJob(ctx, paths, nTasks, progress)
|
||||
|
||||
taskQueue.Close()
|
||||
|
||||
@@ -86,6 +89,264 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScanJob) runJob(ctx context.Context, paths []string, nTasks int, progress *job.Progress) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
j.fileQueue = make(chan file.ScannedFile, scanQueueSize)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
|
||||
// handle panics in goroutine
|
||||
if p := recover(); p != nil {
|
||||
logger.Errorf("panic while queuing files for scan: %v", p)
|
||||
logger.Errorf(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := j.queueFiles(ctx, paths, progress); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Errorf("error queuing files for scan: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Finished adding files to queue. %d files queued", j.count)
|
||||
}()
|
||||
|
||||
defer wg.Wait()
|
||||
|
||||
j.processQueue(ctx, nTasks, progress)
|
||||
}
|
||||
|
||||
const scanQueueSize = 200000
|
||||
|
||||
func (j *ScanJob) queueFiles(ctx context.Context, paths []string, progress *job.Progress) error {
|
||||
fs := &file.OsFS{}
|
||||
|
||||
defer func() {
|
||||
close(j.fileQueue)
|
||||
|
||||
progress.AddTotal(j.count)
|
||||
progress.Definite()
|
||||
}()
|
||||
|
||||
var err error
|
||||
progress.ExecuteTask("Walking directory tree", func() {
|
||||
for _, p := range paths {
|
||||
err = file.SymWalk(fs, p, j.queueFileFunc(ctx, fs, nil, progress))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.ScannedFile, progress *job.Progress) fs.WalkDirFunc {
|
||||
return func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
logger.Errorf("error scanning %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
logger.Errorf("reading info for %q: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !j.scanner.AcceptEntry(ctx, path, info) {
|
||||
if info.IsDir() {
|
||||
logger.Debugf("Skipping directory %s", path)
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
logger.Debugf("Skipping file %s", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := file.GetFileSize(f, path, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ff := file.ScannedFile{
|
||||
BaseFile: &models.BaseFile{
|
||||
DirEntry: models.DirEntry{
|
||||
ModTime: file.ModTime(info),
|
||||
},
|
||||
Path: path,
|
||||
Basename: filepath.Base(path),
|
||||
Size: size,
|
||||
},
|
||||
FS: f,
|
||||
Info: info,
|
||||
}
|
||||
|
||||
if zipFile != nil {
|
||||
ff.ZipFileID = &zipFile.ID
|
||||
ff.ZipFile = zipFile
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// handle folders immediately
|
||||
if err := j.handleFolder(ctx, ff, progress); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
|
||||
// skip the directory since we won't be able to process the files anyway
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// if zip file is present, we handle immediately
|
||||
if zipFile != nil {
|
||||
progress.ExecuteTask("Scanning "+path, func() {
|
||||
// don't increment progress in zip files
|
||||
if err := j.handleFile(ctx, ff, nil); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
// don't return an error, just skip the file
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Tracef("Queueing file %s for scanning", path)
|
||||
j.fileQueue <- ff
|
||||
|
||||
j.count++
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress *job.Progress) {
|
||||
if parallelTasks < 1 {
|
||||
parallelTasks = 1
|
||||
}
|
||||
|
||||
wg := sizedwaitgroup.New(parallelTasks)
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
wg.Wait()
|
||||
|
||||
// handle panics in goroutine
|
||||
if p := recover(); p != nil {
|
||||
logger.Errorf("panic while scanning files: %v", p)
|
||||
logger.Errorf(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
for f := range j.fileQueue {
|
||||
logger.Tracef("Processing queued file %s", f.Path)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
ff := f
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
j.processQueueItem(ctx, ff, progress)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (j *ScanJob) processQueueItem(ctx context.Context, f file.ScannedFile, progress *job.Progress) {
|
||||
progress.ExecuteTask("Scanning "+f.Path, func() {
|
||||
var err error
|
||||
if f.Info.IsDir() {
|
||||
err = j.handleFolder(ctx, f, progress)
|
||||
} else {
|
||||
err = j.handleFile(ctx, f, progress)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", f.Path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (j *ScanJob) handleFolder(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
|
||||
if progress != nil {
|
||||
defer progress.Increment()
|
||||
}
|
||||
|
||||
_, err := j.scanner.ScanFolder(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
|
||||
if progress != nil {
|
||||
defer progress.Increment()
|
||||
}
|
||||
|
||||
r, err := j.scanner.ScanFile(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// handle rename should have already handled the contents of the zip file
|
||||
// so shouldn't need to scan it again
|
||||
|
||||
if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) {
|
||||
ff := r.File
|
||||
f.BaseFile = ff.Base()
|
||||
|
||||
// scan zip files with a different context that is not cancellable
|
||||
// cancelling while scanning zip file contents results in the scan
|
||||
// contents being partially completed
|
||||
zipCtx := context.WithoutCancel(ctx)
|
||||
|
||||
if err := j.scanZipFile(zipCtx, f, progress); err != nil {
|
||||
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
|
||||
zipFS, err := f.FS.OpenZip(f.Path, f.Size)
|
||||
if err != nil {
|
||||
if errors.Is(err, file.ErrNotReaderAt) {
|
||||
// can't walk the zip file
|
||||
// just return
|
||||
logger.Debugf("Skipping zip file %q as it cannot be opened for walking", f.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
defer zipFS.Close()
|
||||
|
||||
return file.SymWalk(zipFS, f.Path, j.queueFileFunc(ctx, zipFS, &f, progress))
|
||||
}
|
||||
|
||||
type extensionConfig struct {
|
||||
vidExt []string
|
||||
imgExt []string
|
||||
@@ -463,6 +724,29 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
|
||||
}
|
||||
}
|
||||
|
||||
if t.ScanGenerateImagePhashes {
|
||||
progress.AddTotal(1)
|
||||
phashFn := func(ctx context.Context) {
|
||||
mgr := GetInstance()
|
||||
// Only generate phash for image files, not video files
|
||||
if imageFile, ok := f.(*models.ImageFile); ok {
|
||||
taskPhash := GenerateImagePhashTask{
|
||||
repository: mgr.Repository,
|
||||
File: imageFile,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
taskPhash.Start(ctx)
|
||||
}
|
||||
progress.Increment()
|
||||
}
|
||||
|
||||
if g.sequentialScanning {
|
||||
phashFn(ctx)
|
||||
} else {
|
||||
g.taskQueue.Add(fmt.Sprintf("Generating phash for %s", path), phashFn)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,32 @@ const minHeight int = 480
|
||||
|
||||
// Tests all (given) hardware codec's
|
||||
func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
// do the hardware codec tests in a separate goroutine to avoid blocking
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
f.initHWSupport(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// log if the initialization takes too long
|
||||
const hwInitLogTimeoutSecondsDefault = 5
|
||||
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second
|
||||
timer := time.NewTimer(hwInitLogTimeoutSeconds)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds)
|
||||
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
|
||||
case <-done:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (f *FFMpeg) initHWSupport(ctx context.Context) {
|
||||
var hwCodecSupport []VideoCodec
|
||||
|
||||
// Note that the first compatible codec is returned, so order is important
|
||||
@@ -83,6 +109,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
defer cancel()
|
||||
|
||||
cmd := f.Command(testCtx, args)
|
||||
cmd.WaitDelay = time.Second
|
||||
logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
@@ -112,6 +139,8 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
}
|
||||
logger.Info(outstr)
|
||||
|
||||
f.hwCodecSupportMutex.Lock()
|
||||
defer f.hwCodecSupportMutex.Unlock()
|
||||
f.hwCodecSupport = hwCodecSupport
|
||||
}
|
||||
|
||||
@@ -334,8 +363,11 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw
|
||||
args = args.Append("scale_qsv=format=nv12")
|
||||
}
|
||||
case VideoCodecRK264:
|
||||
// For Rockchip, no extra mapping here. If there is no scale filter,
|
||||
// leave frames in DRM_PRIME for the encoder.
|
||||
// Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15.
|
||||
// h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12
|
||||
if fullhw {
|
||||
args = args.Append("scale_rkrga=w=iw:h=ih:format=nv12")
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
@@ -370,7 +402,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in
|
||||
// by downloading the scaled frame to system RAM and re-uploading it.
|
||||
// The filter chain below uses a zero-copy approach, passing the hardware-scaled
|
||||
// frame directly to the encoder. This is more efficient but may be less stable.
|
||||
template = "scale_rkrga=$value"
|
||||
template = "scale_rkrga=$value:format=nv12"
|
||||
default:
|
||||
return VideoFilter(sargs)
|
||||
}
|
||||
@@ -411,7 +443,7 @@ func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHei
|
||||
|
||||
// Return if a hardware accelerated for HLS is available
|
||||
func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
|
||||
for _, element := range f.hwCodecSupport {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
switch element {
|
||||
case VideoCodecN264,
|
||||
VideoCodecN264H,
|
||||
@@ -429,7 +461,7 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
|
||||
|
||||
// Return if a hardware accelerated codec for MP4 is available
|
||||
func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
|
||||
for _, element := range f.hwCodecSupport {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
switch element {
|
||||
case VideoCodecN264,
|
||||
VideoCodecN264H,
|
||||
@@ -445,7 +477,7 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
|
||||
|
||||
// Return if a hardware accelerated codec for WebM is available
|
||||
func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec {
|
||||
for _, element := range f.hwCodecSupport {
|
||||
for _, element := range f.getHWCodecSupport() {
|
||||
switch element {
|
||||
case VideoCodecIVP9,
|
||||
VideoCodecVVP9:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
@@ -216,9 +217,10 @@ func (v Version) String() string {
|
||||
|
||||
// FFMpeg provides an interface to ffmpeg.
|
||||
type FFMpeg struct {
|
||||
ffmpeg string
|
||||
version Version
|
||||
hwCodecSupport []VideoCodec
|
||||
ffmpeg string
|
||||
version Version
|
||||
hwCodecSupport []VideoCodec
|
||||
hwCodecSupportMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// Creates a new FFMpeg encoder
|
||||
@@ -241,3 +243,9 @@ func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
|
||||
func (f *FFMpeg) Path() string {
|
||||
return f.ffmpeg
|
||||
}
|
||||
|
||||
func (f *FFMpeg) getHWCodecSupport() []VideoCodec {
|
||||
f.hwCodecSupportMutex.RLock()
|
||||
defer f.hwCodecSupportMutex.RUnlock()
|
||||
return f.hwCodecSupport
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
@@ -35,3 +39,23 @@ func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {
|
||||
func (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error {
|
||||
return txn.WithDatabase(ctx, r.TxnManager, fn)
|
||||
}
|
||||
|
||||
// ModTime returns the modification time truncated to seconds.
|
||||
func ModTime(info fs.FileInfo) time.Time {
|
||||
// truncate to seconds, since we don't store beyond that in the database
|
||||
return info.ModTime().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// GetFileSize gets the size of the file, taking into account symlinks.
|
||||
func GetFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) {
|
||||
// #2196/#3042 - replace size with target size if file is a symlink
|
||||
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
targetInfo, err := f.Stat(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading info for symlink %q: %w", path, err)
|
||||
}
|
||||
return targetInfo.Size(), nil
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (d *folderRenameDetector) bestCandidate() *models.Folder {
|
||||
return best.folder
|
||||
}
|
||||
|
||||
func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.Folder, error) {
|
||||
func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
// in order for a folder to be considered moved, the existing folder must be
|
||||
// missing, and the majority of the old folder's files must be present, unchanged,
|
||||
// in the new folder.
|
||||
@@ -88,7 +88,7 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.
|
||||
|
||||
r := s.Repository
|
||||
|
||||
if err := symWalk(file.fs, file.Path, func(path string, d fs.DirEntry, err error) error {
|
||||
if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
logger.Errorf("error scanning %s: %v", path, err)
|
||||
@@ -111,11 +111,11 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.acceptEntry(ctx, path, info) {
|
||||
if !s.AcceptEntry(ctx, path, info) {
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := getFileSize(file.fs, path, info)
|
||||
size, err := GetFileSize(file.FS, path, info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting file size for %q: %w", path, err)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.
|
||||
}
|
||||
|
||||
// parent folder must be missing
|
||||
_, err = file.fs.Lstat(pf.Path)
|
||||
_, err = file.FS.Lstat(pf.Path)
|
||||
if err == nil {
|
||||
// parent folder exists, not a candidate
|
||||
detector.reject(parentFolderID)
|
||||
|
||||
586
pkg/file/scan.go
586
pkg/file/scan.go
@@ -2,27 +2,16 @@ package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/remeh/sizedwaitgroup"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
scanQueueSize = 200000
|
||||
// maximum number of times to retry in the event of a locked database
|
||||
// use -1 to retry forever
|
||||
maxRetries = -1
|
||||
)
|
||||
|
||||
// Scanner scans files into the database.
|
||||
@@ -55,8 +44,26 @@ type Scanner struct {
|
||||
Repository Repository
|
||||
FingerprintCalculator FingerprintCalculator
|
||||
|
||||
// ZipFileExtensions is a list of file extensions that are considered zip files.
|
||||
// Extension does not include the . character.
|
||||
ZipFileExtensions []string
|
||||
|
||||
// ScanFilters are used to determine if a file should be scanned.
|
||||
ScanFilters []PathFilter
|
||||
|
||||
// HandlerRequiredFilters are used to determine if an unchanged file needs to be handled
|
||||
HandlerRequiredFilters []Filter
|
||||
|
||||
// FileDecorators are applied to files as they are scanned.
|
||||
FileDecorators []Decorator
|
||||
|
||||
// handlers are called after a file has been scanned.
|
||||
FileHandlers []Handler
|
||||
|
||||
// Rescan indicates whether files should be rescanned even if they haven't changed.
|
||||
Rescan bool
|
||||
|
||||
folderPathToID sync.Map
|
||||
}
|
||||
|
||||
// FingerprintCalculator calculates a fingerprint for the provided file.
|
||||
@@ -91,246 +98,18 @@ func (d *FilteredDecorator) IsMissingMetadata(ctx context.Context, fs models.FS,
|
||||
return false
|
||||
}
|
||||
|
||||
// ProgressReporter is used to report progress of the scan.
|
||||
type ProgressReporter interface {
|
||||
AddTotal(total int)
|
||||
Increment()
|
||||
Definite()
|
||||
ExecuteTask(description string, fn func())
|
||||
}
|
||||
|
||||
type scanJob struct {
|
||||
*Scanner
|
||||
|
||||
// handlers are called after a file has been scanned.
|
||||
handlers []Handler
|
||||
|
||||
ProgressReports ProgressReporter
|
||||
options ScanOptions
|
||||
|
||||
startTime time.Time
|
||||
fileQueue chan scanFile
|
||||
retryList []scanFile
|
||||
retrying bool
|
||||
folderPathToID sync.Map
|
||||
zipPathToID sync.Map
|
||||
count int
|
||||
|
||||
txnRetryer txn.Retryer
|
||||
}
|
||||
|
||||
// ScanOptions provides options for scanning files.
|
||||
type ScanOptions struct {
|
||||
Paths []string
|
||||
|
||||
// ZipFileExtensions is a list of file extensions that are considered zip files.
|
||||
// Extension does not include the . character.
|
||||
ZipFileExtensions []string
|
||||
|
||||
// ScanFilters are used to determine if a file should be scanned.
|
||||
ScanFilters []PathFilter
|
||||
|
||||
// HandlerRequiredFilters are used to determine if an unchanged file needs to be handled
|
||||
HandlerRequiredFilters []Filter
|
||||
|
||||
ParallelTasks int
|
||||
|
||||
// When true files in path will be rescanned even if they haven't changed
|
||||
Rescan bool
|
||||
}
|
||||
|
||||
// Scan starts the scanning process.
|
||||
func (s *Scanner) Scan(ctx context.Context, handlers []Handler, options ScanOptions, progressReporter ProgressReporter) {
|
||||
job := &scanJob{
|
||||
Scanner: s,
|
||||
handlers: handlers,
|
||||
ProgressReports: progressReporter,
|
||||
options: options,
|
||||
txnRetryer: txn.Retryer{
|
||||
Manager: s.Repository.TxnManager,
|
||||
Retries: maxRetries,
|
||||
},
|
||||
}
|
||||
|
||||
job.execute(ctx)
|
||||
}
|
||||
|
||||
type scanFile struct {
|
||||
// ScannedFile represents a file being scanned.
|
||||
type ScannedFile struct {
|
||||
*models.BaseFile
|
||||
fs models.FS
|
||||
info fs.FileInfo
|
||||
FS models.FS
|
||||
Info fs.FileInfo
|
||||
}
|
||||
|
||||
func (s *scanJob) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
return s.txnRetryer.WithTxn(ctx, fn)
|
||||
}
|
||||
|
||||
func (s *scanJob) withDB(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
return s.Repository.WithDB(ctx, fn)
|
||||
}
|
||||
|
||||
func (s *scanJob) execute(ctx context.Context) {
|
||||
paths := s.options.Paths
|
||||
logger.Infof("scanning %d paths", len(paths))
|
||||
s.startTime = time.Now()
|
||||
|
||||
s.fileQueue = make(chan scanFile, scanQueueSize)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := s.queueFiles(ctx, paths); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Errorf("error queuing files for scan: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Finished adding files to queue. %d files queued", s.count)
|
||||
}()
|
||||
|
||||
defer wg.Wait()
|
||||
|
||||
if err := s.processQueue(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Errorf("error scanning files: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
|
||||
var err error
|
||||
s.ProgressReports.ExecuteTask("Walking directory tree", func() {
|
||||
for _, p := range paths {
|
||||
err = symWalk(s.FS, p, s.queueFileFunc(ctx, s.FS, nil))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
close(s.fileQueue)
|
||||
|
||||
if s.ProgressReports != nil {
|
||||
s.ProgressReports.AddTotal(s.count)
|
||||
s.ProgressReports.Definite()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *scanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *scanFile) fs.WalkDirFunc {
|
||||
return func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
logger.Errorf("error scanning %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
logger.Errorf("reading info for %q: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.acceptEntry(ctx, path, info) {
|
||||
if info.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := getFileSize(f, path, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ff := scanFile{
|
||||
BaseFile: &models.BaseFile{
|
||||
DirEntry: models.DirEntry{
|
||||
ModTime: modTime(info),
|
||||
},
|
||||
Path: path,
|
||||
Basename: filepath.Base(path),
|
||||
Size: size,
|
||||
},
|
||||
fs: f,
|
||||
info: info,
|
||||
}
|
||||
|
||||
if zipFile != nil {
|
||||
zipFileID, err := s.getZipFileID(ctx, zipFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ff.ZipFileID = zipFileID
|
||||
ff.ZipFile = zipFile
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// handle folders immediately
|
||||
if err := s.handleFolder(ctx, ff); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
|
||||
// skip the directory since we won't be able to process the files anyway
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// if zip file is present, we handle immediately
|
||||
if zipFile != nil {
|
||||
s.ProgressReports.ExecuteTask("Scanning "+path, func() {
|
||||
if err := s.handleFile(ctx, ff); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", path, err)
|
||||
}
|
||||
// don't return an error, just skip the file
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
s.fileQueue <- ff
|
||||
|
||||
s.count++
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) {
|
||||
// #2196/#3042 - replace size with target size if file is a symlink
|
||||
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
targetInfo, err := f.Stat(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading info for symlink %q: %w", path, err)
|
||||
}
|
||||
return targetInfo.Size(), nil
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func (s *scanJob) acceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
// AcceptEntry determines if the file entry should be accepted for scanning
|
||||
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
// always accept if there's no filters
|
||||
accept := len(s.options.ScanFilters) == 0
|
||||
for _, filter := range s.options.ScanFilters {
|
||||
accept := len(s.ScanFilters) == 0
|
||||
for _, filter := range s.ScanFilters {
|
||||
// accept if any filter accepts the file
|
||||
if filter.Accept(ctx, path, info) {
|
||||
accept = true
|
||||
@@ -341,102 +120,7 @@ func (s *scanJob) acceptEntry(ctx context.Context, path string, info fs.FileInfo
|
||||
return accept
|
||||
}
|
||||
|
||||
func (s *scanJob) scanZipFile(ctx context.Context, f scanFile) error {
|
||||
zipFS, err := f.fs.OpenZip(f.Path, f.Size)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotReaderAt) {
|
||||
// can't walk the zip file
|
||||
// just return
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
defer zipFS.Close()
|
||||
|
||||
return symWalk(zipFS, f.Path, s.queueFileFunc(ctx, zipFS, &f))
|
||||
}
|
||||
|
||||
func (s *scanJob) processQueue(ctx context.Context) error {
|
||||
parallelTasks := s.options.ParallelTasks
|
||||
if parallelTasks < 1 {
|
||||
parallelTasks = 1
|
||||
}
|
||||
|
||||
wg := sizedwaitgroup.New(parallelTasks)
|
||||
|
||||
if err := func() error {
|
||||
defer wg.Wait()
|
||||
|
||||
for f := range s.fileQueue {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
ff := f
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.processQueueItem(ctx, ff)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.retrying = true
|
||||
|
||||
if err := func() error {
|
||||
defer wg.Wait()
|
||||
|
||||
for _, f := range s.retryList {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
ff := f
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.processQueueItem(ctx, ff)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scanJob) incrementProgress(f scanFile) {
|
||||
// don't increment for files inside zip files since these aren't
|
||||
// counted during the initial walking
|
||||
if s.ProgressReports != nil && f.ZipFile == nil {
|
||||
s.ProgressReports.Increment()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scanJob) processQueueItem(ctx context.Context, f scanFile) {
|
||||
s.ProgressReports.ExecuteTask("Scanning "+f.Path, func() {
|
||||
var err error
|
||||
if f.info.IsDir() {
|
||||
err = s.handleFolder(ctx, f)
|
||||
} else {
|
||||
err = s.handleFile(ctx, f)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("error processing %q: %v", f.Path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderID, error) {
|
||||
func (s *Scanner) getFolderID(ctx context.Context, path string) (*models.FolderID, error) {
|
||||
// check the folder cache first
|
||||
if f, ok := s.folderPathToID.Load(path); ok {
|
||||
v := f.(models.FolderID)
|
||||
@@ -459,48 +143,17 @@ func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderI
|
||||
return &ret.ID, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) getZipFileID(ctx context.Context, zipFile *scanFile) (*models.FileID, error) {
|
||||
if zipFile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if zipFile.ID != 0 {
|
||||
return &zipFile.ID, nil
|
||||
}
|
||||
|
||||
path := zipFile.Path
|
||||
|
||||
// check the folder cache first
|
||||
if f, ok := s.zipPathToID.Load(path); ok {
|
||||
v := f.(models.FileID)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// assume case sensitive when searching for the zip file
|
||||
const caseSensitive = true
|
||||
|
||||
ret, err := s.Repository.File.FindByPath(ctx, path, caseSensitive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting zip file ID for %q: %w", path, err)
|
||||
}
|
||||
|
||||
if ret == nil {
|
||||
return nil, fmt.Errorf("zip file %q doesn't exist in database", zipFile.Path)
|
||||
}
|
||||
|
||||
s.zipPathToID.Store(path, ret.Base().ID)
|
||||
return &ret.Base().ID, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error {
|
||||
// ScanFolder scans the provided folder into the database, returning the folder entry.
|
||||
// If the folder already exists, it is updated if necessary.
|
||||
func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
var f *models.Folder
|
||||
var err error
|
||||
path := file.Path
|
||||
|
||||
return s.withTxn(ctx, func(ctx context.Context) error {
|
||||
defer s.incrementProgress(file)
|
||||
|
||||
err = s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
// determine if folder already exists in data store (by path)
|
||||
// assume case sensitive by default
|
||||
f, err := s.Repository.Folder.FindByPath(ctx, path, true)
|
||||
f, err = s.Repository.Folder.FindByPath(ctx, path, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing folder %q: %w", path, err)
|
||||
}
|
||||
@@ -509,7 +162,7 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error {
|
||||
// case insensitive searching
|
||||
// assume case sensitive if in zip
|
||||
if f == nil && file.ZipFileID == nil {
|
||||
caseSensitive, _ := file.fs.IsPathCaseSensitive(file.Path)
|
||||
caseSensitive, _ := file.FS.IsPathCaseSensitive(file.Path)
|
||||
|
||||
if !caseSensitive {
|
||||
f, err = s.Repository.Folder.FindByPath(ctx, path, false)
|
||||
@@ -536,9 +189,11 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error {
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*models.Folder, error) {
|
||||
func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
renamed, err := s.handleFolderRename(ctx, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -585,7 +240,7 @@ func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*models.Folde
|
||||
return toCreate, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) handleFolderRename(ctx context.Context, file scanFile) (*models.Folder, error) {
|
||||
func (s *Scanner) handleFolderRename(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
// ignore folders in zip files
|
||||
if file.ZipFileID != nil {
|
||||
return nil, nil
|
||||
@@ -626,7 +281,7 @@ func (s *scanJob) handleFolderRename(ctx context.Context, file scanFile) (*model
|
||||
return renamedFrom, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *models.Folder) (*models.Folder, error) {
|
||||
func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing *models.Folder) (*models.Folder, error) {
|
||||
update := false
|
||||
|
||||
// update if mod time is changed
|
||||
@@ -667,22 +322,22 @@ func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *mo
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func modTime(info fs.FileInfo) time.Time {
|
||||
// truncate to seconds, since we don't store beyond that in the database
|
||||
return info.ModTime().Truncate(time.Second)
|
||||
type ScanFileResult struct {
|
||||
File models.File
|
||||
New bool
|
||||
Renamed bool
|
||||
Updated bool
|
||||
}
|
||||
|
||||
func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
|
||||
defer s.incrementProgress(f)
|
||||
|
||||
var ff models.File
|
||||
// ScanFile scans the provided file into the database, returning the scan result.
|
||||
func (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) {
|
||||
var r *ScanFileResult
|
||||
|
||||
// don't use a transaction to check if new or existing
|
||||
if err := s.withDB(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
// determine if file already exists in data store
|
||||
// assume case sensitive when searching for the file to begin with
|
||||
var err error
|
||||
ff, err = s.Repository.File.FindByPath(ctx, f.Path, true)
|
||||
ff, err := s.Repository.File.FindByPath(ctx, f.Path, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing file %q: %w", f.Path, err)
|
||||
}
|
||||
@@ -691,7 +346,7 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
|
||||
// case insensitive search
|
||||
// assume case sensitive if in zip
|
||||
if ff == nil && f.ZipFileID != nil {
|
||||
caseSensitive, _ := f.fs.IsPathCaseSensitive(f.Path)
|
||||
caseSensitive, _ := f.FS.IsPathCaseSensitive(f.Path)
|
||||
|
||||
if !caseSensitive {
|
||||
ff, err = s.Repository.File.FindByPath(ctx, f.Path, false)
|
||||
@@ -703,35 +358,23 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
|
||||
|
||||
if ff == nil {
|
||||
// returns a file only if it is actually new
|
||||
ff, err = s.onNewFile(ctx, f)
|
||||
r, err = s.onNewFile(ctx, f)
|
||||
return err
|
||||
}
|
||||
|
||||
ff, err = s.onExistingFile(ctx, f, ff)
|
||||
r, err = s.onExistingFile(ctx, f, ff)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ff != nil && s.isZipFile(f.info.Name()) {
|
||||
f.BaseFile = ff.Base()
|
||||
|
||||
// scan zip files with a different context that is not cancellable
|
||||
// cancelling while scanning zip file contents results in the scan
|
||||
// contents being partially completed
|
||||
zipCtx := utils.ValueOnlyContext{Context: ctx}
|
||||
|
||||
if err := s.scanZipFile(zipCtx, f); err != nil {
|
||||
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) isZipFile(path string) bool {
|
||||
// IsZipFile determines if the provided path is a zip file based on its extension.
|
||||
func (s *Scanner) IsZipFile(path string) bool {
|
||||
fExt := filepath.Ext(path)
|
||||
for _, ext := range s.options.ZipFileExtensions {
|
||||
for _, ext := range s.ZipFileExtensions {
|
||||
if strings.EqualFold(fExt, "."+ext) {
|
||||
return true
|
||||
}
|
||||
@@ -740,7 +383,7 @@ func (s *scanJob) isZipFile(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error) {
|
||||
func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) {
|
||||
now := time.Now()
|
||||
|
||||
baseFile := f.BaseFile
|
||||
@@ -756,28 +399,20 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error
|
||||
}
|
||||
|
||||
if parentFolderID == nil {
|
||||
// if parent folder doesn't exist, assume it's not yet created
|
||||
// add this file to the queue to be created later
|
||||
if s.retrying {
|
||||
// if we're retrying and the folder still doesn't exist, then it's a problem
|
||||
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
|
||||
}
|
||||
|
||||
s.retryList = append(s.retryList, f)
|
||||
return nil, nil
|
||||
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
|
||||
}
|
||||
|
||||
baseFile.ParentFolderID = *parentFolderID
|
||||
|
||||
const useExisting = false
|
||||
fp, err := s.calculateFingerprints(f.fs, baseFile, path, useExisting)
|
||||
fp, err := s.calculateFingerprints(f.FS, baseFile, path, useExisting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseFile.SetFingerprints(fp)
|
||||
|
||||
file, err := s.fireDecorators(ctx, f.fs, baseFile)
|
||||
file, err := s.fireDecorators(ctx, f.FS, baseFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -790,14 +425,17 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error
|
||||
}
|
||||
|
||||
if renamed != nil {
|
||||
return &ScanFileResult{
|
||||
File: renamed,
|
||||
Renamed: true,
|
||||
}, nil
|
||||
// handle rename should have already handled the contents of the zip file
|
||||
// so shouldn't need to scan it again
|
||||
// return nil so it doesn't
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// if not renamed, queue file for creation
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Create(ctx, file); err != nil {
|
||||
return fmt.Errorf("creating file %q: %w", path, err)
|
||||
}
|
||||
@@ -811,10 +449,13 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
return &ScanFileResult{
|
||||
File: file,
|
||||
New: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) {
|
||||
func (s *Scanner) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) {
|
||||
for _, h := range s.FileDecorators {
|
||||
var err error
|
||||
f, err = h.Decorate(ctx, fs, f)
|
||||
@@ -826,8 +467,8 @@ func (s *scanJob) fireDecorators(ctx context.Context, fs models.FS, f models.Fil
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error {
|
||||
for _, h := range s.handlers {
|
||||
func (s *Scanner) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error {
|
||||
for _, h := range s.FileHandlers {
|
||||
if err := h.Handle(ctx, f, oldFile); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -836,7 +477,7 @@ func (s *scanJob) fireHandlers(ctx context.Context, f models.File, oldFile model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scanJob) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) {
|
||||
func (s *Scanner) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) {
|
||||
// only log if we're (re)calculating fingerprints
|
||||
if !useExisting {
|
||||
logger.Infof("Calculating fingerprints for %s ...", path)
|
||||
@@ -873,7 +514,7 @@ func appendFileUnique(v []models.File, toAdd []models.File) []models.File {
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) {
|
||||
func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {
|
||||
if f.ZipFile == nil {
|
||||
return s.FS, nil
|
||||
}
|
||||
@@ -884,10 +525,11 @@ func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) {
|
||||
}
|
||||
|
||||
zipPath := f.ZipFile.Base().Path
|
||||
return fs.OpenZip(zipPath, f.Size)
|
||||
zipSize := f.ZipFile.Base().Size
|
||||
return fs.OpenZip(zipPath, zipSize)
|
||||
}
|
||||
|
||||
func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
|
||||
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
|
||||
var others []models.File
|
||||
|
||||
for _, tfp := range fp {
|
||||
@@ -929,7 +571,7 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
// treat as a move
|
||||
missing = append(missing, other)
|
||||
}
|
||||
case !s.acceptEntry(ctx, other.Base().Path, info):
|
||||
case !s.AcceptEntry(ctx, other.Base().Path, info):
|
||||
// #4393 - if the file is no longer in the configured library paths, treat it as a move
|
||||
logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path)
|
||||
missing = append(missing, other)
|
||||
@@ -962,12 +604,12 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
fBaseCopy.Fingerprints = updatedBase.Fingerprints
|
||||
*updatedBase = fBaseCopy
|
||||
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, updated); err != nil {
|
||||
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
|
||||
}
|
||||
|
||||
if s.isZipFile(updatedBase.Basename) {
|
||||
if s.IsZipFile(updatedBase.Basename) {
|
||||
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
|
||||
}
|
||||
@@ -985,9 +627,9 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) isHandlerRequired(ctx context.Context, f models.File) bool {
|
||||
accept := len(s.options.HandlerRequiredFilters) == 0
|
||||
for _, filter := range s.options.HandlerRequiredFilters {
|
||||
func (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool {
|
||||
accept := len(s.HandlerRequiredFilters) == 0
|
||||
for _, filter := range s.HandlerRequiredFilters {
|
||||
// accept if any filter accepts the file
|
||||
if filter.Accept(ctx, f) {
|
||||
accept = true
|
||||
@@ -1006,9 +648,9 @@ func (s *scanJob) isHandlerRequired(ctx context.Context, f models.File) bool {
|
||||
// - file size
|
||||
// - image format, width or height
|
||||
// - video codec, audio codec, format, width, height, framerate or bitrate
|
||||
func (s *scanJob) isMissingMetadata(ctx context.Context, f scanFile, existing models.File) bool {
|
||||
func (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) bool {
|
||||
for _, h := range s.FileDecorators {
|
||||
if h.IsMissingMetadata(ctx, f.fs, existing) {
|
||||
if h.IsMissingMetadata(ctx, f.FS, existing) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1016,20 +658,20 @@ func (s *scanJob) isMissingMetadata(ctx context.Context, f scanFile, existing mo
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *scanJob) setMissingMetadata(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
func (s *Scanner) setMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) {
|
||||
path := existing.Base().Path
|
||||
logger.Infof("Updating metadata for %s", path)
|
||||
|
||||
existing.Base().Size = f.Size
|
||||
|
||||
var err error
|
||||
existing, err = s.fireDecorators(ctx, f.fs, existing)
|
||||
existing, err = s.fireDecorators(ctx, f.FS, existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// queue file for update
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("updating file %q: %w", path, err)
|
||||
}
|
||||
@@ -1042,9 +684,9 @@ func (s *scanJob) setMissingMetadata(ctx context.Context, f scanFile, existing m
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
func (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) {
|
||||
const useExisting = true
|
||||
fp, err := s.calculateFingerprints(f.fs, existing.Base(), f.Path, useExisting)
|
||||
fp, err := s.calculateFingerprints(f.FS, existing.Base(), f.Path, useExisting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1052,7 +694,7 @@ func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existi
|
||||
if fp.ContentsChanged(existing.Base().Fingerprints) {
|
||||
existing.SetFingerprints(fp)
|
||||
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("updating file %q: %w", f.Path, err)
|
||||
}
|
||||
@@ -1067,14 +709,14 @@ func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existi
|
||||
}
|
||||
|
||||
// returns a file only if it was updated
|
||||
func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) {
|
||||
base := existing.Base()
|
||||
path := base.Path
|
||||
|
||||
fileModTime := f.ModTime
|
||||
// #6326 - also force a rescan if the basename changed
|
||||
updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename
|
||||
forceRescan := s.options.Rescan
|
||||
forceRescan := s.Rescan
|
||||
|
||||
if !updated && !forceRescan {
|
||||
return s.onUnchangedFile(ctx, f, existing)
|
||||
@@ -1096,7 +738,7 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
|
||||
|
||||
// calculate and update fingerprints for the file
|
||||
const useExisting = false
|
||||
fp, err := s.calculateFingerprints(f.fs, base, path, useExisting)
|
||||
fp, err := s.calculateFingerprints(f.FS, base, path, useExisting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1104,13 +746,13 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
|
||||
s.removeOutdatedFingerprints(existing, fp)
|
||||
existing.SetFingerprints(fp)
|
||||
|
||||
existing, err = s.fireDecorators(ctx, f.fs, existing)
|
||||
existing, err = s.fireDecorators(ctx, f.FS, existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// queue file for update
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("updating file %q: %w", path, err)
|
||||
}
|
||||
@@ -1123,11 +765,13 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
Updated: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) {
|
||||
func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) {
|
||||
// HACK - if no MD5 fingerprint was returned, and the oshash is changed
|
||||
// then remove the MD5 fingerprint
|
||||
oshash := fp.For(models.FingerprintTypeOshash)
|
||||
@@ -1155,7 +799,7 @@ func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fin
|
||||
}
|
||||
|
||||
// returns a file only if it was updated
|
||||
func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing models.File) (models.File, error) {
|
||||
func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) {
|
||||
var err error
|
||||
|
||||
isMissingMetdata := s.isMissingMetadata(ctx, f, existing)
|
||||
@@ -1174,7 +818,7 @@ func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing mode
|
||||
}
|
||||
|
||||
handlerRequired := false
|
||||
if err := s.withDB(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
// check if the handler needs to be run
|
||||
handlerRequired = s.isHandlerRequired(ctx, existing)
|
||||
return nil
|
||||
@@ -1184,15 +828,20 @@ func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing mode
|
||||
|
||||
if !handlerRequired {
|
||||
// if this file is a zip file, then we need to rescan the contents
|
||||
// as well. We do this by returning the file, instead of nil.
|
||||
// as well. We do this by indicating that the file is updated.
|
||||
if isMissingMetdata {
|
||||
return existing, nil
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
Updated: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.fireHandlers(ctx, existing, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1203,6 +852,9 @@ func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing mode
|
||||
}
|
||||
|
||||
// if this file is a zip file, then we need to rescan the contents
|
||||
// as well. We do this by returning the file, instead of nil.
|
||||
return existing, nil
|
||||
// as well. We do this by indicating that the file is updated.
|
||||
return &ScanFileResult{
|
||||
File: existing,
|
||||
Updated: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ func walkSym(f models.FS, filename string, linkDirname string, walkFn fs.WalkDir
|
||||
return fsWalk(f, filename, symWalkFunc)
|
||||
}
|
||||
|
||||
// symWalk extends filepath.Walk to also follow symlinks
|
||||
func symWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error {
|
||||
// SymWalk extends filepath.Walk to also follow symlinks
|
||||
func SymWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error {
|
||||
return walkSym(fs, path, path, walkFn)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errNotReaderAt = errors.New("not a ReaderAt")
|
||||
ErrNotReaderAt = errors.New("invalid reader: does not implement io.ReaderAt")
|
||||
errZipFSOpenZip = errors.New("cannot open zip file inside zip file")
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func newZipFS(fs models.FS, path string, size int64) (*zipFS, error) {
|
||||
asReaderAt, _ := reader.(io.ReaderAt)
|
||||
if asReaderAt == nil {
|
||||
reader.Close()
|
||||
return nil, errNotReaderAt
|
||||
return nil, ErrNotReaderAt
|
||||
}
|
||||
|
||||
zipReader, err := zip.NewReader(asReaderAt, size)
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {
|
||||
var imgsDestroyed []*models.Image
|
||||
|
||||
// chapter deletion is done via delete cascade, so we don't need to do anything here
|
||||
|
||||
// if this is a zip-based gallery, delete the images as well first
|
||||
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile)
|
||||
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter,
|
||||
return qb.Destroy(ctx, galleryChapter.ID)
|
||||
}
|
||||
|
||||
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
|
||||
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {
|
||||
if err := i.LoadFiles(ctx, s.Repository); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,6 +81,12 @@ func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, f
|
||||
if err := destroyer.DestroyZip(ctx, f, fileDeleter.Deleter, deleteFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if destroyFileEntry {
|
||||
// destroy file DB entry without deleting filesystem file
|
||||
const deleteFileFromFS = false
|
||||
if err := destroyer.DestroyZip(ctx, f, nil, deleteFileFromFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ func (i *Importer) populateStudio(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
|
||||
newStudio := models.NewStudio()
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio.Name = name
|
||||
|
||||
err := i.StudioWriter.Create(ctx, &newStudio)
|
||||
@@ -249,7 +249,9 @@ func (i *Importer) createTags(ctx context.Context, names []string) ([]*models.Ta
|
||||
newTag := models.NewTag()
|
||||
newTag.Name = name
|
||||
|
||||
err := i.TagWriter.Create(ctx, &newTag)
|
||||
err := i.TagWriter.Create(ctx, &models.CreateTagInput{
|
||||
Tag: &newTag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -115,9 +115,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = existingStudioID
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.Studio.ID = existingStudioID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -147,7 +147,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error"))
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
@@ -289,9 +289,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.Tag)
|
||||
t.ID = existingTagID
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.CreateTagInput)
|
||||
t.Tag.ID = existingTagID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -323,7 +323,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error"))
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -16,7 +16,7 @@ type ImageFinder interface {
|
||||
}
|
||||
|
||||
type ImageService interface {
|
||||
Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
|
||||
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
|
||||
DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,9 @@ func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []
|
||||
newTag := models.NewTag()
|
||||
newTag.Name = name
|
||||
|
||||
err := tagWriter.Create(ctx, &newTag)
|
||||
err := tagWriter.Create(ctx, &models.CreateTagInput{
|
||||
Tag: &newTag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -203,7 +205,7 @@ func (i *Importer) populateStudio(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
|
||||
newStudio := models.NewStudio()
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio.Name = name
|
||||
|
||||
err := i.StudioWriter.Create(ctx, &newStudio)
|
||||
|
||||
@@ -121,9 +121,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = existingStudioID
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.Studio.ID = existingStudioID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -156,7 +156,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error"))
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
@@ -212,9 +212,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.Tag)
|
||||
t.ID = existingTagID
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.CreateTagInput)
|
||||
t.Tag.ID = existingTagID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -247,7 +247,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error"))
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
84
pkg/hash/imagephash/phash.go
Normal file
84
pkg/hash/imagephash/phash.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package imagephash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/corona10/goimagehash"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg/transcoder"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// Generate computes a perceptual hash for an image file.
|
||||
func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, error) {
|
||||
img, err := loadImage(encoder, imageFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading image: %w", err)
|
||||
}
|
||||
|
||||
hash, err := goimagehash.PerceptionHash(img)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing phash from image: %w", err)
|
||||
}
|
||||
|
||||
hashValue := hash.GetHash()
|
||||
return &hashValue, nil
|
||||
}
|
||||
|
||||
// loadImage loads an image from disk and decodes it.
|
||||
// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder.
|
||||
func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) {
|
||||
ext := strings.ToLower(filepath.Ext(imageFile.Path))
|
||||
if ext == ".avif" {
|
||||
// AVIF in zip files is not supported - ffmpeg cannot read files inside zips
|
||||
if imageFile.Base().ZipFileID != nil {
|
||||
return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation")
|
||||
}
|
||||
return loadImageFFmpeg(encoder, imageFile.Path)
|
||||
}
|
||||
|
||||
reader, err := imageFile.Open(&file.OsFS{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding image: %w", err)
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// loadImageFFmpeg uses ffmpeg to convert an image to BMP and then decodes it.
|
||||
func loadImageFFmpeg(encoder *ffmpeg.FFMpeg, path string) (image.Image, error) {
|
||||
options := transcoder.ScreenshotOptions{
|
||||
OutputPath: "-",
|
||||
OutputType: transcoder.ScreenshotOutputTypeBMP,
|
||||
}
|
||||
|
||||
args := transcoder.ScreenshotTime(path, 0, options)
|
||||
data, err := encoder.GenerateOutput(context.Background(), args, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting image with ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding ffmpeg output: %w", err)
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
@@ -37,8 +37,8 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
|
||||
}
|
||||
|
||||
// Destroy destroys an image, optionally marking the file and generated files for deletion.
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
|
||||
return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile)
|
||||
func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
|
||||
return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
}
|
||||
|
||||
// DestroyZipImages destroys all images in zip, optionally marking the files and generated files for deletion.
|
||||
@@ -75,7 +75,8 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil
|
||||
}
|
||||
|
||||
const deleteFileInZip = false
|
||||
if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip); err != nil {
|
||||
const destroyFileEntry = false
|
||||
if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip, destroyFileEntry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -135,7 +136,8 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil {
|
||||
const destroyFileEntry = false
|
||||
if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -146,11 +148,15 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde
|
||||
}
|
||||
|
||||
// Destroy destroys an image, optionally marking the file and generated files for deletion.
|
||||
func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
|
||||
func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
|
||||
if deleteFile {
|
||||
if err := s.deleteFiles(ctx, i, fileDeleter); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if destroyFileEntry {
|
||||
if err := s.destroyFileEntries(ctx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if deleteGenerated {
|
||||
@@ -192,3 +198,35 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// destroyFileEntries destroys file entries from the database without deleting
|
||||
// the files from the filesystem
|
||||
func (s *Service) destroyFileEntries(ctx context.Context, i *models.Image) error {
|
||||
if err := i.LoadFiles(ctx, s.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range i.Files.List() {
|
||||
// only destroy file entries where there is no other associated image
|
||||
otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(otherImages) > 1 {
|
||||
// other image associated, don't remove
|
||||
continue
|
||||
}
|
||||
|
||||
// don't destroy files in zip archives
|
||||
if f.Base().ZipFileID == nil {
|
||||
const deleteFile = false
|
||||
logger.Info("Destroying image file entry: ", f.Base().Path)
|
||||
if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func (i *Importer) populateStudio(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
|
||||
newStudio := models.NewStudio()
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio.Name = name
|
||||
|
||||
err := i.StudioWriter.Create(ctx, &newStudio)
|
||||
@@ -407,7 +407,9 @@ func createTags(ctx context.Context, tagWriter models.TagCreator, names []string
|
||||
newTag := models.NewTag()
|
||||
newTag.Name = name
|
||||
|
||||
err := tagWriter.Create(ctx, &newTag)
|
||||
err := tagWriter.Create(ctx, &models.CreateTagInput{
|
||||
Tag: &newTag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -77,9 +77,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.Studio)
|
||||
s.ID = existingStudioID
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
|
||||
s := args.Get(1).(*models.CreateStudioInput)
|
||||
s.Studio.ID = existingStudioID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -109,7 +109,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error"))
|
||||
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
@@ -251,9 +251,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.Tag)
|
||||
t.ID = existingTagID
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.CreateTagInput)
|
||||
t.Tag.ID = existingTagID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
@@ -285,7 +285,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error"))
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
const maxGraveyardSize = 10
|
||||
@@ -179,7 +178,8 @@ func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) {
|
||||
j.StartTime = &t
|
||||
j.Status = StatusRunning
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(utils.ValueOnlyContext{Context: ctx})
|
||||
// create a cancellable context for the job that is not canceled by the outer context
|
||||
ctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx))
|
||||
j.cancelFunc = cancelFunc
|
||||
|
||||
done = make(chan struct{})
|
||||
|
||||
@@ -17,3 +17,7 @@ type CustomFieldsReader interface {
|
||||
GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error)
|
||||
GetCustomFieldsBulk(ctx context.Context, ids []int) ([]CustomFieldMap, error)
|
||||
}
|
||||
|
||||
type CustomFieldsWriter interface {
|
||||
SetCustomFields(ctx context.Context, id int, fields CustomFieldsInput) error
|
||||
}
|
||||
|
||||
@@ -1,31 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type DatePrecision int
|
||||
|
||||
const (
|
||||
// default precision is day
|
||||
DatePrecisionDay DatePrecision = iota
|
||||
DatePrecisionMonth
|
||||
DatePrecisionYear
|
||||
)
|
||||
|
||||
// Date wraps a time.Time with a format of "YYYY-MM-DD"
|
||||
type Date struct {
|
||||
time.Time
|
||||
Precision DatePrecision
|
||||
}
|
||||
|
||||
const dateFormat = "2006-01-02"
|
||||
var dateFormatPrecision = []string{
|
||||
"2006-01-02",
|
||||
"2006-01",
|
||||
"2006",
|
||||
}
|
||||
|
||||
func (d Date) String() string {
|
||||
return d.Format(dateFormat)
|
||||
return d.Format(dateFormatPrecision[d.Precision])
|
||||
}
|
||||
|
||||
func (d Date) After(o Date) bool {
|
||||
return d.Time.After(o.Time)
|
||||
}
|
||||
|
||||
// ParseDate uses utils.ParseDateStringAsTime to parse a string into a date.
|
||||
// ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime.
|
||||
// If that fails, it attempts to parse the string with decreasing precision (month, then year).
|
||||
// It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail.
|
||||
func ParseDate(s string) (Date, error) {
|
||||
var errs []error
|
||||
|
||||
// default parse to day precision
|
||||
ret, err := utils.ParseDateStringAsTime(s)
|
||||
if err != nil {
|
||||
return Date{}, err
|
||||
if err == nil {
|
||||
return Date{Time: ret, Precision: DatePrecisionDay}, nil
|
||||
}
|
||||
return Date{Time: ret}, nil
|
||||
|
||||
errs = append(errs, err)
|
||||
|
||||
// try month and year precision
|
||||
for i, format := range dateFormatPrecision[1:] {
|
||||
ret, err := time.Parse(format, s)
|
||||
if err == nil {
|
||||
return Date{Time: ret, Precision: DatePrecision(i + 1)}, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
|
||||
}
|
||||
|
||||
50
pkg/models/date_test.go
Normal file
50
pkg/models/date_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDateStringAsTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
output Date
|
||||
expectError bool
|
||||
}{
|
||||
// Full date formats (existing support)
|
||||
{"RFC3339", "2014-01-02T15:04:05Z", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
|
||||
{"Date only", "2014-01-02", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false},
|
||||
{"Date with time", "2014-01-02 15:04:05", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
|
||||
|
||||
// Partial date formats (new support)
|
||||
{"Year-Month", "2006-08", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false},
|
||||
{"Year only", "2014", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false},
|
||||
|
||||
// Invalid formats
|
||||
{"Invalid format", "not-a-date", Date{}, true},
|
||||
{"Empty string", "", Date{}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseDate(tt.input)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for input %q, but got none", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for input %q: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision {
|
||||
t.Errorf("For input %q, expected output %+v, got %+v", tt.input, tt.output, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type FileFilterType struct {
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Duplicated *FileDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"`
|
||||
ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"`
|
||||
|
||||
@@ -95,6 +95,7 @@ type GalleryDestroyInput struct {
|
||||
// If true, then the zip file will be deleted if the gallery is zip-file-based.
|
||||
// If gallery is folder-based, then any files not associated with other
|
||||
// galleries will be deleted, along with the folder, if it is not empty.
|
||||
DeleteFile *bool `json:"delete_file"`
|
||||
DeleteGenerated *bool `json:"delete_generated"`
|
||||
DeleteFile *bool `json:"delete_file"`
|
||||
DeleteGenerated *bool `json:"delete_generated"`
|
||||
DestroyFileEntry *bool `json:"destroy_file_entry"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ type ImageFilterType struct {
|
||||
Photographer *StringCriterionInput `json:"photographer"`
|
||||
// Filter by file checksum
|
||||
Checksum *StringCriterionInput `json:"checksum"`
|
||||
// Filter by phash distance
|
||||
PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"`
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
// Filter by file count
|
||||
@@ -88,15 +90,17 @@ type ImageUpdateInput struct {
|
||||
}
|
||||
|
||||
type ImageDestroyInput struct {
|
||||
ID string `json:"id"`
|
||||
DeleteFile *bool `json:"delete_file"`
|
||||
DeleteGenerated *bool `json:"delete_generated"`
|
||||
ID string `json:"id"`
|
||||
DeleteFile *bool `json:"delete_file"`
|
||||
DeleteGenerated *bool `json:"delete_generated"`
|
||||
DestroyFileEntry *bool `json:"destroy_file_entry"`
|
||||
}
|
||||
|
||||
type ImagesDestroyInput struct {
|
||||
Ids []string `json:"ids"`
|
||||
DeleteFile *bool `json:"delete_file"`
|
||||
DeleteGenerated *bool `json:"delete_generated"`
|
||||
Ids []string `json:"ids"`
|
||||
DeleteFile *bool `json:"delete_file"`
|
||||
DeleteGenerated *bool `json:"delete_generated"`
|
||||
DestroyFileEntry *bool `json:"destroy_file_entry"`
|
||||
}
|
||||
|
||||
type ImageQueryOptions struct {
|
||||
|
||||
@@ -25,6 +25,8 @@ type Studio struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
|
||||
|
||||
CustomFields map[string]interface{} `json:"custom_fields,omitempty"`
|
||||
|
||||
// deprecated - for import only
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
@@ -11,17 +11,18 @@ import (
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
SortName string `json:"sort_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Parents []string `json:"parents,omitempty"`
|
||||
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
|
||||
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
SortName string `json:"sort_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Parents []string `json:"parents,omitempty"`
|
||||
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
|
||||
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
CustomFields map[string]interface{} `json:"custom_fields,omitempty"`
|
||||
}
|
||||
|
||||
func (s Tag) Filename() string {
|
||||
|
||||
@@ -473,6 +473,20 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Merge provides a mock function with given fields: ctx, source, destination
|
||||
func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
|
||||
ret := _m.Called(ctx, source, destination)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {
|
||||
r0 = rf(ctx, source, destination)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, performerFilter, findFilter
|
||||
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
|
||||
ret := _m.Called(ctx, performerFilter, findFilter)
|
||||
|
||||
@@ -80,11 +80,11 @@ func (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int,
|
||||
}
|
||||
|
||||
// Create provides a mock function with given fields: ctx, newStudio
|
||||
func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error {
|
||||
func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.CreateStudioInput) error {
|
||||
ret := _m.Called(ctx, newStudio)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.Studio) error); ok {
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.CreateStudioInput) error); ok {
|
||||
r0 = rf(ctx, newStudio)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
@@ -291,6 +291,52 @@ func (_m *StudioReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetCustomFields provides a mock function with given fields: ctx, id
|
||||
func (_m *StudioReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 map[string]interface{}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids
|
||||
func (_m *StudioReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {
|
||||
ret := _m.Called(ctx, ids)
|
||||
|
||||
var r0 []models.CustomFieldMap
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {
|
||||
r0 = rf(ctx, ids)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]models.CustomFieldMap)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
|
||||
r1 = rf(ctx, ids)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetImage provides a mock function with given fields: ctx, studioID
|
||||
func (_m *StudioReaderWriter) GetImage(ctx context.Context, studioID int) ([]byte, error) {
|
||||
ret := _m.Called(ctx, studioID)
|
||||
@@ -479,11 +525,11 @@ func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []strin
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, updatedStudio
|
||||
func (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models.Studio) error {
|
||||
func (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models.UpdateStudioInput) error {
|
||||
ret := _m.Called(ctx, updatedStudio)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.Studio) error); ok {
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateStudioInput) error); ok {
|
||||
r0 = rf(ctx, updatedStudio)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
|
||||
@@ -101,11 +101,11 @@ func (_m *TagReaderWriter) CountByParentTagID(ctx context.Context, parentID int)
|
||||
}
|
||||
|
||||
// Create provides a mock function with given fields: ctx, newTag
|
||||
func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.Tag) error {
|
||||
func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.CreateTagInput) error {
|
||||
ret := _m.Called(ctx, newTag)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.Tag) error); ok {
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.CreateTagInput) error); ok {
|
||||
r0 = rf(ctx, newTag)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
@@ -542,6 +542,52 @@ func (_m *TagReaderWriter) GetChildIDs(ctx context.Context, relatedID int) ([]in
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetCustomFields provides a mock function with given fields: ctx, id
|
||||
func (_m *TagReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 map[string]interface{}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids
|
||||
func (_m *TagReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {
|
||||
ret := _m.Called(ctx, ids)
|
||||
|
||||
var r0 []models.CustomFieldMap
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {
|
||||
r0 = rf(ctx, ids)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]models.CustomFieldMap)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
|
||||
r1 = rf(ctx, ids)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetImage provides a mock function with given fields: ctx, tagID
|
||||
func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) {
|
||||
ret := _m.Called(ctx, tagID)
|
||||
@@ -699,12 +745,26 @@ func (_m *TagReaderWriter) QueryForAutoTag(ctx context.Context, words []string)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SetCustomFields provides a mock function with given fields: ctx, id, fields
|
||||
func (_m *TagReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error {
|
||||
ret := _m.Called(ctx, id, fields)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok {
|
||||
r0 = rf(ctx, id, fields)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, updatedTag
|
||||
func (_m *TagReaderWriter) Update(ctx context.Context, updatedTag *models.Tag) error {
|
||||
func (_m *TagReaderWriter) Update(ctx context.Context, updatedTag *models.UpdateTagInput) error {
|
||||
ret := _m.Called(ctx, updatedTag)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.Tag) error); ok {
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateTagInput) error); ok {
|
||||
r0 = rf(ctx, updatedTag)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
|
||||
@@ -27,9 +27,9 @@ type ScrapedStudio struct {
|
||||
|
||||
func (ScrapedStudio) IsScrapedContent() {}
|
||||
|
||||
func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Studio {
|
||||
func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *CreateStudioInput {
|
||||
// Populate a new studio from the input
|
||||
ret := NewStudio()
|
||||
ret := NewCreateStudioInput()
|
||||
ret.Name = strings.TrimSpace(s.Name)
|
||||
|
||||
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
|
||||
|
||||
@@ -113,7 +113,7 @@ func Test_scrapedToStudioInput(t *testing.T) {
|
||||
got.StashIDs.List()[stid].UpdatedAt = time.Time{}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
assert.Equal(t, tt.want, got.Studio)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ type Studio struct {
|
||||
StashIDs RelatedStashIDs `json:"stash_ids"`
|
||||
}
|
||||
|
||||
type CreateStudioInput struct {
|
||||
*Studio
|
||||
|
||||
CustomFields map[string]interface{} `json:"custom_fields"`
|
||||
}
|
||||
|
||||
type UpdateStudioInput struct {
|
||||
*Studio
|
||||
|
||||
CustomFields CustomFieldsInput `json:"custom_fields"`
|
||||
}
|
||||
|
||||
func NewStudio() Studio {
|
||||
currentTime := time.Now()
|
||||
return Studio{
|
||||
@@ -31,6 +43,13 @@ func NewStudio() Studio {
|
||||
}
|
||||
}
|
||||
|
||||
func NewCreateStudioInput() CreateStudioInput {
|
||||
s := NewStudio()
|
||||
return CreateStudioInput{
|
||||
Studio: &s,
|
||||
}
|
||||
}
|
||||
|
||||
// StudioPartial represents part of a Studio object. It is used to update the database entry.
|
||||
type StudioPartial struct {
|
||||
ID int
|
||||
@@ -48,6 +67,8 @@ type StudioPartial struct {
|
||||
URLs *UpdateStrings
|
||||
TagIDs *UpdateIDs
|
||||
StashIDs *UpdateStashIDs
|
||||
|
||||
CustomFields CustomFieldsInput
|
||||
}
|
||||
|
||||
func NewStudioPartial() StudioPartial {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user