Skip to content

Commit f379e01

Browse files
flound1129claude
andcommitted
feat: add SpectravR plugin for SpectraVR VR media player integration
REST API plugin enabling the SpectraVR Quest app to manage torrents and stream video via the Squall daemon. Includes a web UI preferences page with a reveal/copy button for the bearer token. - Plugin core: REST endpoints for torrents, files, VR tagging, streaming - Web UI: ExtJS prefs panel, token fetched via Deluge session auth - Debian: squall-spectravr package with postinst token generation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f70b8dd commit f379e01

File tree

12 files changed

+803
-0
lines changed

12 files changed

+803
-0
lines changed

debian/control

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ Description: lightweight, lean BitTorrent client (web frontend)
103103
.
104104
This package contains the web frontend.
105105

106+
Package: squall-spectravr
107+
Architecture: all
108+
Depends:
109+
squall-common (= ${source:Version}),
110+
squall-daemon (= ${source:Version}),
111+
squall-web (= ${source:Version}),
112+
${misc:Depends},
113+
Description: SpectraVR plugin for Squall
114+
Adds a REST API to the Squall daemon for torrent management and video
115+
streaming, used by the SpectraVR VR media player app.
116+
.
117+
Provides endpoints for adding/removing torrents, file management,
118+
VR projection tagging, and byte-range HTTP streaming. Includes a
119+
token reveal button in the Squall web preferences.
120+
106121
Package: squall-daemon
107122
Architecture: all
108123
Pre-Depends:

debian/squall-spectravr.postinst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/bin/sh
2+
3+
set -e
4+
5+
CONF_DIR=/var/lib/squall-daemon/config
6+
CONF_FILE=$CONF_DIR/spectravr.conf
7+
DAEMON_USER=debian-squall-daemon
8+
9+
case "${1}" in
10+
configure)
11+
if [ ! -f "$CONF_FILE" ]; then
12+
TOKEN=$(python3 -c "import secrets; print(secrets.token_hex(32))")
13+
mkdir -p "$CONF_DIR"
14+
printf '[spectravr]\ntoken = %s\n' "$TOKEN" > "$CONF_FILE"
15+
if getent passwd "$DAEMON_USER" > /dev/null 2>&1; then
16+
chown "$DAEMON_USER:$DAEMON_USER" "$CONF_FILE"
17+
fi
18+
chmod 600 "$CONF_FILE"
19+
echo "SpectraVR: token written to $CONF_FILE"
20+
echo "SpectraVR: reveal it in Squall Web → Preferences → SpectraVR"
21+
fi
22+
;;
23+
24+
abort-upgrade|abort-remove|abort-deconfigure)
25+
;;
26+
27+
*)
28+
echo "postinst called with unknown argument \`${1}'" >&2
29+
exit 1
30+
;;
31+
esac
32+
33+
#DEBHELPER#
34+
35+
exit 0

debian/squall-spectravr.postrm

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/sh
2+
3+
set -e
4+
5+
case "${1}" in
6+
purge)
7+
rm -f /var/lib/squall-daemon/config/spectravr.conf
8+
rm -f /var/lib/squall-daemon/config/spectravr.db
9+
;;
10+
esac
11+
12+
#DEBHELPER#
13+
14+
exit 0

