Allow serving of interactive CSVs directly to Handy (#3756)

* allow direct serve interactive CSVs to Handy
---------
Co-authored-by: kermieisinthehouse <kermie@isinthe.house>
This commit is contained in:
hontheinternet
2023-07-11 13:02:09 +09:00
committed by GitHub
parent 8e235a26ee
commit 4f0e0e1d99
12 changed files with 159 additions and 16 deletions

View File

@@ -93,6 +93,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
} }
handyKey handyKey
funscriptOffset funscriptOffset
useStashHostedFunscript
} }
fragment ConfigDLNAData on ConfigDLNAResult { fragment ConfigDLNAData on ConfigDLNAResult {

View File

@@ -354,6 +354,8 @@ input ConfigInterfaceInput {
handyKey: String handyKey: String
"""Funscript Time Offset""" """Funscript Time Offset"""
funscriptOffset: Int funscriptOffset: Int
"""Whether to use Stash Hosted Funscript"""
useStashHostedFunscript: Boolean
"""True if we should not auto-open a browser window on startup""" """True if we should not auto-open a browser window on startup"""
noBrowser: Boolean noBrowser: Boolean
"""True if we should send notifications to the desktop""" """True if we should send notifications to the desktop"""
@@ -425,6 +427,8 @@ type ConfigInterfaceResult {
handyKey: String handyKey: String
"""Funscript Time Offset""" """Funscript Time Offset"""
funscriptOffset: Int funscriptOffset: Int
"""Whether to use Stash Hosted Funscript"""
useStashHostedFunscript: Boolean
} }
input ConfigDLNAInput { input ConfigDLNAInput {

View File

@@ -479,6 +479,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
c.Set(config.FunscriptOffset, *input.FunscriptOffset) c.Set(config.FunscriptOffset, *input.FunscriptOffset)
} }
if input.UseStashHostedFunscript != nil {
c.Set(config.UseStashHostedFunscript, *input.UseStashHostedFunscript)
}
if err := c.Write(); err != nil { if err := c.Write(); err != nil {
return makeConfigInterfaceResult(), err return makeConfigInterfaceResult(), err
} }

View File

@@ -159,6 +159,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
language := config.GetLanguage() language := config.GetLanguage()
handyKey := config.GetHandyKey() handyKey := config.GetHandyKey()
scriptOffset := config.GetFunscriptOffset() scriptOffset := config.GetFunscriptOffset()
useStashHostedFunscript := config.GetUseStashHostedFunscript()
imageLightboxOptions := config.GetImageLightboxOptions() imageLightboxOptions := config.GetImageLightboxOptions()
// FIXME - misnamed output field means we have redundant fields // FIXME - misnamed output field means we have redundant fields
disableDropdownCreate := config.GetDisableDropdownCreate() disableDropdownCreate := config.GetDisableDropdownCreate()
@@ -190,8 +191,9 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
DisabledDropdownCreate: disableDropdownCreate, DisabledDropdownCreate: disableDropdownCreate,
DisableDropdownCreate: disableDropdownCreate, DisableDropdownCreate: disableDropdownCreate,
HandyKey: &handyKey, HandyKey: &handyKey,
FunscriptOffset: &scriptOffset, FunscriptOffset: &scriptOffset,
UseStashHostedFunscript: &useStashHostedFunscript,
} }
} }

View File

@@ -72,6 +72,7 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Get("/vtt/thumbs", rs.VttThumbs) r.Get("/vtt/thumbs", rs.VttThumbs)
r.Get("/vtt/sprite", rs.VttSprite) r.Get("/vtt/sprite", rs.VttSprite)
r.Get("/funscript", rs.Funscript) r.Get("/funscript", rs.Funscript)
r.Get("/interactive_csv", rs.InteractiveCSV)
r.Get("/interactive_heatmap", rs.InteractiveHeatmap) r.Get("/interactive_heatmap", rs.InteractiveHeatmap)
r.Get("/caption", rs.CaptionLang) r.Get("/caption", rs.CaptionLang)
@@ -374,6 +375,20 @@ func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
utils.ServeStaticFile(w, r, filepath) 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) { 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()) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())

