mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
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:
@@ -93,6 +93,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
}
|
}
|
||||||
handyKey
|
handyKey
|
||||||
funscriptOffset
|
funscriptOffset
|
||||||
|
useStashHostedFunscript
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ConfigDLNAData on ConfigDLNAResult {
|
fragment ConfigDLNAData on ConfigDLNAResult {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -192,6 +193,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
|||||||
|
|
||||||
HandyKey: &handyKey,
|
HandyKey: &handyKey,
|
||||||
FunscriptOffset: &scriptOffset,
|
FunscriptOffset: &scriptOffset,
|
||||||
|
UseStashHostedFunscript: &useStashHostedFunscript,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ const (
|
|||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var funscriptUrl;
|
||||||
|
|
||||||
|
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)
|
const csv = await fetch(funscriptPath)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((json) => convertFunscriptToCSV(json));
|
.then((json) => convertFunscriptToCSV(json));
|
||||||
const fileName = `${Math.round(Math.random() * 100000000)}.csv`;
|
const fileName = `${Math.round(Math.random() * 100000000)}.csv`;
|
||||||
const csvFile = new File([csv], fileName);
|
const csvFile = new File([csv], fileName);
|
||||||
|
|
||||||
const tempURL = await uploadCsv(csvFile).then((response) => response.url);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user