Fix VTT thumbnails (#3513)

* Fix VTT thumbnails
* Add API key to sceneStreams query
* Add scene ID routes
This commit is contained in:
DingDongSoLong4
2023-03-10 02:54:18 +02:00
committed by GitHub
parent 0c1b02380e
commit 7a2ee7cdda
5 changed files with 72 additions and 65 deletions

View File

@@ -179,13 +179,13 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
builder.APIKey = config.GetAPIKey()
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt) screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt)
previewPath := builder.GetStreamPreviewURL() previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL().String() streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL() webpPath := builder.GetStreamPreviewImageURL()
vttPath := builder.GetSpriteVTTURL() objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
spritePath := builder.GetSpriteURL() vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
chaptersVttPath := builder.GetChaptersVTTURL() chaptersVttPath := builder.GetChaptersVTTURL()
funscriptPath := builder.GetFunscriptURL() funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL() captionBasePath := builder.GetCaptionURL()
@@ -371,9 +371,9 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
builder.APIKey = config.GetAPIKey() apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(), config.GetMaxStreamingTranscodeSize()) return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
} }
func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) { func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) {

View File

@@ -7,7 +7,6 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@@ -32,8 +31,11 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
return nil, errors.New("nil scene") return nil, errors.New("nil scene")
} }
config := manager.GetInstance().Config
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID)
apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetInstance().GetMaxStreamingTranscodeSize()) return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
} }

View File