deluge/plugins/SpectravR/setup.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from setuptools import setup, find_packages
2+
3+
setup(
4+
name="SpectravR",
5+
version="0.1.0",
6+
description="SpectraVR Deluge plugin — REST API for torrent management and streaming",
7+
author="SpectraVR",
8+
packages=find_packages(exclude=["tests"]),
9+
package_data={
10+
"spectravr": ["data/*.js"],
11+
},
12+
entry_points={
13+
"deluge.plugin.core": [
14+
"SpectravR = spectravr:CorePlugin",
15+
],
16+
"deluge.plugin.web": [
17+
"SpectravR = spectravr.webui:WebUI",
18+
],
19+
},
20+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""SpectraVR Deluge plugin."""
2+
3+
PLUGIN_NAME = "SpectravR"
4+
PLUGIN_DESCRIPTION = "REST API for torrent management, streaming, and file ops"
5+
PLUGIN_VERSION = "0.1.0"
6+
PLUGIN_AUTHOR = "SpectraVR"
7+
8+
9+
def CorePlugin(plugin_api, *args, **kwargs):
10+
from .core import Core
11+
return Core(plugin_api, *args, **kwargs)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from importlib.resources import files
2+
3+
4+
def get_resource(filename):
5+
return str(files(__package__).joinpath('data', filename))
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""SpectraVR plugin core — REST endpoints, SQLite state, streaming."""
2+
3+
import json
4+
import os
5+
import sqlite3
6+
import logging
7+
8+
log = logging.getLogger(__name__)
9+
10+
# VR projection suffix map (projection key → filename suffix, no extension)
11+
VR_TAGS = {
12+
"vr180_lr": "_180_lr",
13+
"vr180_tb": "_180_ou",
14+
"sphere_360_mono": "_360",
15+
"sphere_360_3d": "_360_3d",
16+
"fisheye": "_fisheye_180",
17+
}
18+
19+
ALL_VR_SUFFIXES = list(VR_TAGS.values())
20+
21+
22+
def strip_vr_tag(stem: str) -> str:
23+
"""Remove any existing VR suffix from a filename stem (no extension)."""
24+
for suffix in ALL_VR_SUFFIXES:
25+
if stem.endswith(suffix):
26+
return stem[: -len(suffix)]
27+
return stem
28+
29+
30+
def apply_vr_tag_to_name(filename: str, projection: str | None) -> str:
31+
"""Return new filename with VR tag applied (or removed if projection is None)."""
32+
if "." in filename:
33+
stem, ext = filename.rsplit(".", 1)
34+
ext = "." + ext
35+
else:
36+
stem, ext = filename, ""
37+
clean = strip_vr_tag(stem)
38+
if projection is None:
39+
return clean + ext
40+
suffix = VR_TAGS.get(projection, "")
41+
return clean + suffix + ext
42+
43+
44+
def db_connect(db_path: str) -> sqlite3.Connection:
45+
"""Open (or create) the plugin SQLite database."""
46+
conn = sqlite3.connect(db_path)
47+
conn.execute("""
48+
CREATE TABLE IF NOT EXISTS file_metadata (
49+
path TEXT PRIMARY KEY,
50+
favorited INTEGER DEFAULT 0,
51+
hidden INTEGER DEFAULT 0
52+
)
53+
""")
54+
conn.commit()
55+
return conn
56+
57+
58+
def db_toggle(conn: sqlite3.Connection, path: str, column: str) -> None:
59+
"""Toggle a boolean column in file_metadata for the given path key."""
60+
assert column in ("favorited", "hidden")
61+
conn.execute(
62+
f"INSERT INTO file_metadata (path, {column}) VALUES (?, 1) "
63+
f"ON CONFLICT(path) DO UPDATE SET {column} = 1 - {column}",
64+
(path,),
65+
)
66+
conn.commit()
67+
68+
69+
def db_get(conn: sqlite3.Connection, path: str) -> dict:
70+
"""Return {favorited, hidden} for path; defaults 0 if not present."""
71+
row = conn.execute(
72+
"SELECT favorited, hidden FROM file_metadata WHERE path = ?", (path,)
73+
).fetchone()
74+
return {"favorited": bool(row[0]), "hidden": bool(row[1])} if row else {"favorited": False, "hidden": False}
75+
76+
77+
class Core:
78+
"""Deluge core plugin class — registered by setup.py entry point."""
79+
80+
def __init__(self, plugin_api, *args, **kwargs):
81+
self.plugin_api = plugin_api
82+
self.resource = None
83+
self.db = None
84+
85+
def enable(self):
86+
"""Called when plugin is enabled in Deluge preferences."""
87+
import deluge.configmanager as cm
88+
config_dir = cm.get_config_dir()
89+
db_path = os.path.join(config_dir, "spectravr.db")
90+
conf_path = os.path.join(config_dir, "spectravr.conf")
91+
self.token = self._load_token(conf_path)
92+
self.db = db_connect(db_path)
93+
94+
# Register REST resource on Deluge's Twisted web server.
95+
try:
96+
import deluge.component as component
97+
from .resource import SpectravRResource
98+
self.resource = SpectravRResource(self)
99+
component.get("DelugeWeb").top_level.putChild(b"spectravr", self.resource)
100+
log.info("SpectravR plugin enabled, resource registered at /spectravr/")
101+
except Exception as e:
102+
log.error("Failed to register SpectravR resource: %s", e)
103+
104+
def disable(self):
105+
"""Called when plugin is disabled."""
106+
try:
107+
import deluge.component as component
108+
top = component.get("DelugeWeb").top_level
109+
if b"spectravr" in top.children:
110+
del top.children[b"spectravr"]
111+
except Exception as e:
112+
log.warning("Error deregistering SpectravR resource: %s", e)
113+
if self.db:
114+
self.db.close()
115+
self.db = None
116+
117+
def update(self):
118+
pass
119+
120+
@staticmethod
121+
def _load_token(conf_path: str) -> str:
122+
"""Read token from spectravr.conf; return empty string if missing."""
123+
if not os.path.exists(conf_path):
124+
return ""
125+
import configparser
126+
cfg = configparser.ConfigParser()
127+
cfg.read(conf_path)
128+
return cfg.get("spectravr", "token", fallback="")
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* spectravr.js — SpectraVR preferences panel for the Deluge web UI.
3+
*
4+
* Adds a "SpectraVR" page to Preferences showing the API token.
5+
* "Reveal" fetches the token from /spectravr/admin/token (Deluge session auth).
6+
* Subsequent clicks copy the token to the clipboard.
7+
*/
8+
9+
Ext.ns('Deluge.ux.preferences');
10+
11+
Deluge.ux.preferences.SpectravRPage = Ext.extend(Ext.Panel, {
12+
title: _('SpectraVR'),
13+
layout: 'fit',
14+
border: false,
15+
16+
initComponent: function () {
17+
Deluge.ux.preferences.SpectravRPage.superclass.initComponent.call(this);
18+
19+
var fs = this.add({
20+
xtype: 'fieldset',
21+
border: false,
22+
title: _('API Token'),
23+
autoHeight: true,
24+
labelWidth: 60,
25+
});
26+
27+
this.tokenField = fs.add({
28+
xtype: 'textfield',
29+
fieldLabel: _('Token'),
30+
width: 380,
31+
readOnly: true,
32+
value: '',
33+
emptyText: _('(click Reveal to show)'),
34+
});
35+
36+
this.actionBtn = fs.add({
37+
xtype: 'button',
38+
text: _('Reveal'),
39+
style: 'margin-top: 8px',
40+
handler: this.onAction,
41+
scope: this,
42+
});
43+
},
44+
45+
onAction: function () {
46+
var token = this.tokenField.getValue();
47+
48+
if (token) {
49+
// Token already revealed — copy it.
50+
this._copyToken(token);
51+
return;
52+
}
53+
54+
// First click: fetch token from the daemon.
55+
Ext.Ajax.request({
56+
url: '/spectravr/admin/token',
57+
method: 'GET',
58+
success: function (response) {
59+
try {
60+
var data = Ext.decode(response.responseText);
61+
if (data.token) {
62+
this.tokenField.setValue(data.token);
63+
this.actionBtn.setText(_('Copy'));
64+
} else {
65+
Ext.Msg.alert(_('SpectraVR'), _('Token not set. Check spectravr.conf.'));
66+
}
67+
} catch (e) {
68+
Ext.Msg.alert(_('SpectraVR'), _('Unexpected response from server.'));
69+
}
70+
},
71+
failure: function () {
72+
Ext.Msg.alert(_('SpectraVR'), _('Failed to retrieve token. Are you logged in?'));
73+
},
74+
scope: this,
75+
});
76+
},
77+
78+
_copyToken: function (token) {
79+
if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
80+
navigator.clipboard.writeText(token).then(function () {
81+
Ext.Msg.alert(_('SpectraVR'), _('Token copied to clipboard.'));
82+
}).catch(function () {
83+
this._fallbackCopy(token);
84+
}.bind(this));
85+
} else {
86+
this._fallbackCopy(token);
87+
}
88+
},
89+
90+
_fallbackCopy: function (token) {
91+
// Select the text field so the user can copy manually.
92+
var el = this.tokenField.el.dom;
93+
el.select();
94+
try {
95+
document.execCommand('copy');
96+
Ext.Msg.alert(_('SpectraVR'), _('Token copied to clipboard.'));
97+
} catch (e) {
98+
Ext.Msg.alert(_('SpectraVR'), _('Copy failed — please select and copy the token manually.'));
99+
}
100+
},
101+
});
102+
103+
Ext.ns('Deluge.plugins');
104+
105+
Deluge.plugins.SpectravRPlugin = Ext.extend(Deluge.Plugin, {
106+
name: 'SpectravR',
107+
108+
onEnable: function () {
109+
this.prefsPage = deluge.preferences.addPage(
110+
new Deluge.ux.preferences.SpectravRPage()
111+
);
112+
},
113+
114+
onDisable: function () {
115+
deluge.preferences.removePage(this.prefsPage);
116+
},
117+
});
118+
119+
Deluge.registerPlugin('SpectravR', Deluge.plugins.SpectravRPlugin);

0 commit comments

Comments
 (0)