diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 2a56e9512..330b7b02f 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -93,6 +93,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { } handyKey funscriptOffset + useStashHostedFunscript } fragment ConfigDLNAData on ConfigDLNAResult { diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 6c9939385..08fa4e217 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -354,6 +354,8 @@ input ConfigInterfaceInput { handyKey: String """Funscript Time Offset""" funscriptOffset: Int + """Whether to use Stash Hosted Funscript""" + useStashHostedFunscript: Boolean """True if we should not auto-open a browser window on startup""" noBrowser: Boolean """True if we should send notifications to the desktop""" @@ -425,6 +427,8 @@ type ConfigInterfaceResult { handyKey: String """Funscript Time Offset""" funscriptOffset: Int + """Whether to use Stash Hosted Funscript""" + useStashHostedFunscript: Boolean } input ConfigDLNAInput { diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index bdc93137f..177b44427 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -479,6 +479,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI c.Set(config.FunscriptOffset, *input.FunscriptOffset) } + if input.UseStashHostedFunscript != nil { + c.Set(config.UseStashHostedFunscript, *input.UseStashHostedFunscript) + } + if err := c.Write(); err != nil { return makeConfigInterfaceResult(), err } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 4c9f00aea..7de9bda0d 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -159,6 +159,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { language := config.GetLanguage() handyKey := config.GetHandyKey() scriptOffset := config.GetFunscriptOffset() + useStashHostedFunscript := config.GetUseStashHostedFunscript() imageLightboxOptions := config.GetImageLightboxOptions() // FIXME - misnamed output field means we have redundant fields disableDropdownCreate := config.GetDisableDropdownCreate() @@ -190,8 +191,9 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { DisabledDropdownCreate: disableDropdownCreate, DisableDropdownCreate: disableDropdownCreate, - HandyKey: &handyKey, - FunscriptOffset: &scriptOffset, + HandyKey: &handyKey, + FunscriptOffset: &scriptOffset, + UseStashHostedFunscript: &useStashHostedFunscript, } } diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index 9a5e81496..43d37da36 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -72,6 +72,7 @@ func (rs sceneRoutes) Routes() chi.Router { r.Get("/vtt/thumbs", rs.VttThumbs) r.Get("/vtt/sprite", rs.VttSprite) r.Get("/funscript", rs.Funscript) + r.Get("/interactive_csv", rs.InteractiveCSV) r.Get("/interactive_heatmap", rs.InteractiveHeatmap) r.Get("/caption", rs.CaptionLang) @@ -374,6 +375,20 @@ func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) { utils.ServeStaticFile(w, r, filepath) } +func (rs sceneRoutes) InteractiveCSV(w http.ResponseWriter, r *http.Request) { + s := r.Context().Value(sceneKey).(*models.Scene) + filepath := video.GetFunscriptPath(s.Path) + + // TheHandy directly only accepts interactive CSVs + csvBytes, err := manager.ConvertFunscriptToCSV(filepath) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + utils.ServeStaticContent(w, r, csvBytes) +} + func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 44c643925..2a242c114 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -192,8 +192,10 @@ const ( DisableDropdownCreateStudio = "disable_dropdown_create.studio" DisableDropdownCreateTag = "disable_dropdown_create.tag" - HandyKey = "handy_key" - FunscriptOffset = "funscript_offset" + HandyKey = "handy_key" + FunscriptOffset = "funscript_offset" + UseStashHostedFunscript = "use_stash_hosted_funscript" + useStashHostedFunscriptDefault = false DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range" drawFunscriptHeatmapRangeDefault = true @@ -1260,6 +1262,10 @@ func (i *Instance) GetFunscriptOffset() int { return i.getInt(FunscriptOffset) } +func (i *Instance) GetUseStashHostedFunscript() bool { + return i.getBoolDefault(UseStashHostedFunscript, useStashHostedFunscriptDefault) +} + func (i *Instance) GetDeleteFileDefault() bool { return i.getBool(DeleteFileDefault) } diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go index 81bb7e816..0ede5f055 100644 --- a/internal/manager/config/config_concurrency_test.go +++ b/internal/manager/config/config_concurrency_test.go @@ -93,6 +93,7 @@ func TestConcurrentConfigAccess(t *testing.T) { i.Set(CSSEnabled, i.GetCSSEnabled()) i.Set(CSSEnabled, i.GetCustomLocalesEnabled()) i.Set(HandyKey, i.GetHandyKey()) + i.Set(UseStashHostedFunscript, i.GetUseStashHostedFunscript()) i.Set(DLNAServerName, i.GetDLNAServerName()) i.Set(DLNADefaultEnabled, i.GetDLNADefaultEnabled()) i.Set(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist()) diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index 3cae5f562..17f8c2a8a 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -1,6 +1,7 @@ package manager import ( + "bytes" "encoding/json" "fmt" "image" @@ -11,6 +12,7 @@ import ( "sort" "github.com/lucasb-eyer/go-colorful" + "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) @@ -365,3 +367,62 @@ func getSegmentColor(intensity float64) colorful.Color { return c } + +func LoadFunscriptData(path string) (Script, error) { + data, err := os.ReadFile(path) + if err != nil { + return Script{}, err + } + + var funscript Script + err = json.Unmarshal(data, &funscript) + if err != nil { + return Script{}, err + } + + if funscript.Actions == nil { + return Script{}, fmt.Errorf("actions list missing in %s", path) + } + + sort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At }) + + return funscript, nil +} + +func convertRange(value int, fromLow int, fromHigh int, toLow int, toHigh int) int { + return ((value-fromLow)*(toHigh-toLow))/(fromHigh-fromLow) + toLow +} + +func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) { + funscript, err := LoadFunscriptData(funscriptPath) + + if err != nil { + return nil, err + } + + var buffer bytes.Buffer + for _, action := range funscript.Actions { + pos := action.Pos + + if funscript.Inverted { + pos = convertRange(pos, 0, 100, 100, 0) + } + + if funscript.Range > 0 { + pos = convertRange(pos, 0, funscript.Range, 0, 100) + } + + buffer.WriteString(fmt.Sprintf("%d,%d\r\n", action.At, pos)) + } + return buffer.Bytes(), nil +} + +func ConvertFunscriptToCSVFile(funscriptPath string, csvPath string) error { + csvBytes, err := ConvertFunscriptToCSV(funscriptPath) + + if err != nil { + return err + } + + return fsutil.WriteFile(csvPath, csvBytes) +} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 82343cda4..2332538b1 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -762,6 +762,14 @@ export const SettingsInterfacePanel: React.FC = () => { value={iface.funscriptOffset ?? undefined} onChange={(v) => saveInterface({ funscriptOffset: v })} /> + + saveInterface({ useStashHostedFunscript: v })} + /> ); diff --git a/ui/v2.5/src/hooks/Interactive/context.tsx b/ui/v2.5/src/hooks/Interactive/context.tsx index c3c5ccb86..487fa8468 100644 --- a/ui/v2.5/src/hooks/Interactive/context.tsx +++ b/ui/v2.5/src/hooks/Interactive/context.tsx @@ -81,6 +81,8 @@ export const InteractiveProvider: React.FC = ({ children }) => { undefined ); const [scriptOffset, setScriptOffset] = useState(0); + const [useStashHostedFunscript, setUseStashHostedFunscript] = + useState(false); const [interactive] = useState(new InteractiveAPI("", 0)); const [initialised, setInitialised] = useState(false); @@ -118,6 +120,9 @@ export const InteractiveProvider: React.FC = ({ children }) => { setHandyKey(stashConfig.interface.handyKey ?? undefined); setScriptOffset(stashConfig.interface.funscriptOffset ?? 0); + setUseStashHostedFunscript( + stashConfig.interface.useStashHostedFunscript ?? false + ); }, [stashConfig]); useEffect(() => { @@ -129,11 +134,19 @@ export const InteractiveProvider: React.FC = ({ children }) => { interactive.handyKey = handyKey ?? ""; interactive.scriptOffset = scriptOffset; + interactive.useStashHostedFunscript = useStashHostedFunscript; if (oldKey !== interactive.handyKey && interactive.handyKey) { initialise(); } - }, [handyKey, scriptOffset, config, interactive, initialise]); + }, [ + handyKey, + scriptOffset, + useStashHostedFunscript, + config, + interactive, + initialise, + ]); const sync = useCallback(async () => { if ( @@ -163,14 +176,17 @@ export const InteractiveProvider: React.FC = ({ children }) => { setState(ConnectionState.Uploading); try { - await interactive.uploadScript(funscriptPath); + await interactive.uploadScript( + funscriptPath, + stashConfig?.general?.apiKey + ); setCurrentScript(funscriptPath); setState(ConnectionState.Ready); } catch (e) { setState(ConnectionState.Error); } }, - [interactive, currentScript] + [interactive, currentScript, stashConfig] ); return ( diff --git a/ui/v2.5/src/hooks/Interactive/interactive.ts b/ui/v2.5/src/hooks/Interactive/interactive.ts index 1198ac59e..ef34bd2ef 100644 --- a/ui/v2.5/src/hooks/Interactive/interactive.ts +++ b/ui/v2.5/src/hooks/Interactive/interactive.ts @@ -97,11 +97,13 @@ export class Interactive { _playing: boolean; _scriptOffset: number; _handy: Handy; + _useStashHostedFunscript: boolean; constructor(handyKey: string, scriptOffset: number) { this._handy = new Handy(); this._handy.connectionKey = handyKey; this._scriptOffset = scriptOffset; + this._useStashHostedFunscript = false; this._connected = false; this._playing = false; } @@ -127,27 +129,46 @@ export class Interactive { return this._handy.connectionKey; } + set useStashHostedFunscript(useStashHostedFunscript: boolean) { + this._useStashHostedFunscript = useStashHostedFunscript; + } + + get useStashHostedFunscript(): boolean { + return this._useStashHostedFunscript; + } + set scriptOffset(offset: number) { this._scriptOffset = offset; } - async uploadScript(funscriptPath: string) { + async uploadScript(funscriptPath: string, apiKey?: string) { if (!(this._handy.connectionKey && funscriptPath)) { return; } - const csv = await fetch(funscriptPath) - .then((response) => response.json()) - .then((json) => convertFunscriptToCSV(json)); - const fileName = `${Math.round(Math.random() * 100000000)}.csv`; - const csvFile = new File([csv], fileName); + var funscriptUrl; - const tempURL = await uploadCsv(csvFile).then((response) => response.url); + if (this._useStashHostedFunscript) { + funscriptUrl = funscriptPath.replace("/funscript", "/interactive_csv"); + if (typeof apiKey !== "undefined" && apiKey !== "") { + var url = new URL(funscriptUrl); + url.searchParams.append("apikey", apiKey); + funscriptUrl = url.toString(); + } + } else { + const csv = await fetch(funscriptPath) + .then((response) => response.json()) + .then((json) => convertFunscriptToCSV(json)); + const fileName = `${Math.round(Math.random() * 100000000)}.csv`; + const csvFile = new File([csv], fileName); + + funscriptUrl = await uploadCsv(csvFile).then((response) => response.url); + } await this._handy.setMode(HandyMode.hssp); this._connected = await this._handy - .setHsspSetup(tempURL) + .setHsspSetup(funscriptUrl) .then((result) => result === HsspSetupResult.downloaded); } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e7df3470d..9a33f0fc2 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -704,7 +704,11 @@ } } }, - "title": "User Interface" + "title": "User Interface", + "use_stash_hosted_funscript": { + "description": "When enabled, funscripts will be served directly from Stash to your Handy device without using the third party Handy server. Requires that Stash be accessible from your Handy device.", + "heading": "Serve funscripts directly" + } } }, "configuration": "Configuration",