@@ -68,7 +68,9 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Get("/screenshot", rs.Screenshot) r.Get("/screenshot", rs.Screenshot)
r.Get("/preview", rs.Preview) r.Get("/preview", rs.Preview)
r.Get("/webp", rs.Webp) r.Get("/webp", rs.Webp)
r.Get("/vtt/chapter", rs.ChapterVtt) r.Get("/vtt/chapter", rs.VttChapter)
r.Get("/vtt/thumbs", rs.VttThumbs)
r.Get("/vtt/sprite", rs.VttSprite)
r.Get("/funscript", rs.Funscript) r.Get("/funscript", rs.Funscript)
r.Get("/interactive_heatmap", rs.InteractiveHeatmap) r.Get("/interactive_heatmap", rs.InteractiveHeatmap)
r.Get("/caption", rs.CaptionLang) r.Get("/caption", rs.CaptionLang)
@@ -77,8 +79,8 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview) r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview)
r.Get("/scene_marker/{sceneMarkerId}/screenshot", rs.SceneMarkerScreenshot) r.Get("/scene_marker/{sceneMarkerId}/screenshot", rs.SceneMarkerScreenshot)
}) })
r.With(rs.SceneCtx).Get("/{sceneId}_thumbs.vtt", rs.VttThumbs) r.Get("/{sceneHash}_thumbs.vtt", rs.VttThumbs)
r.With(rs.SceneCtx).Get("/{sceneId}_sprite.jpg", rs.VttSprite) r.Get("/{sceneHash}_sprite.jpg", rs.VttSprite)
return r return r
} }
@@ -87,11 +89,9 @@ func (rs sceneRoutes) Routes() chi.Router {
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)
hash := scene.GetHash(fileNamingAlgo)
filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, hash)
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r) streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari // #2579 - hijacking and closing the connection here causes video playback to fail in Safari
@@ -229,8 +229,7 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
logger.Warnf("[transcode] error parsing query form: %v", err) logger.Warnf("[transcode] error parsing query form: %v", err)
} }
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
hash := scene.GetHash(fileNamingAlgo)
segment := chi.URLParam(r, "segment") segment := chi.URLParam(r, "segment")
resolution := r.Form.Get("resolution") resolution := r.Form.Get("resolution")
@@ -239,7 +238,7 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
StreamType: streamType, StreamType: streamType,
VideoFile: f, VideoFile: f,
Resolution: resolution, Resolution: resolution,
Hash: hash, Hash: sceneHash,
Segment: segment, Segment: segment,
} }
@@ -258,7 +257,8 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash)
serveFileNoCache(w, r, filepath) serveFileNoCache(w, r, filepath)
} }
@@ -272,7 +272,8 @@ func serveFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) {
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash)
http.ServeFile(w, r, filepath) http.ServeFile(w, r, filepath)
} }
@@ -308,7 +309,7 @@ func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.Sce
return &title, nil return &title, nil
} }
func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
var sceneMarkers []*models.SceneMarker var sceneMarkers []*models.SceneMarker
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
@@ -350,6 +351,32 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(vtt)) _, _ = w.Write([]byte(vtt))
} }
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
scene, ok := r.Context().Value(sceneKey).(*models.Scene)
var sceneHash string
if ok && scene != nil {
sceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
} else {
sceneHash = chi.URLParam(r, "sceneHash")
}
w.Header().Set("Content-Type", "text/vtt")
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash)
http.ServeFile(w, r, filepath)
}
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
scene, ok := r.Context().Value(sceneKey).(*models.Scene)
var sceneHash string
if ok && scene != nil {
sceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
} else {
sceneHash = chi.URLParam(r, "sceneHash")
}
w.Header().Set("Content-Type", "image/jpeg")
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash)
http.ServeFile(w, r, filepath)
}
func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
s := r.Context().Value(sceneKey).(*models.Scene) s := r.Context().Value(sceneKey).(*models.Scene)
funscript := video.GetFunscriptPath(s.Path) funscript := video.GetFunscriptPath(s.Path)
@@ -358,8 +385,9 @@ func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash)
http.ServeFile(w, r, filepath) http.ServeFile(w, r, filepath)
} }
@@ -423,22 +451,9 @@ func (rs sceneRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) {
rs.Caption(w, r, l, ext) rs.Caption(w, r, l, ext)
} }
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
w.Header().Set("Content-Type", "text/vtt")
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
http.ServeFile(w, r, filepath)
}
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
w.Header().Set("Content-Type", "image/jpeg")
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
http.ServeFile(w, r, filepath)
}
func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
var sceneMarker *models.SceneMarker var sceneMarker *models.SceneMarker
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
@@ -460,12 +475,13 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request)
return return
} }
filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds))
http.ServeFile(w, r, filepath) http.ServeFile(w, r, filepath)
} }
func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
var sceneMarker *models.SceneMarker var sceneMarker *models.SceneMarker
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
@@ -487,7 +503,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
return return
} }
filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(sceneHash, int(sceneMarker.Seconds))
// If the image doesn't exist, send the placeholder // If the image doesn't exist, send the placeholder
exists, _ := fsutil.FileExists(filepath) exists, _ := fsutil.FileExists(filepath)
@@ -503,6 +519,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
var sceneMarker *models.SceneMarker var sceneMarker *models.SceneMarker
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
@@ -524,7 +541,7 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque
return return
} }
filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(sceneHash, int(sceneMarker.Seconds))
// If the image doesn't exist, send the placeholder // If the image doesn't exist, send the placeholder
exists, _ := fsutil.FileExists(filepath) exists, _ := fsutil.FileExists(filepath)
@@ -542,28 +559,16 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque
func (rs sceneRoutes) SceneCtx(next http.Handler) http.Handler { func (rs sceneRoutes) SceneCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sceneIdentifierQueryParam := chi.URLParam(r, "sceneId") sceneID, err := strconv.Atoi(chi.URLParam(r, "sceneId"))
sceneID, _ := strconv.Atoi(sceneIdentifierQueryParam) if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
var scene *models.Scene var scene *models.Scene
_ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
qb := rs.sceneFinder qb := rs.sceneFinder
if sceneID == 0 { scene, _ = qb.Find(ctx, sceneID)
var scenes []*models.Scene
// determine checksum/os by the length of the query param
if len(sceneIdentifierQueryParam) == 32 {
scenes, _ = qb.FindByChecksum(ctx, sceneIdentifierQueryParam)
} else {
scenes, _ = qb.FindByOSHash(ctx, sceneIdentifierQueryParam)
}
if len(scenes) > 0 {
scene = scenes[0]
}
} else {
scene, _ = qb.Find(ctx, sceneID)
}
if scene != nil { if scene != nil {
if err := scene.LoadPrimaryFile(ctx, rs.fileFinder); err != nil { if err := scene.LoadPrimaryFile(ctx, rs.fileFinder); err != nil {

View File

@@ -10,7 +10,6 @@ import (
type SceneURLBuilder struct { type SceneURLBuilder struct {
BaseURL string BaseURL string
SceneID string SceneID string
APIKey string
} }
func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder { func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
@@ -20,16 +19,16 @@ func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
} }
} }
func (b SceneURLBuilder) GetStreamURL() *url.URL { func (b SceneURLBuilder) GetStreamURL(apiKey string) *url.URL {
u, err := url.Parse(fmt.Sprintf("%s/scene/%s/stream", b.BaseURL, b.SceneID)) u, err := url.Parse(fmt.Sprintf("%s/scene/%s/stream", b.BaseURL, b.SceneID))
if err != nil { if err != nil {
// shouldn't happen // shouldn't happen
panic(err) panic(err)
} }
if b.APIKey != "" { if apiKey != "" {
v := u.Query() v := u.Query()
v.Set("apikey", b.APIKey) v.Set("apikey", apiKey)
u.RawQuery = v.Encode() u.RawQuery = v.Encode()
} }
return u return u
@@ -43,12 +42,12 @@ func (b SceneURLBuilder) GetStreamPreviewImageURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/webp" return b.BaseURL + "/scene/" + b.SceneID + "/webp"
} }
func (b SceneURLBuilder) GetSpriteVTTURL() string { func (b SceneURLBuilder) GetSpriteVTTURL(checksum string) string {
return b.BaseURL + "/scene/" + b.SceneID + "_thumbs.vtt" return b.BaseURL + "/scene/" + checksum + "_thumbs.vtt"
} }
func (b SceneURLBuilder) GetSpriteURL() string { func (b SceneURLBuilder) GetSpriteURL(checksum string) string {
return b.BaseURL + "/scene/" + b.SceneID + "_sprite.jpg" return b.BaseURL + "/scene/" + checksum + "_sprite.jpg"
} }
func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string { func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string {

View File

@@ -17,6 +17,7 @@
* Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274)) * Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274))
### 🐛 Bug fixes ### 🐛 Bug fixes
* Fixed sprites not being displayed for scenes with numeric-only hashes. ([#3513](https://github.com/stashapp/stash/pull/3513))
* Fixed Save button being disabled when stting Tag image. ([#3509](https://github.com/stashapp/stash/pull/3509)) * Fixed Save button being disabled when stting Tag image. ([#3509](https://github.com/stashapp/stash/pull/3509))
* Fixed incorrect performer with identical name being matched when scraping from stash-box. ([#3488](https://github.com/stashapp/stash/pull/3488)) * Fixed incorrect performer with identical name being matched when scraping from stash-box. ([#3488](https://github.com/stashapp/stash/pull/3488))
* Fixed scene cover not being included when submitting file-less scenes to stash-box. ([#3465](https://github.com/stashapp/stash/pull/3465)) * Fixed scene cover not being included when submitting file-less scenes to stash-box. ([#3465](https://github.com/stashapp/stash/pull/3465))