View File

@@ -192,8 +192,10 @@ const (
DisableDropdownCreateStudio = "disable_dropdown_create.studio" DisableDropdownCreateStudio = "disable_dropdown_create.studio"
DisableDropdownCreateTag = "disable_dropdown_create.tag" DisableDropdownCreateTag = "disable_dropdown_create.tag"
HandyKey = "handy_key" HandyKey = "handy_key"
FunscriptOffset = "funscript_offset" FunscriptOffset = "funscript_offset"
UseStashHostedFunscript = "use_stash_hosted_funscript"
useStashHostedFunscriptDefault = false
DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range" DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range"
drawFunscriptHeatmapRangeDefault = true drawFunscriptHeatmapRangeDefault = true
@@ -1260,6 +1262,10 @@ func (i *Instance) GetFunscriptOffset() int {
return i.getInt(FunscriptOffset) return i.getInt(FunscriptOffset)
} }
func (i *Instance) GetUseStashHostedFunscript() bool {
return i.getBoolDefault(UseStashHostedFunscript, useStashHostedFunscriptDefault)
}
func (i *Instance) GetDeleteFileDefault() bool { func (i *Instance) GetDeleteFileDefault() bool {
return i.getBool(DeleteFileDefault) return i.getBool(DeleteFileDefault)
} }

View File

@@ -93,6 +93,7 @@ func TestConcurrentConfigAccess(t *testing.T) {
i.Set(CSSEnabled, i.GetCSSEnabled()) i.Set(CSSEnabled, i.GetCSSEnabled())
i.Set(CSSEnabled, i.GetCustomLocalesEnabled()) i.Set(CSSEnabled, i.GetCustomLocalesEnabled())
i.Set(HandyKey, i.GetHandyKey()) i.Set(HandyKey, i.GetHandyKey())
i.Set(UseStashHostedFunscript, i.GetUseStashHostedFunscript())
i.Set(DLNAServerName, i.GetDLNAServerName()) i.Set(DLNAServerName, i.GetDLNAServerName())
i.Set(DLNADefaultEnabled, i.GetDLNADefaultEnabled()) i.Set(DLNADefaultEnabled, i.GetDLNADefaultEnabled())
i.Set(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist()) i.Set(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist())

View File

@@ -1,6 +1,7 @@
package manager package manager
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image" "image"
@@ -11,6 +12,7 @@ import (
"sort" "sort"
"github.com/lucasb-eyer/go-colorful" "github.com/lucasb-eyer/go-colorful"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
@@ -365,3 +367,62 @@ func getSegmentColor(intensity float64) colorful.Color {
return c 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)
}

View File

@@ -762,6 +762,14 @@ export const SettingsInterfacePanel: React.FC = () => {
value={iface.funscriptOffset ?? undefined} value={iface.funscriptOffset ?? undefined}
onChange={(v) => saveInterface({ funscriptOffset: v })} onChange={(v) => saveInterface({ funscriptOffset: v })}
/> />
<BooleanSetting
id="use-stash-hosted-funscript"
headingID="config.ui.use_stash_hosted_funscript.heading"
subHeadingID="config.ui.use_stash_hosted_funscript.description"
checked={iface.useStashHostedFunscript ?? false}
onChange={(v) => saveInterface({ useStashHostedFunscript: v })}
/>
</SettingSection> </SettingSection>
</> </>
); );

View File

