mirror of
https://github.com/stashapp/stash.git
synced 2025-12-07 17:02:38 +01:00
* Add funscript route to scenes Adds a /scene/:id/funscript route which serves a funscript file, if present. Current convention is that these are files stored with the same path, but with the extension ".funscript". * Look for funscript during scan This is stored in the Scene record and used to drive UI changes for funscript support. Currently, that's limited to a funscript link in the Scene's file info. * Add filtering and sorting for interactive * Add Handy connection key to interface config * Add Handy client and placeholder component. Uses defucilis/thehandy, but not thehandy-react as I had difficulty integrating the context with the existing components. Instead, the expensive calculation for the server time offset is put in localStorage for reuse. A debounce was added when scrubbing the video, as otherwise it spammed the Handy API with updates to the current offset.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stashapp/stash/pkg/ffmpeg"
|
|
"github.com/stashapp/stash/pkg/logger"
|
|
"github.com/stashapp/stash/pkg/manager"
|
|
"github.com/stashapp/stash/pkg/manager/config"
|
|
"github.com/stashapp/stash/pkg/models"
|
|
"github.com/stashapp/stash/pkg/utils"
|
|
)
|
|
|
|
type sceneRoutes struct {
|
|
txnManager models.TransactionManager
|
|
sceneServer manager.SceneServer
|
|
}
|
|
|
|
func (rs sceneRoutes) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
|
|
r.Route("/{sceneId}", func(r chi.Router) {
|
|
r.Use(SceneCtx)
|
|
|
|
// streaming endpoints
|
|
r.Get("/stream", rs.StreamDirect)
|
|
r.Get("/stream.mkv", rs.StreamMKV)
|
|
r.Get("/stream.webm", rs.StreamWebM)
|
|
r.Get("/stream.m3u8", rs.StreamHLS)
|
|
r.Get("/stream.ts", rs.StreamTS)
|
|
r.Get("/stream.mp4", rs.StreamMp4)
|
|
|
|
r.Get("/screenshot", rs.Screenshot)
|
|
r.Get("/preview", rs.Preview)
|
|
r.Get("/webp", rs.Webp)
|
|
r.Get("/vtt/chapter", rs.ChapterVtt)
|
|
r.Get("/funscript", rs.Funscript)
|
|
|
|
r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream)
|
|
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview)
|
|
})
|
|
r.With(SceneCtx).Get("/{sceneId}_thumbs.vtt", rs.VttThumbs)
|
|
r.With(SceneCtx).Get("/{sceneId}_sprite.jpg", rs.VttSprite)
|
|
|
|
return r
|
|
}
|
|
|
|
// region Handlers
|
|
|
|
func getSceneFileContainer(scene *models.Scene) ffmpeg.Container {
|
|
var container ffmpeg.Container
|
|
if scene.Format.Valid {
|
|
container = ffmpeg.Container(scene.Format.String)
|
|
} else { // container isn't in the DB
|
|
// shouldn't happen, fallback to ffprobe
|
|
tmpVideoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path, false)
|
|
if err != nil {
|
|
logger.Errorf("[transcode] error reading video file: %s", err.Error())
|
|
return ffmpeg.Container("")
|
|
}
|
|
|
|
container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
|
|
}
|
|
|
|
return container
|
|
}
|
|
|
|
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
|
|
ss := manager.SceneServer{
|
|
TXNManager: rs.txnManager,
|
|
}
|
|
ss.StreamSceneDirect(scene, w, r)
|
|
}
|
|
|
|
func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
|
|
// only allow mkv streaming if the scene container is an mkv already
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
|
|
container := getSceneFileContainer(scene)
|
|
if container != ffmpeg.Matroska {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("not an mkv file"))
|
|
return
|
|
}
|
|
|
|
rs.streamTranscode(w, r, ffmpeg.CodecMKVAudio)
|
|
}
|
|
|
|
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {
|
|
rs.streamTranscode(w, r, ffmpeg.CodecVP9)
|
|
}
|
|
|
|
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
|
|
rs.streamTranscode(w, r, ffmpeg.CodecH264)
|
|
}
|
|
|
|
func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
|
|
videoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path, false)
|
|
if err != nil {
|
|
logger.Errorf("[stream] error reading video file: %s", err.Error())
|
|
return
|
|
}
|
|
|
|
logger.Debug("Returning HLS playlist")
|
|
|
|
// getting the playlist manifest only
|
|
w.Header().Set("Content-Type", ffmpeg.MimeHLS)
|
|
var str strings.Builder
|
|
|
|
ffmpeg.WriteHLSPlaylist(*videoFile, r.URL.String(), &str)
|
|
|
|
requestByteRange := utils.CreateByteRange(r.Header.Get("Range"))
|
|
if requestByteRange.RawString != "" {
|
|
logger.Debugf("Requested range: %s", requestByteRange.RawString)
|
|
}
|
|
|
|
ret := requestByteRange.Apply([]byte(str.String()))
|
|
rangeStr := requestByteRange.ToHeaderValue(int64(str.Len()))
|
|
w.Header().Set("Content-Range", rangeStr)
|
|
|
|
w.Write(ret)
|
|
}
|
|
|
|
func (rs sceneRoutes) StreamTS(w http.ResponseWriter, r *http.Request) {
|
|
rs.streamTranscode(w, r, ffmpeg.CodecHLS)
|
|
}
|
|
|
|
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, videoCodec ffmpeg.Codec) {
|
|
logger.Debugf("Streaming as %s", videoCodec.MimeType)
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
|
|
// needs to be transcoded
|
|
|
|
videoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path, false)
|
|
if err != nil {
|
|
logger.Errorf("[stream] error reading video file: %s", err.Error())
|
|
return
|
|
}
|
|
|
|
// start stream based on query param, if provided
|
|
r.ParseForm()
|
|
startTime := r.Form.Get("start")
|
|
requestedSize := r.Form.Get("resolution")
|
|
|
|
var stream *ffmpeg.Stream
|
|
|
|
audioCodec := ffmpeg.MissingUnsupported
|
|
if scene.AudioCodec.Valid {
|
|
audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
|
|
}
|
|
|
|
options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec)
|
|
options.StartTime = startTime
|
|
options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize()
|
|
if requestedSize != "" {
|
|
options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize)
|
|
}
|
|
|
|
encoder := ffmpeg.NewEncoder(manager.GetInstance().FFMPEGPath)
|
|
stream, err = encoder.GetTranscodeStream(options)
|
|
|
|
if err != nil {
|
|
logger.Errorf("[stream] error transcoding video file: %s", err.Error())
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(err.Error()))
|
|
return
|
|
}
|
|
|
|
stream.Serve(w, r)
|
|
}
|
|
|
|
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
|
|
ss := manager.SceneServer{
|
|
TXNManager: rs.txnManager,
|
|
}
|
|
ss.ServeScreenshot(scene, w, r)
|
|
}
|
|
|
|
func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
|
utils.ServeFileNoCache(w, r, filepath)
|
|
}
|
|
|
|
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
|
http.ServeFile(w, r, filepath)
|
|
}
|
|
|
|
func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.SceneMarker) string {
|
|
if marker.Title != "" {
|
|
return marker.Title
|
|
}
|
|
|
|
var ret string
|
|
if err := rs.txnManager.WithReadTxn(ctx, func(repo models.ReaderRepository) error {
|
|
qb := repo.Tag()
|
|
primaryTag, err := qb.Find(marker.PrimaryTagID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ret = primaryTag.Name
|
|
|
|
tags, err := qb.FindBySceneMarkerID(marker.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, t := range tags {
|
|
ret += ", " + t.Name
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
var sceneMarkers []*models.SceneMarker
|
|
if err := rs.txnManager.WithReadTxn(r.Context(), func(repo models.ReaderRepository) error {
|
|
var err error
|
|
sceneMarkers, err = repo.SceneMarker().FindBySceneID(scene.ID)
|
|
return err
|
|
}); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
vttLines := []string{"WEBVTT", ""}
|
|
for i, marker := range sceneMarkers {
|
|
vttLines = append(vttLines, strconv.Itoa(i+1))
|
|
time := utils.GetVTTTime(marker.Seconds)
|
|
vttLines = append(vttLines, time+" --> "+time)
|
|
vttLines = append(vttLines, rs.getChapterVttTitle(r.Context(), marker))
|
|
vttLines = append(vttLines, "")
|
|
}
|
|
vtt := strings.Join(vttLines, "\n")
|
|
|
|
w.Header().Set("Content-Type", "text/vtt")
|
|
_, _ = w.Write([]byte(vtt))
|
|
}
|
|
|
|
func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
funscript := utils.GetFunscriptPath(scene.Path)
|
|
utils.ServeFileNoCache(w, r, funscript)
|
|
}
|
|
|
|
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) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
|
|
var sceneMarker *models.SceneMarker
|
|
if err := rs.txnManager.WithReadTxn(r.Context(), func(repo models.ReaderRepository) error {
|
|
var err error
|
|
sceneMarker, err = repo.SceneMarker().Find(sceneMarkerID)
|
|
return err
|
|
}); err != nil {
|
|
logger.Warnf("Error when getting scene marker for stream: %s", err.Error())
|
|
http.Error(w, http.StatusText(500), 500)
|
|
return
|
|
}
|
|
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
|
|
http.ServeFile(w, r, filepath)
|
|
}
|
|
|
|
func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) {
|
|
scene := r.Context().Value(sceneKey).(*models.Scene)
|
|
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
|
|
var sceneMarker *models.SceneMarker
|
|
if err := rs.txnManager.WithReadTxn(r.Context(), func(repo models.ReaderRepository) error {
|
|
var err error
|
|
sceneMarker, err = repo.SceneMarker().Find(sceneMarkerID)
|
|
return err
|
|
}); err != nil {
|
|
logger.Warnf("Error when getting scene marker for stream: %s", err.Error())
|
|
http.Error(w, http.StatusText(500), 500)
|
|
return
|
|
}
|
|
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
|
|
|
|
// If the image doesn't exist, send the placeholder
|
|
exists, _ := utils.FileExists(filepath)
|
|
if !exists {
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(utils.PendingGenerateResource)
|
|
return
|
|
}
|
|
|
|
http.ServeFile(w, r, filepath)
|
|
}
|
|
|
|
// endregion
|
|
|
|
func SceneCtx(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sceneIdentifierQueryParam := chi.URLParam(r, "sceneId")
|
|
sceneID, _ := strconv.Atoi(sceneIdentifierQueryParam)
|
|
|
|
var scene *models.Scene
|
|
manager.GetInstance().TxnManager.WithReadTxn(r.Context(), func(repo models.ReaderRepository) error {
|
|
qb := repo.Scene()
|
|
if sceneID == 0 {
|
|
// determine checksum/os by the length of the query param
|
|
if len(sceneIdentifierQueryParam) == 32 {
|
|
scene, _ = qb.FindByChecksum(sceneIdentifierQueryParam)
|
|
} else {
|
|
scene, _ = qb.FindByOSHash(sceneIdentifierQueryParam)
|
|
}
|
|
} else {
|
|
scene, _ = qb.Find(sceneID)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if scene == nil {
|
|
http.Error(w, http.StatusText(404), 404)
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), sceneKey, scene)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|