Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions graphql/schema/types/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ input ConfigInterfaceInput {
noBrowser: Boolean
"True if we should send notifications to the desktop"
notificationsEnabled: Boolean

"True if 'Open in Ext. Player' button in scene details is shown"
showOpenExternal: Boolean
}

type ConfigDisableDropdownCreate {
Expand Down Expand Up @@ -483,6 +486,9 @@ type ConfigInterfaceResult {
funscriptOffset: Int
"Whether to use Stash Hosted Funscript"
useStashHostedFunscript: Boolean

"Show 'Open in Ext. Player' button in scene details"
showOpenExternal: Boolean
}

input ConfigDLNAInput {
Expand Down
41 changes: 41 additions & 0 deletions internal/api/routes_scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"context"
"errors"
"net/http"
"net/url"
"os"
"strconv"
"strings"

Expand Down Expand Up @@ -68,6 +70,7 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Get("/stream.mpd", rs.StreamDASH)
r.Get("/stream.mpd/{segment}_v.webm", rs.StreamDASHVideoSegment)
r.Get("/stream.mpd/{segment}_a.webm", rs.StreamDASHAudioSegment)
r.Get("/stream/org/{streamOrgFile}", rs.StreamOrgDirect)

r.Get("/screenshot", rs.Screenshot)
r.Get("/preview", rs.Preview)
Expand Down Expand Up @@ -99,6 +102,44 @@ func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
ss.StreamSceneDirect(scene, w, r)
}

func (rs sceneRoutes) StreamOrgDirect(w http.ResponseWriter, r *http.Request) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this end point and how does it differ from the /stream end point?

Copy link
Contributor Author

@philpw99 philpw99 Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeoVR and other VR player require a file name with extension. Just "/steam" as the end point won't work.
At least it should be like "/steam.mp4", but the current implementation of "/steam.mp4" is very slow, because it involves FFmpeg re-encoding.

Plus, when serving the media file to VR Players, it will help the player a lot by keeping the file name, like "my_video_3dh_180.mp4". VR players will recognize this media file as a 180 3D side-by-side video by using the original file name. This is a common feature for almost all VR players.

Moreover, a simple "/steam.mp4" will leave VR Players no idea what kind of video it actually is. And since every video in Stash shares the same name like "/steam.mp4", after you watching the 1st video, the second video will start playing at the 1st video's last position.

This end point will allow Stash to serve the original file at "/steam/org" route, if the scene has a file named "my_video_3dh_180.mp4", it can be served at "/steam/org/my_video_3dh_180.mp4". This way it serves the original file while keeping the original file name for the VR players.

Once the link is clicked, the VR player will open the media file and hide the browser. So there is no need to open a new browser tab. VR player's browser is quite rudimentary. So creating a new browser tab for the file will only complicate things and cover the browser with a blank page. Plus, if someone wants to play the original video inside a browser, he already has the media player in the same page.

I wish I didn't have to create this special end point to implement the simple file serving, but there is no other way I can see that can do the trick.

PS: The new end point also serves other file types in the same folder. As the example above, if there is a "my_video_3dh_180.srt" subtitle file in the same folder as the prime file, using "/steam/org/my_video_3dh_180.srt" will serve the subtitle file as well. This will allow VR players to open additional files like *.ass , *.funscript, *.hsp ... etc.

scene := r.Context().Value(sceneKey).(*models.Scene)
// check if it's funscript
streamOrgFile := chi.URLParam(r, "streamOrgFile")
aStr := strings.Split(streamOrgFile, ".")
// aStr usually is the primary file, but can be .srt or other file format.
if strings.ToLower(aStr[len(aStr)-1]) == "funscript" {
// it's a funscript request
rs.Funscript(w, r)
return
}

// return 404 if the scene does not have a primary file
primaryFile := scene.Files.Primary()
if primaryFile == nil {
w.WriteHeader(http.StatusNotFound)
if _, err := w.Write([]byte("Primary file not found for streaming original file.")); err != nil {
logger.Warnf("[scene] error getting primary file for streaming original: $v", err)
}
return
}
pathBase := primaryFile.Path[:len(primaryFile.Path)-len(primaryFile.Basename)] // remove filename from the path.
f, err := url.PathUnescape(streamOrgFile)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
f = pathBase + f
// Also return 404 if the actual file cannot be found
if _, err := os.Stat(f); errors.Is(err, os.ErrNotExist) {
w.WriteHeader(http.StatusNotFound)
return
}

utils.ServeStaticFile(w, r, f)
// http.ServeFile(w, r, f.Path)
}