@@ -81,6 +81,8 @@ export const InteractiveProvider: React.FC = ({ children }) => {
undefined undefined
); );
const [scriptOffset, setScriptOffset] = useState<number>(0); const [scriptOffset, setScriptOffset] = useState<number>(0);
const [useStashHostedFunscript, setUseStashHostedFunscript] =
useState<boolean>(false);
const [interactive] = useState<InteractiveAPI>(new InteractiveAPI("", 0)); const [interactive] = useState<InteractiveAPI>(new InteractiveAPI("", 0));
const [initialised, setInitialised] = useState(false); const [initialised, setInitialised] = useState(false);
@@ -118,6 +120,9 @@ export const InteractiveProvider: React.FC = ({ children }) => {
setHandyKey(stashConfig.interface.handyKey ?? undefined); setHandyKey(stashConfig.interface.handyKey ?? undefined);
setScriptOffset(stashConfig.interface.funscriptOffset ?? 0); setScriptOffset(stashConfig.interface.funscriptOffset ?? 0);
setUseStashHostedFunscript(
stashConfig.interface.useStashHostedFunscript ?? false
);
}, [stashConfig]); }, [stashConfig]);
useEffect(() => { useEffect(() => {
@@ -129,11 +134,19 @@ export const InteractiveProvider: React.FC = ({ children }) => {
interactive.handyKey = handyKey ?? ""; interactive.handyKey = handyKey ?? "";
interactive.scriptOffset = scriptOffset; interactive.scriptOffset = scriptOffset;
interactive.useStashHostedFunscript = useStashHostedFunscript;
if (oldKey !== interactive.handyKey && interactive.handyKey) { if (oldKey !== interactive.handyKey && interactive.handyKey) {
initialise(); initialise();
} }
}, [handyKey, scriptOffset, config, interactive, initialise]); }, [
handyKey,
scriptOffset,
useStashHostedFunscript,
config,
interactive,
initialise,
]);
const sync = useCallback(async () => { const sync = useCallback(async () => {
if ( if (
@@ -163,14 +176,17 @@ export const InteractiveProvider: React.FC = ({ children }) => {
setState(ConnectionState.Uploading); setState(ConnectionState.Uploading);
try { try {
await interactive.uploadScript(funscriptPath); await interactive.uploadScript(
funscriptPath,
stashConfig?.general?.apiKey
);
setCurrentScript(funscriptPath); setCurrentScript(funscriptPath);
setState(ConnectionState.Ready); setState(ConnectionState.Ready);
} catch (e) { } catch (e) {
setState(ConnectionState.Error); setState(ConnectionState.Error);
} }
}, },
[interactive, currentScript] [interactive, currentScript, stashConfig]
); );
return ( return (

View File

@@ -97,11 +97,13 @@ export class Interactive {
_playing: boolean; _playing: boolean;
_scriptOffset: number; _scriptOffset: number;
_handy: Handy; _handy: Handy;
_useStashHostedFunscript: boolean;
constructor(handyKey: string, scriptOffset: number) { constructor(handyKey: string, scriptOffset: number) {
this._handy = new Handy(); this._handy = new Handy();
this._handy.connectionKey = handyKey; this._handy.connectionKey = handyKey;
this._scriptOffset = scriptOffset; this._scriptOffset = scriptOffset;
this._useStashHostedFunscript = false;
this._connected = false; this._connected = false;
this._playing = false; this._playing = false;
} }
@@ -127,27 +129,46 @@ export class Interactive {
return this._handy.connectionKey; return this._handy.connectionKey;
} }
set useStashHostedFunscript(useStashHostedFunscript: boolean) {
this._useStashHostedFunscript = useStashHostedFunscript;
}
get useStashHostedFunscript(): boolean {
return this._useStashHostedFunscript;
}
set scriptOffset(offset: number) { set scriptOffset(offset: number) {
this._scriptOffset = offset; this._scriptOffset = offset;
} }
async uploadScript(funscriptPath: string) { async uploadScript(funscriptPath: string, apiKey?: string) {
if (!(this._handy.connectionKey && funscriptPath)) { if (!(this._handy.connectionKey && funscriptPath)) {
return; return;
} }
const csv = await fetch(funscriptPath) var funscriptUrl;
.then((response) => response.json())
.then((json) => convertFunscriptToCSV(json));
const fileName = `${Math.round(Math.random() * 100000000)}.csv`;
const csvFile = new File([csv], fileName);
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); await this._handy.setMode(HandyMode.hssp);
this._connected = await this._handy this._connected = await this._handy
.setHsspSetup(tempURL) .setHsspSetup(funscriptUrl)
.then((result) => result === HsspSetupResult.downloaded); .then((result) => result === HsspSetupResult.downloaded);
} }

View File

@@ -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", "configuration": "Configuration",