func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.StreamTypeMP4)
}
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/graphql/data/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
handyKey
funscriptOffset
useStashHostedFunscript
showOpenExternal
}

fragment ConfigDLNAData on ConfigDLNAResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useIntl } from "react-intl";
import { Icon } from "src/components/Shared/Icon";
import { objectTitle } from "src/core/files";
import { SceneDataFragment } from "src/core/generated-graphql";
import { useConfigurationContext } from "src/hooks/Config";

export interface IExternalPlayerButtonProps {
scene: SceneDataFragment;
Expand All @@ -16,10 +17,17 @@ export const ExternalPlayerButton: React.FC<IExternalPlayerButtonProps> = ({
const isAndroid = /(android)/i.test(navigator.userAgent);
const isAppleDevice = /(ipod|iphone|ipad)/i.test(navigator.userAgent);
const intl = useIntl();
const { configuration } = useConfigurationContext();
const showOpenExternal = configuration.ui.showOpenExternal ?? true;
const { paths, files } = scene;
// Get only file name from the full path.
const fileName = files[0].path?.split("/").pop()?.split("\\").pop() ?? "";

const { paths } = scene;

if (!paths || !paths.stream || (!isAndroid && !isAppleDevice))
if (
!paths ||
!paths.stream ||
(!isAndroid && !isAppleDevice && !showOpenExternal)
)
return <span />;

const { stream } = paths;
Expand Down Expand Up @@ -47,6 +55,8 @@ export const ExternalPlayerButton: React.FC<IExternalPlayerButtonProps> = ({
url = streamURL
.toString()
.replace(new RegExp(`^${streamURL.protocol}`), "vlc-x-callback:");
} else if (showOpenExternal) {
url = stream + "/org/" + encodeURIComponent(fileName);
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,13 +462,18 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
return <span>{TextUtils.secondsToTimestamp(v ?? 0)}</span>;
}}
/>

<BooleanSetting
id="show-ab-loop"
headingID="config.ui.scene_player.options.show_ab_loop_controls"
checked={ui.showAbLoopControls ?? undefined}
onChange={(v) => saveUI({ showAbLoopControls: v })}
/>
<BooleanSetting
id="show-open-external"
headingID="config.ui.scene_player.options.show_open_external"
checked={ui.showOpenExternal ?? true}
onChange={(v) => saveUI({ showOpenExternal: v })}
/>
</SettingSection>
<SettingSection headingID="config.ui.tag_panel.heading">
<BooleanSetting
Expand Down
2 changes: 2 additions & 0 deletions ui/v2.5/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface IUIConfig {

showAbLoopControls?: boolean;

showOpenExternal?: boolean;

// maximum number of items to shown in the dropdown list - defaults to 200
// upper limit of 1000
maxOptionsShown?: number;
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/src/locales/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@
"disable_mobile_media_auto_rotate": "Disable auto-rotate of fullscreen media on Mobile",
"enable_chromecast": "Enable Chromecast",
"show_ab_loop_controls": "Show AB Loop plugin controls",
"show_open_external": "Show 'Open In External Player' button",
"show_scrubber": "Show Scrubber",
"show_range_markers": "Show Range Markers",
"track_activity": "Enable Scene Play history",
Expand Down