From 7d15da39c3e5cd39d3f62063e3c4cfdd3e47328c Mon Sep 17 00:00:00 2001
From: Renat Sharipov <6128858+SHAREN@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:14:29 +0500
Subject: [PATCH 1/3] feat(server): add playback device registry and API
---
server/playback/devices.go | 549 ++++++++++++++++++++++++++++++++
server/playback/devices_test.go | 313 ++++++++++++++++++
server/server.go | 4 +
server/web/api/playback.go | 190 +++++++++++
server/web/api/playback_test.go | 95 ++++++
server/web/api/route.go | 8 +
6 files changed, 1159 insertions(+)
create mode 100644 server/playback/devices.go
create mode 100644 server/playback/devices_test.go
create mode 100644 server/web/api/playback.go
create mode 100644 server/web/api/playback_test.go
diff --git a/server/playback/devices.go b/server/playback/devices.go
new file mode 100644
index 000000000..aef2fbe41
--- /dev/null
+++ b/server/playback/devices.go
@@ -0,0 +1,549 @@
+package playback
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ configFileName = "playback_devices.json"
+ configVersion = 1
+
+ RoutingLocal = "local"
+ RoutingPrimary = "primary"
+ RoutingPerBrowser = "per-browser"
+)
+
+var (
+ idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
+ ErrRemotePlaybackDisabled = errors.New("remote playback is disabled")
+ ErrPrimaryDeviceNotSelected = errors.New("primary playback device is not selected")
+)
+
+// Device is the private, persisted playback-device configuration.
+// Endpoint and Token are intentionally never exposed by PublicConfig.
+type Device struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Endpoint string `json:"endpoint"`
+ Token string `json:"token,omitempty"`
+ StreamBaseURL string `json:"stream_base_url,omitempty"`
+ Fullscreen bool `json:"fullscreen,omitempty"`
+}
+
+// PublicDevice is safe to return to normal web clients.
+type PublicDevice struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// ManagedDevice is returned only by the authenticated management endpoint.
+// The token itself is never returned.
+type ManagedDevice struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Endpoint string `json:"endpoint"`
+ StreamBaseURL string `json:"stream_base_url,omitempty"`
+ Fullscreen bool `json:"fullscreen"`
+ HasToken bool `json:"has_token"`
+}
+
+// DeviceInput is used for create/update operations. An empty token preserves
+// the current token. ClearToken explicitly removes it when no replacement is supplied.
+type DeviceInput struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Endpoint string `json:"endpoint"`
+ Token string `json:"token,omitempty"`
+ ClearToken bool `json:"clear_token,omitempty"`
+ StreamBaseURL string `json:"stream_base_url,omitempty"`
+ Fullscreen bool `json:"fullscreen"`
+}
+
+// Settings controls whether the new routing behavior is enabled. Local routing
+// preserves the historical behavior where each browser opens its own player.
+type Settings struct {
+ Enabled bool `json:"enabled"`
+ RoutingMode string `json:"routing_mode"`
+ PrimaryDeviceID string `json:"primary_device_id,omitempty"`
+}
+
+type PublicConfig struct {
+ Settings
+ Devices []PublicDevice `json:"devices"`
+}
+
+type ManagedConfig struct {
+ Settings
+ Devices []ManagedDevice `json:"devices"`
+}
+
+type persistedConfig struct {
+ Version int `json:"version"`
+ Settings
+ Devices []Device `json:"devices"`
+}
+
+// AgentPlayRequest is the contract sent by TorrServer to a playback agent.
+type AgentPlayRequest struct {
+ Path string `json:"path"`
+ Hash string `json:"hash"`
+ Index int `json:"index"`
+ StreamURL string `json:"stream_url"`
+ Fullscreen bool `json:"fullscreen"`
+}
+
+type store struct {
+ mu sync.RWMutex
+ path string
+ readOnly bool
+ settings Settings
+ devices map[string]Device
+ client *http.Client
+}
+
+var global = &store{
+ settings: defaultSettings(),
+ devices: make(map[string]Device),
+ client: &http.Client{
+ Timeout: 12 * time.Second,
+ },
+}
+
+func defaultSettings() Settings {
+ return Settings{Enabled: false, RoutingMode: RoutingLocal}
+}
+
+// Init loads playback devices from the TorrServer configuration directory.
+func Init(configDir string, readOnly bool) error {
+ global.mu.Lock()
+ defer global.mu.Unlock()
+
+ global.path = filepath.Join(configDir, configFileName)
+ global.readOnly = readOnly
+ global.settings = defaultSettings()
+ global.devices = make(map[string]Device)
+
+ buf, err := os.ReadFile(global.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("read playback devices: %w", err)
+ }
+
+ config, err := decodeConfig(buf)
+ if err != nil {
+ return err
+ }
+ settings, err := normalizeSettings(config.Settings, nil)
+ if err != nil {
+ return fmt.Errorf("invalid playback settings: %w", err)
+ }
+
+ for _, device := range config.Devices {
+ normalized, err := normalizeDevice(device)
+ if err != nil {
+ return fmt.Errorf("invalid playback device %q: %w", device.ID, err)
+ }
+ global.devices[normalized.ID] = normalized
+ }
+ if settings.PrimaryDeviceID != "" {
+ if _, ok := global.devices[settings.PrimaryDeviceID]; !ok {
+ settings = defaultSettings()
+ }
+ }
+ global.settings = settings
+ return nil
+}
+
+func decodeConfig(buf []byte) (persistedConfig, error) {
+ trimmed := strings.TrimSpace(string(buf))
+ if strings.HasPrefix(trimmed, "[") {
+ // Backward compatibility with the first prototype, which stored only an array.
+ var devices []Device
+ if err := json.Unmarshal(buf, &devices); err != nil {
+ return persistedConfig{}, fmt.Errorf("decode playback devices: %w", err)
+ }
+ return persistedConfig{Version: configVersion, Settings: defaultSettings(), Devices: devices}, nil
+ }
+
+ var config persistedConfig
+ if err := json.Unmarshal(buf, &config); err != nil {
+ return persistedConfig{}, fmt.Errorf("decode playback devices: %w", err)
+ }
+ if config.Version == 0 {
+ config.Version = configVersion
+ }
+ if config.RoutingMode == "" {
+ config.RoutingMode = RoutingLocal
+ }
+ return config, nil
+}
+
+func GetPublicConfig() PublicConfig {
+ global.mu.RLock()
+ defer global.mu.RUnlock()
+ return PublicConfig{Settings: global.settings, Devices: publicDevicesLocked()}
+}
+
+func GetManagedConfig() ManagedConfig {
+ global.mu.RLock()
+ defer global.mu.RUnlock()
+
+ devices := make([]ManagedDevice, 0, len(global.devices))
+ for _, device := range global.devices {
+ devices = append(devices, ManagedDevice{
+ ID: device.ID,
+ Name: device.Name,
+ Endpoint: device.Endpoint,
+ StreamBaseURL: device.StreamBaseURL,
+ Fullscreen: device.Fullscreen,
+ HasToken: device.Token != "",
+ })
+ }
+ sort.Slice(devices, func(i, j int) bool {
+ return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name)
+ })
+ return ManagedConfig{Settings: global.settings, Devices: devices}
+}
+
+func publicDevicesLocked() []PublicDevice {
+ devices := make([]PublicDevice, 0, len(global.devices))
+ for _, device := range global.devices {
+ devices = append(devices, PublicDevice{ID: device.ID, Name: device.Name})
+ }
+ sort.Slice(devices, func(i, j int) bool {
+ return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name)
+ })
+ return devices
+}
+
+// GetPrivate is intended only for server-side playback routing.
+// Token is omitted from the returned copy.
+func GetPrivate(id string) (Device, bool) {
+ global.mu.RLock()
+ defer global.mu.RUnlock()
+ device, ok := global.devices[id]
+ if !ok {
+ return Device{}, false
+ }
+ device.Token = ""
+ return device, true
+}
+
+func UpdateSettings(input Settings) (Settings, error) {
+ global.mu.Lock()
+ defer global.mu.Unlock()
+
+ if global.readOnly {
+ return Settings{}, errors.New("playback devices are read-only")
+ }
+ settings, err := normalizeSettings(input, global.devices)
+ if err != nil {
+ return Settings{}, err
+ }
+ previous := global.settings
+ global.settings = settings
+ if err := global.saveLocked(); err != nil {
+ global.settings = previous
+ return Settings{}, err
+ }
+ return global.settings, nil
+}
+
+func normalizeSettings(settings Settings, devices map[string]Device) (Settings, error) {
+ settings.RoutingMode = strings.TrimSpace(settings.RoutingMode)
+ settings.PrimaryDeviceID = strings.TrimSpace(settings.PrimaryDeviceID)
+ if settings.RoutingMode == "" {
+ settings.RoutingMode = RoutingLocal
+ }
+ switch settings.RoutingMode {
+ case RoutingLocal, RoutingPrimary, RoutingPerBrowser:
+ default:
+ return Settings{}, errors.New("invalid playback routing mode")
+ }
+ if settings.RoutingMode == RoutingPrimary && settings.Enabled {
+ if settings.PrimaryDeviceID == "" {
+ return Settings{}, ErrPrimaryDeviceNotSelected
+ }
+ if devices != nil {
+ if _, ok := devices[settings.PrimaryDeviceID]; !ok {
+ return Settings{}, os.ErrNotExist
+ }
+ }
+ }
+ return settings, nil
+}
+
+func Upsert(input DeviceInput) (PublicDevice, error) {
+ global.mu.Lock()
+ defer global.mu.Unlock()
+
+ if global.readOnly {
+ return PublicDevice{}, errors.New("playback devices are read-only")
+ }
+
+ id := strings.TrimSpace(input.ID)
+ if id == "" {
+ var err error
+ id, err = randomID()
+ if err != nil {
+ return PublicDevice{}, err
+ }
+ }
+
+ existing := global.devices[id]
+ token := strings.TrimSpace(input.Token)
+ if token == "" && !input.ClearToken {
+ token = existing.Token
+ }
+
+ device, err := normalizeDevice(Device{
+ ID: id,
+ Name: input.Name,
+ Endpoint: input.Endpoint,
+ Token: token,
+ StreamBaseURL: input.StreamBaseURL,
+ Fullscreen: input.Fullscreen,
+ })
+ if err != nil {
+ return PublicDevice{}, err
+ }
+
+ global.devices[id] = device
+ if err := global.saveLocked(); err != nil {
+ if existing.ID == "" {
+ delete(global.devices, id)
+ } else {
+ global.devices[id] = existing
+ }
+ return PublicDevice{}, err
+ }
+
+ return PublicDevice{ID: device.ID, Name: device.Name}, nil
+}
+
+func Delete(id string) error {
+ global.mu.Lock()
+ defer global.mu.Unlock()
+
+ if global.readOnly {
+ return errors.New("playback devices are read-only")
+ }
+ device, ok := global.devices[id]
+ if !ok {
+ return os.ErrNotExist
+ }
+ previousSettings := global.settings
+ delete(global.devices, id)
+ if global.settings.PrimaryDeviceID == id {
+ global.settings.PrimaryDeviceID = ""
+ global.settings.Enabled = false
+ global.settings.RoutingMode = RoutingLocal
+ }
+ if err := global.saveLocked(); err != nil {
+ global.devices[id] = device
+ global.settings = previousSettings
+ return err
+ }
+ return nil
+}
+
+// ResolveTarget enforces the server-wide routing mode. In primary mode the
+// browser cannot override the configured main playback device.
+func ResolveTarget(requestedID string) (string, error) {
+ global.mu.RLock()
+ defer global.mu.RUnlock()
+
+ if !global.settings.Enabled || global.settings.RoutingMode == RoutingLocal {
+ return "", ErrRemotePlaybackDisabled
+ }
+ if global.settings.RoutingMode == RoutingPrimary {
+ if global.settings.PrimaryDeviceID == "" {
+ return "", ErrPrimaryDeviceNotSelected
+ }
+ if _, ok := global.devices[global.settings.PrimaryDeviceID]; !ok {
+ return "", os.ErrNotExist
+ }
+ return global.settings.PrimaryDeviceID, nil
+ }
+ requestedID = strings.TrimSpace(requestedID)
+ if requestedID == "" {
+ return "", errors.New("playback device is not selected")
+ }
+ if _, ok := global.devices[requestedID]; !ok {
+ return "", os.ErrNotExist
+ }
+ return requestedID, nil
+}
+
+func Play(deviceID string, payload AgentPlayRequest) error {
+ device, err := getDevice(deviceID)
+ if err != nil {
+ return err
+ }
+ payload.Fullscreen = device.Fullscreen
+ return sendJSON(device, http.MethodPost, "play", payload)
+}
+
+func Test(deviceID string) error {
+ device, err := getDevice(deviceID)
+ if err != nil {
+ return err
+ }
+ return sendJSON(device, http.MethodGet, "health", nil)
+}
+
+func getDevice(id string) (Device, error) {
+ global.mu.RLock()
+ defer global.mu.RUnlock()
+ device, ok := global.devices[id]
+ if !ok {
+ return Device{}, os.ErrNotExist
+ }
+ return device, nil
+}
+
+func sendJSON(device Device, method, endpointPath string, payload any) error {
+ base, err := url.Parse(device.Endpoint)
+ if err != nil {
+ return err
+ }
+ base.Path = strings.TrimRight(base.Path, "/") + "/" + endpointPath
+
+ var body *strings.Reader
+ if payload == nil {
+ body = strings.NewReader("")
+ } else {
+ buf, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ body = strings.NewReader(string(buf))
+ }
+
+ req, err := http.NewRequest(method, base.String(), body)
+ if err != nil {
+ return err
+ }
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if device.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+device.Token)
+ }
+
+ resp, err := global.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("playback device request failed: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("playback device returned %s", resp.Status)
+ }
+ return nil
+}
+
+func normalizeDevice(device Device) (Device, error) {
+ device.ID = strings.TrimSpace(device.ID)
+ device.Name = strings.TrimSpace(device.Name)
+ device.Endpoint = strings.TrimRight(strings.TrimSpace(device.Endpoint), "/")
+ device.StreamBaseURL = strings.TrimRight(strings.TrimSpace(device.StreamBaseURL), "/")
+ device.Token = strings.TrimSpace(device.Token)
+
+ if !idPattern.MatchString(device.ID) {
+ return Device{}, errors.New("invalid device id")
+ }
+ if device.Name == "" || len(device.Name) > 80 {
+ return Device{}, errors.New("device name must contain 1-80 characters")
+ }
+ if len(device.Token) > 512 {
+ return Device{}, errors.New("device token is too long")
+ }
+ if err := validateBaseURL(device.Endpoint, true); err != nil {
+ return Device{}, fmt.Errorf("invalid endpoint: %w", err)
+ }
+ if err := validateBaseURL(device.StreamBaseURL, false); err != nil {
+ return Device{}, fmt.Errorf("invalid stream base URL: %w", err)
+ }
+ return device, nil
+}
+
+func validateBaseURL(value string, required bool) error {
+ if value == "" {
+ if required {
+ return errors.New("URL is required")
+ }
+ return nil
+ }
+ parsed, err := url.Parse(value)
+ if err != nil {
+ return err
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return errors.New("only http and https are supported")
+ }
+ if parsed.Host == "" {
+ return errors.New("host is required")
+ }
+ if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return errors.New("credentials, query and fragment are not allowed")
+ }
+ return nil
+}
+
+func randomID() (string, error) {
+ buf := make([]byte, 8)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate device id: %w", err)
+ }
+ return "player-" + hex.EncodeToString(buf), nil
+}
+
+func (s *store) saveLocked() error {
+ devices := make([]Device, 0, len(s.devices))
+ for _, device := range s.devices {
+ devices = append(devices, device)
+ }
+ sort.Slice(devices, func(i, j int) bool { return devices[i].ID < devices[j].ID })
+
+ buf, err := json.MarshalIndent(persistedConfig{
+ Version: configVersion,
+ Settings: s.settings,
+ Devices: devices,
+ }, "", " ")
+ if err != nil {
+ return err
+ }
+ buf = append(buf, '\n')
+
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, buf, 0o600); err != nil {
+ return err
+ }
+ if err := os.Chmod(tmp, 0o600); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, s.path); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return os.Chmod(s.path, 0o600)
+}
diff --git a/server/playback/devices_test.go b/server/playback/devices_test.go
new file mode 100644
index 000000000..5f5b89144
--- /dev/null
+++ b/server/playback/devices_test.go
@@ -0,0 +1,313 @@
+package playback
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func TestConservativeDefaults(t *testing.T) {
+ if err := Init(t.TempDir(), false); err != nil {
+ t.Fatal(err)
+ }
+ config := GetPublicConfig()
+ if config.Enabled || config.RoutingMode != RoutingLocal || len(config.Devices) != 0 {
+ t.Fatalf("unexpected defaults: %#v", config)
+ }
+ if _, err := ResolveTarget("anything"); !errors.Is(err, ErrRemotePlaybackDisabled) {
+ t.Fatalf("remote routing should be disabled by default, got %v", err)
+ }
+}
+
+func TestLegacyArrayMigrationStaysDisabled(t *testing.T) {
+ dir := t.TempDir()
+ legacy := []Device{{ID: "tv", Name: "TV", Endpoint: "http://127.0.0.1:9000"}}
+ buf, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, configFileName), buf, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Init(dir, false); err != nil {
+ t.Fatal(err)
+ }
+ config := GetPublicConfig()
+ if config.Enabled || config.RoutingMode != RoutingLocal || len(config.Devices) != 1 {
+ t.Fatalf("legacy migration enabled new behavior: %#v", config)
+ }
+}
+
+func TestDevicePersistenceSettingsAndTokenRedaction(t *testing.T) {
+ dir := t.TempDir()
+ if err := Init(dir, false); err != nil {
+ t.Fatal(err)
+ }
+
+ created, err := Upsert(DeviceInput{
+ ID: "living-room",
+ Name: "Living room",
+ Endpoint: "http://127.0.0.1:9080",
+ Token: "secret",
+ StreamBaseURL: "http://192.168.1.10:8090",
+ Fullscreen: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.ID != "living-room" || created.Name != "Living room" {
+ t.Fatalf("unexpected public device: %#v", created)
+ }
+
+ public := GetPublicConfig()
+ if len(public.Devices) != 1 || public.Devices[0].ID != "living-room" {
+ t.Fatalf("unexpected public config: %#v", public)
+ }
+ managed := GetManagedConfig()
+ if len(managed.Devices) != 1 || !managed.Devices[0].HasToken || !managed.Devices[0].Fullscreen {
+ t.Fatalf("unexpected managed config: %#v", managed)
+ }
+
+ settings, err := UpdateSettings(Settings{
+ Enabled: true,
+ RoutingMode: RoutingPrimary,
+ PrimaryDeviceID: "living-room",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !settings.Enabled || settings.RoutingMode != RoutingPrimary {
+ t.Fatalf("settings were not saved: %#v", settings)
+ }
+
+ // Empty token on update preserves the existing secret.
+ if _, err := Upsert(DeviceInput{
+ ID: "living-room",
+ Name: "TV",
+ Endpoint: "http://127.0.0.1:9080",
+ StreamBaseURL: "http://192.168.1.10:8090",
+ Fullscreen: false,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ managed = GetManagedConfig()
+ if !managed.Devices[0].HasToken || managed.Devices[0].Name != "TV" || managed.Devices[0].Fullscreen {
+ t.Fatalf("device update was not applied correctly: %#v", managed.Devices[0])
+ }
+
+ info, err := os.Stat(filepath.Join(dir, configFileName))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("unexpected config permissions: %o", info.Mode().Perm())
+ }
+
+ if err := Init(dir, false); err != nil {
+ t.Fatal(err)
+ }
+ device, ok := getDeviceForTest("living-room")
+ if !ok || device.Token != "secret" {
+ t.Fatalf("token was not persisted: %#v", device)
+ }
+ reloaded := GetManagedConfig()
+ if !reloaded.Enabled || reloaded.RoutingMode != RoutingPrimary || reloaded.PrimaryDeviceID != "living-room" {
+ t.Fatalf("routing settings were not persisted: %#v", reloaded)
+ }
+
+ // A replacement token takes priority over a stale clear checkbox.
+ if _, err := Upsert(DeviceInput{
+ ID: "living-room",
+ Name: "TV",
+ Endpoint: "http://127.0.0.1:9080",
+ StreamBaseURL: "http://192.168.1.10:8090",
+ Token: "replacement",
+ ClearToken: true,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ device, ok = getDeviceForTest("living-room")
+ if !ok || device.Token != "replacement" {
+ t.Fatalf("replacement token was discarded: %#v", device)
+ }
+
+ if _, err := Upsert(DeviceInput{
+ ID: "living-room",
+ Name: "TV",
+ Endpoint: "http://127.0.0.1:9080",
+ StreamBaseURL: "http://192.168.1.10:8090",
+ ClearToken: true,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if GetManagedConfig().Devices[0].HasToken {
+ t.Fatal("clear_token did not remove the secret")
+ }
+}
+
+func TestRoutingModesAndPrimaryDeletion(t *testing.T) {
+ if err := Init(t.TempDir(), false); err != nil {
+ t.Fatal(err)
+ }
+ for _, input := range []DeviceInput{
+ {ID: "living-room", Name: "Living room", Endpoint: "http://127.0.0.1:9001"},
+ {ID: "bedroom", Name: "Bedroom", Endpoint: "http://127.0.0.1:9002"},
+ } {
+ if _, err := Upsert(input); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: RoutingPrimary}); !errors.Is(err, ErrPrimaryDeviceNotSelected) {
+ t.Fatalf("primary mode without a device should fail, got %v", err)
+ }
+ if _, err := UpdateSettings(Settings{
+ Enabled: true,
+ RoutingMode: RoutingPrimary,
+ PrimaryDeviceID: "missing",
+ }); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("unknown primary device should fail, got %v", err)
+ }
+
+ if _, err := UpdateSettings(Settings{
+ Enabled: true,
+ RoutingMode: RoutingPrimary,
+ PrimaryDeviceID: "living-room",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ resolved, err := ResolveTarget("bedroom")
+ if err != nil || resolved != "living-room" {
+ t.Fatalf("browser overrode primary target: %q, %v", resolved, err)
+ }
+
+ if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: RoutingPerBrowser}); err != nil {
+ t.Fatal(err)
+ }
+ resolved, err = ResolveTarget("bedroom")
+ if err != nil || resolved != "bedroom" {
+ t.Fatalf("per-browser target was not honored: %q, %v", resolved, err)
+ }
+
+ if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: RoutingLocal}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ResolveTarget("bedroom"); !errors.Is(err, ErrRemotePlaybackDisabled) {
+ t.Fatalf("local mode should reject remote routing, got %v", err)
+ }
+
+ if _, err := UpdateSettings(Settings{
+ Enabled: true,
+ RoutingMode: RoutingPrimary,
+ PrimaryDeviceID: "living-room",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := Delete("living-room"); err != nil {
+ t.Fatal(err)
+ }
+ settings := GetPublicConfig().Settings
+ if settings.Enabled || settings.RoutingMode != RoutingLocal || settings.PrimaryDeviceID != "" {
+ t.Fatalf("deleting primary device did not restore conservative mode: %#v", settings)
+ }
+}
+
+func TestValidation(t *testing.T) {
+ if err := Init(t.TempDir(), false); err != nil {
+ t.Fatal(err)
+ }
+
+ cases := []DeviceInput{
+ {ID: "bad id", Name: "TV", Endpoint: "http://127.0.0.1:9000"},
+ {ID: "tv", Name: "", Endpoint: "http://127.0.0.1:9000"},
+ {ID: "tv", Name: "TV", Endpoint: "file:///tmp/socket"},
+ {ID: "tv", Name: "TV", Endpoint: "http://user:pass@127.0.0.1:9000"},
+ {ID: "tv", Name: "TV", Endpoint: "http://127.0.0.1:9000?x=1"},
+ {ID: "tv", Name: "TV", Endpoint: "http://127.0.0.1:9000", StreamBaseURL: "ftp://host"},
+ }
+ for _, input := range cases {
+ if _, err := Upsert(input); err == nil {
+ t.Fatalf("expected validation error for %#v", input)
+ }
+ }
+ if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: "unknown"}); err == nil {
+ t.Fatal("invalid routing mode was accepted")
+ }
+}
+
+func TestAgentRequestsIncludeFullscreen(t *testing.T) {
+ var mu sync.Mutex
+ requests := make([]string, 0, 2)
+
+ agent := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer agent-secret" {
+ t.Errorf("unexpected authorization: %q", got)
+ }
+ mu.Lock()
+ requests = append(requests, r.Method+" "+r.URL.Path)
+ mu.Unlock()
+
+ switch r.URL.Path {
+ case "/health":
+ w.WriteHeader(http.StatusOK)
+ case "/play":
+ var payload AgentPlayRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Errorf("decode play request: %v", err)
+ }
+ if payload.StreamURL != "http://ts.local/stream/Movie.mkv?link=hash&index=1&play" {
+ t.Errorf("unexpected stream URL: %q", payload.StreamURL)
+ }
+ if !payload.Fullscreen {
+ t.Error("device fullscreen preference was not sent")
+ }
+ w.WriteHeader(http.StatusAccepted)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer agent.Close()
+
+ if err := Init(t.TempDir(), false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Upsert(DeviceInput{
+ ID: "tv",
+ Name: "TV",
+ Endpoint: agent.URL,
+ Token: "agent-secret",
+ Fullscreen: true,
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Test("tv"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Play("tv", AgentPlayRequest{
+ Path: "Movie.mkv",
+ Hash: "hash",
+ Index: 1,
+ StreamURL: "http://ts.local/stream/Movie.mkv?link=hash&index=1&play",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(requests) != 2 || requests[0] != "GET /health" || requests[1] != "POST /play" {
+ t.Fatalf("unexpected requests: %#v", requests)
+ }
+}
+
+func getDeviceForTest(id string) (Device, bool) {
+ global.mu.RLock()
+ defer global.mu.RUnlock()
+ device, ok := global.devices[id]
+ return device, ok
+}
diff --git a/server/server.go b/server/server.go
index 8d6350f33..ad2af568a 100644
--- a/server/server.go
+++ b/server/server.go
@@ -10,6 +10,7 @@ import (
"server/log"
"server/netbind"
+ "server/playback"
"server/settings"
"server/torr/utils"
"server/web"
@@ -17,6 +18,9 @@ import (
func Start() {
settings.InitSets(settings.Args.RDB, settings.Args.SearchWA)
+ if err := playback.Init(settings.Path, settings.ReadOnly); err != nil {
+ log.TLogln("Playback devices init failed:", err)
+ }
// https checks
if settings.Args.Ssl {
// set settings ssl enabled
diff --git a/server/web/api/playback.go b/server/web/api/playback.go
new file mode 100644
index 000000000..0ff1bf9da
--- /dev/null
+++ b/server/web/api/playback.go
@@ -0,0 +1,190 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ pathpkg "path"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "server/playback"
+)
+
+var playbackHashPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
+
+type playbackRequest struct {
+ DeviceID string `json:"device_id,omitempty"`
+ Path string `json:"path" binding:"required"`
+ Hash string `json:"hash" binding:"required"`
+ Index int `json:"index"`
+}
+
+func listPlaybackDevices(c *gin.Context) {
+ c.JSON(http.StatusOK, playback.GetPublicConfig())
+}
+
+func listPlaybackDeviceConfigs(c *gin.Context) {
+ c.JSON(http.StatusOK, playback.GetManagedConfig())
+}
+
+func updatePlaybackSettings(c *gin.Context) {
+ var input playback.Settings
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+ settings, err := playback.UpdateSettings(input)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+ c.JSON(http.StatusOK, settings)
+}
+
+func upsertPlaybackDevice(c *gin.Context) {
+ var input playback.DeviceInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+ device, err := playback.Upsert(input)
+ if err != nil {
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+ c.JSON(http.StatusOK, device)
+}
+
+func deletePlaybackDevice(c *gin.Context) {
+ if err := playback.Delete(c.Param("id")); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+func testPlaybackDevice(c *gin.Context) {
+ if err := playback.Test(c.Param("id")); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.AbortWithError(http.StatusBadGateway, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func playOnDevice(c *gin.Context) {
+ var req playbackRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+
+ fileName, err := validatePlaybackRequest(req)
+ if err != nil {
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+
+ deviceID, err := playback.ResolveTarget(req.DeviceID)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+
+ streamURL, err := playbackStreamURL(c, deviceID, fileName, req.Hash, req.Index)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.AbortWithError(http.StatusBadRequest, err)
+ return
+ }
+
+ if err := playback.Play(deviceID, playback.AgentPlayRequest{
+ Path: fileName,
+ Hash: strings.ToLower(req.Hash),
+ Index: req.Index,
+ StreamURL: streamURL,
+ }); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.AbortWithError(http.StatusBadGateway, err)
+ return
+ }
+
+ c.JSON(http.StatusAccepted, gin.H{"ok": true, "device_id": deviceID})
+}
+
+func validatePlaybackRequest(req playbackRequest) (string, error) {
+ if !playbackHashPattern.MatchString(req.Hash) {
+ return "", errors.New("invalid torrent hash")
+ }
+ if req.Index < 0 || req.Index > 100000 {
+ return "", errors.New("invalid file index")
+ }
+ fileName := pathpkg.Base(strings.ReplaceAll(strings.TrimSpace(req.Path), "\\", "/"))
+ if fileName == "" || fileName == "." || fileName == ".." || len(fileName) > 1024 {
+ return "", errors.New("invalid file path")
+ }
+ return fileName, nil
+}
+
+func playbackStreamURL(c *gin.Context, deviceID, fileName, hash string, index int) (string, error) {
+ device, ok := playback.GetPrivate(deviceID)
+ if !ok {
+ return "", os.ErrNotExist
+ }
+
+ baseURL := device.StreamBaseURL
+ if baseURL == "" {
+ scheme := forwardedScheme(c)
+ baseURL = scheme + "://" + c.Request.Host
+ }
+
+ parsed, err := url.Parse(baseURL)
+ if err != nil {
+ return "", err
+ }
+ parsed.Path = strings.TrimRight(parsed.Path, "/") + "/stream/" + fileName
+ query := parsed.Query()
+ query.Set("link", strings.ToLower(hash))
+ query.Set("index", strconv.Itoa(index))
+ query.Set("play", "")
+ parsed.RawQuery = query.Encode()
+ return parsed.String(), nil
+}
+
+func forwardedScheme(c *gin.Context) string {
+ if forwarded := c.GetHeader("X-Forwarded-Proto"); forwarded != "" {
+ if scheme := strings.TrimSpace(strings.Split(forwarded, ",")[0]); scheme == "http" || scheme == "https" {
+ return scheme
+ }
+ }
+ if c.Request.TLS != nil {
+ return "https"
+ }
+ return "http"
+}
diff --git a/server/web/api/playback_test.go b/server/web/api/playback_test.go
new file mode 100644
index 000000000..cd0354d16
--- /dev/null
+++ b/server/web/api/playback_test.go
@@ -0,0 +1,95 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ "server/playback"
+)
+
+func TestValidatePlaybackRequest(t *testing.T) {
+ validHash := strings.Repeat("a", 40)
+
+ fileName, err := validatePlaybackRequest(playbackRequest{
+ Path: `folder\Movie.mkv`,
+ Hash: validHash,
+ Index: 3,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fileName != "Movie.mkv" {
+ t.Fatalf("unexpected filename: %q", fileName)
+ }
+
+ invalid := []playbackRequest{
+ {Path: "Movie.mkv", Hash: "bad", Index: 1},
+ {Path: "Movie.mkv", Hash: validHash, Index: -1},
+ {Path: "Movie.mkv", Hash: validHash, Index: 100001},
+ {Path: "..", Hash: validHash, Index: 1},
+ }
+ for _, request := range invalid {
+ if _, err := validatePlaybackRequest(request); err == nil {
+ t.Fatalf("expected validation error for %#v", request)
+ }
+ }
+}
+
+func TestPlaybackStreamURLUsesForwardedRequestAddress(t *testing.T) {
+ if err := playback.Init(t.TempDir(), false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := playback.Upsert(playback.DeviceInput{
+ ID: "tv",
+ Name: "TV",
+ Endpoint: "http://127.0.0.1:9000",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ gin.SetMode(gin.TestMode)
+ request := httptest.NewRequest(http.MethodPost, "http://internal/playback/play", nil)
+ request.Host = "movies.example.com"
+ request.Header.Set("X-Forwarded-Proto", "https")
+ context, _ := gin.CreateTestContext(httptest.NewRecorder())
+ context.Request = request
+
+ streamURL, err := playbackStreamURL(context, "tv", "My Movie.mkv", strings.Repeat("b", 40), 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected := "https://movies.example.com/stream/My%20Movie.mkv?index=7&link=" + strings.Repeat("b", 40) + "&play="
+ if streamURL != expected {
+ t.Fatalf("unexpected stream URL:\nwant %s\n got %s", expected, streamURL)
+ }
+}
+
+func TestPlaybackStreamURLUsesDeviceOverride(t *testing.T) {
+ if err := playback.Init(t.TempDir(), false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := playback.Upsert(playback.DeviceInput{
+ ID: "tv",
+ Name: "TV",
+ Endpoint: "http://127.0.0.1:9000",
+ StreamBaseURL: "http://192.168.1.10:8090/torrserver",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ context, _ := gin.CreateTestContext(httptest.NewRecorder())
+ context.Request = httptest.NewRequest(http.MethodPost, "http://ignored/playback/play", nil)
+
+ streamURL, err := playbackStreamURL(context, "tv", "Movie.mkv", strings.Repeat("c", 40), 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected := "http://192.168.1.10:8090/torrserver/stream/Movie.mkv?index=1&link=" + strings.Repeat("c", 40) + "&play="
+ if streamURL != expected {
+ t.Fatalf("unexpected stream URL:\nwant %s\n got %s", expected, streamURL)
+ }
+}
diff --git a/server/web/api/route.go b/server/web/api/route.go
index 6acb071b6..12bd422c5 100644
--- a/server/web/api/route.go
+++ b/server/web/api/route.go
@@ -22,6 +22,14 @@ func SetupRoute(route gin.IRouter) {
authorized.POST("/waf", updateWAF)
authorized.POST("/torznab/test", torznabTest)
+ authorized.GET("/playback/devices", listPlaybackDevices)
+ authorized.GET("/playback/devices/config", listPlaybackDeviceConfigs)
+ authorized.POST("/playback/settings", updatePlaybackSettings)
+ authorized.POST("/playback/devices", upsertPlaybackDevice)
+ authorized.DELETE("/playback/devices/:id", deletePlaybackDevice)
+ authorized.POST("/playback/devices/:id/test", testPlaybackDevice)
+ authorized.POST("/playback/play", playOnDevice)
+
authorized.POST("/torrents", torrents)
authorized.POST("/torrent/upload", torrentUpload)
From 6453ebc300e57492302184139cff932fab4c9b26 Mon Sep 17 00:00:00 2001
From: Renat Sharipov <6128858+SHAREN@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:14:29 +0500
Subject: [PATCH 2/3] feat(web): select local or remote playback targets
---
README.md | 37 ++
web/src/components/App/index.jsx | 7 +-
.../Table/index.jsx | 56 ++-
.../Playback/PlaybackTargetSelector.jsx | 78 ++++
.../components/Settings/PlaybackSettings.jsx | 391 ++++++++++++++++++
.../components/Settings/SettingsDialog.jsx | 2 +
web/src/components/TorrentCard/index.jsx | 39 +-
web/src/index.jsx | 5 +-
web/src/locales/en/translation.json | 54 ++-
web/src/locales/ru/translation.json | 52 +++
web/src/playback/PlaybackContext.jsx | 165 ++++++++
web/src/utils/Hosts.js | 4 +
12 files changed, 868 insertions(+), 22 deletions(-)
create mode 100644 web/src/components/Playback/PlaybackTargetSelector.jsx
create mode 100644 web/src/components/Settings/PlaybackSettings.jsx
create mode 100644 web/src/playback/PlaybackContext.jsx
diff --git a/README.md b/README.md
index 91dc79e8c..f16d191d5 100644
--- a/README.md
+++ b/README.md
@@ -434,6 +434,43 @@ The users data file should be located near to the settings. Basic auth, read mor
Note: You should enable authentication with -a (--httpauth) TorrServer startup option.
+## Playback devices
+
+TorrServer can send a selected video to a playback agent running on another computer, TV box, or media server. This is useful when the Web UI is opened on a phone but VLC should start on the device connected to the TV.
+
+The feature is **disabled by default**. Until it is explicitly enabled, every browser opens VLC locally exactly as in previous TorrServer versions.
+
+Configure it in **Settings → Application → Remote playback**. Three routing modes are available:
+
+- **Each browser plays locally (legacy)** — preserves the previous behavior.
+- **Always use one primary device** — every browser sends playback to one configured TV/media device.
+- **Choose a device in each browser** — each browser remembers its own target. It may be marked **Control and playback** or **Control only**.
+
+Each remote device also has an independent **Open VLC in fullscreen** option. It is off by default, so adding a device does not change normal VLC window behavior unless the user asks for it.
+
+Device configuration is stored in the versioned `playback_devices.json` file next to the TorrServer settings. Agent URLs and bearer tokens stay on the server; the normal device list returned to browsers contains only device IDs and names. Enable TorrServer authentication when the Web UI is reachable by untrusted users.
+
+A playback agent implements two endpoints:
+
+```text
+GET /health
+POST /play
+```
+
+TorrServer sends the optional bearer token and the following JSON to `/play`:
+
+```json
+{
+ "path": "Movie.mkv",
+ "hash": "0123456789abcdef0123456789abcdef01234567",
+ "index": 1,
+ "stream_url": "http://torrserver:8090/stream/Movie.mkv?link=...&index=1&play=",
+ "fullscreen": false
+}
+```
+
+Use **TorrServer URL for this device** when the agent reaches TorrServer through a different hostname or address than the browser. A reference Linux/VLC agent is maintained as a separate optional component.
+
## Retrackers
When adding a torrent, TorrServer can modify announce trackers according to **Settings → Additional → Retrackers**:
diff --git a/web/src/components/App/index.jsx b/web/src/components/App/index.jsx
index d469ed8c5..0ef278708 100644
--- a/web/src/components/App/index.jsx
+++ b/web/src/components/App/index.jsx
@@ -26,6 +26,7 @@ import GlobalStyle from 'style/GlobalStyle'
import { /* lightTheme, */ THEME_MODES, useMaterialUITheme } from 'style/materialUISetup'
import getStyledComponentsTheme from 'style/getStyledComponentsTheme'
import SearchDialog from 'components/Search/SearchDialog'
+import PlaybackTargetSelector from 'components/Playback/PlaybackTargetSelector'
import { AppWrapper, AppHeader, HeaderToggle, StyledIconButton, SidebarOverlay } from './style'
import Sidebar from './Sidebar'
@@ -86,11 +87,13 @@ export default function App() {
+
+
{isStandaloneApp && (
setIsSearchDialogOpen(true)}>
diff --git a/web/src/components/DialogTorrentDetailsContent/Table/index.jsx b/web/src/components/DialogTorrentDetailsContent/Table/index.jsx
index db7fc0eec..8f4c97d3f 100644
--- a/web/src/components/DialogTorrentDetailsContent/Table/index.jsx
+++ b/web/src/components/DialogTorrentDetailsContent/Table/index.jsx
@@ -2,9 +2,11 @@ import { streamHost } from 'utils/Hosts'
import isEqual from 'lodash/isEqual'
import { humanizeSize, detectStandaloneApp, isMacOS, isAppleDevice } from 'utils/Utils'
import ptt from 'parse-torrent-title'
-import { Button } from '@material-ui/core'
+import { Button, CircularProgress, Snackbar } from '@material-ui/core'
import CopyToClipboard from 'react-copy-to-clipboard'
import { useTranslation } from 'react-i18next'
+import { usePlayback } from 'playback/PlaybackContext'
+import { useBooleanPlayerPreference } from 'utils/PlayerPreferences'
import {
gstreamerHeartbeatUrl,
gstreamerMasterUrl,
@@ -32,6 +34,9 @@ const Table = memo(
({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => {
const { t } = useTranslation()
const [unsupportedPlayers, setUnsupportedPlayers] = useState({})
+ const [vlcLaunchingFileId, setVlcLaunchingFileId] = useState(null)
+ const [vlcLaunchError, setVlcLaunchError] = useState(false)
+ const { launchVlc, selectedTarget } = usePlayback()
const gstRuntime = useGStreamerRuntime()
const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`)
const getFileLink = (path, id) =>
@@ -48,6 +53,17 @@ const Table = memo(
const markPlayerUnsupported = key => {
setUnsupportedPlayers(current => ({ ...current, [key]: true }))
}
+ const openFileInVlc = async (path, id, streamUrl) => {
+ if (!selectedTarget) return
+ setVlcLaunchingFileId(id)
+ try {
+ await launchVlc({ path, hash, index: id, streamUrl })
+ } catch (_) {
+ setVlcLaunchError(true)
+ } finally {
+ setVlcLaunchingFileId(null)
+ }
+ }
const fileHasEpisodeText = !!playableFileList?.find(({ path }) => ptt.parse(path).episode)
const fileHasSeasonText = !!playableFileList?.find(({ path }) => ptt.parse(path).season)
const fileHasResolutionText = !!playableFileList?.find(({ path }) => ptt.parse(path).resolution)
@@ -55,7 +71,7 @@ const Table = memo(
// if files in list is more then 1 and no season text detected by ptt.parse, show full name
const shouldDisplayFullFileName = playableFileList?.length > 1 && !fileHasEpisodeText
- const isVlcUsed = JSON.parse(localStorage.getItem('isVlcUsed')) ?? false
+ const isVlcUsed = useBooleanPlayerPreference('isVlcUsed')
const isInfuseUsed = JSON.parse(localStorage.getItem('isInfuseUsed')) ?? false
const isSenPlayerUsed = JSON.parse(localStorage.getItem('isSenPlayerUsed')) ?? false
const isIinaUsed = JSON.parse(localStorage.getItem('isIinaUsed')) ?? false
@@ -124,11 +140,16 @@ const Table = memo(
)}
{isVlcUsed && (
-
-
-
+
)}
{isMac && isIinaUsed && (
@@ -246,11 +267,16 @@ const Table = memo(
)}
{isVlcUsed && (
-
-
-
+
)}
{isMac && isIinaUsed && (
@@ -291,6 +317,12 @@ const Table = memo(
)
})}
+ setVlcLaunchError(false)}
+ message={t('Playback.LaunchFailed')}
+ />
>
)
},
diff --git a/web/src/components/Playback/PlaybackTargetSelector.jsx b/web/src/components/Playback/PlaybackTargetSelector.jsx
new file mode 100644
index 000000000..375f954f0
--- /dev/null
+++ b/web/src/components/Playback/PlaybackTargetSelector.jsx
@@ -0,0 +1,78 @@
+import { FormControl, MenuItem, Select, Tooltip, useMediaQuery } from '@material-ui/core'
+import { Tv as TvIcon } from '@material-ui/icons'
+import { useTranslation } from 'react-i18next'
+import { PLAYBACK_ROUTING_MODES, usePlayback } from 'playback/PlaybackContext'
+import { useBooleanPlayerPreference } from 'utils/PlayerPreferences'
+
+const PlaybackTargetSelector = () => {
+ const { t } = useTranslation()
+ const isCompact = useMediaQuery('(max-width:700px)')
+ const {
+ isLoadingDevices,
+ remotePlaybackEnabled,
+ routingMode,
+ selectedTarget,
+ setTargetId,
+ showTargetSelector,
+ targetId,
+ targets,
+ } = usePlayback()
+ const isVlcUsed = useBooleanPlayerPreference('isVlcUsed')
+
+ if (!isVlcUsed || !remotePlaybackEnabled || routingMode === PLAYBACK_ROUTING_MODES.LOCAL) return null
+
+ const label = selectedTarget?.name || t('Playback.NoDevice')
+ const labelContent = (
+
+
+
+ {label}
+
+
+ )
+
+ if (!showTargetSelector) {
+ return (
+
+ {labelContent}
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default PlaybackTargetSelector
diff --git a/web/src/components/Settings/PlaybackSettings.jsx b/web/src/components/Settings/PlaybackSettings.jsx
new file mode 100644
index 000000000..2b0a9b003
--- /dev/null
+++ b/web/src/components/Settings/PlaybackSettings.jsx
@@ -0,0 +1,391 @@
+import axios from 'axios'
+import {
+ Button,
+ Checkbox,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormControl,
+ FormControlLabel,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+ Snackbar,
+ Switch,
+ TextField,
+} from '@material-ui/core'
+import { useCallback, useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { playbackDeviceConfigsHost, playbackDevicesHost, playbackSettingsHost } from 'utils/Hosts'
+import { PLAYBACK_CLIENT_MODES, PLAYBACK_ROUTING_MODES, usePlayback } from 'playback/PlaybackContext'
+
+import { Divider, SecondarySettingsContent, SettingSectionLabel } from './style'
+
+const defaultServerSettings = {
+ enabled: false,
+ routing_mode: PLAYBACK_ROUTING_MODES.LOCAL,
+ primary_device_id: '',
+}
+
+const emptyForm = {
+ id: '',
+ name: '',
+ endpoint: '',
+ stream_base_url: '',
+ token: '',
+ clear_token: false,
+ fullscreen: false,
+ has_token: false,
+}
+
+const PlaybackSettings = () => {
+ const { t } = useTranslation()
+ const { mode, refreshPlaybackConfig, setMode } = usePlayback()
+ const [serverSettings, setServerSettings] = useState(defaultServerSettings)
+ const [configs, setConfigs] = useState([])
+ const [form, setForm] = useState(emptyForm)
+ const [isLoading, setIsLoading] = useState(true)
+ const [isSavingSettings, setIsSavingSettings] = useState(false)
+ const [isSavingDevice, setIsSavingDevice] = useState(false)
+ const [testingId, setTestingId] = useState('')
+ const [deleteCandidate, setDeleteCandidate] = useState(null)
+ const [message, setMessage] = useState('')
+ const [error, setError] = useState('')
+
+ const loadConfigs = useCallback(async () => {
+ setIsLoading(true)
+ try {
+ const { data } = await axios.get(playbackDeviceConfigsHost())
+ if (Array.isArray(data)) {
+ setServerSettings(defaultServerSettings)
+ setConfigs(data)
+ } else {
+ setServerSettings({
+ enabled: Boolean(data?.enabled),
+ routing_mode: data?.routing_mode || PLAYBACK_ROUTING_MODES.LOCAL,
+ primary_device_id: data?.primary_device_id || '',
+ })
+ setConfigs(Array.isArray(data?.devices) ? data.devices : [])
+ }
+ } catch (_) {
+ setError(t('Playback.LoadFailed'))
+ } finally {
+ setIsLoading(false)
+ }
+ }, [t])
+
+ useEffect(() => {
+ loadConfigs()
+ }, [loadConfigs])
+
+ const updateForm =
+ key =>
+ ({ target: { value, checked, type } }) => {
+ setForm(current => ({ ...current, [key]: type === 'checkbox' ? checked : value }))
+ }
+
+ const updateServerSetting =
+ key =>
+ ({ target: { value, checked, type } }) => {
+ setServerSettings(current => ({ ...current, [key]: type === 'checkbox' ? checked : value }))
+ }
+
+ const editDevice = device => {
+ setForm({
+ id: device.id,
+ name: device.name,
+ endpoint: device.endpoint,
+ stream_base_url: device.stream_base_url || '',
+ token: '',
+ clear_token: false,
+ fullscreen: Boolean(device.fullscreen),
+ has_token: Boolean(device.has_token),
+ })
+ }
+
+ const resetForm = () => setForm(emptyForm)
+
+ const saveRoutingSettings = async () => {
+ setIsSavingSettings(true)
+ try {
+ await axios.post(playbackSettingsHost(), serverSettings)
+ setMessage(t('Playback.SettingsSaved'))
+ await Promise.all([loadConfigs(), refreshPlaybackConfig()])
+ } catch (requestError) {
+ setError(requestError.response?.data?.error || t('Playback.SettingsSaveFailed'))
+ } finally {
+ setIsSavingSettings(false)
+ }
+ }
+
+ const saveDevice = async () => {
+ setIsSavingDevice(true)
+ try {
+ await axios.post(playbackDevicesHost(), {
+ id: form.id || undefined,
+ name: form.name,
+ endpoint: form.endpoint,
+ stream_base_url: form.stream_base_url,
+ token: form.token,
+ clear_token: form.clear_token,
+ fullscreen: form.fullscreen,
+ })
+ setMessage(t('Playback.DeviceSaved'))
+ resetForm()
+ await Promise.all([loadConfigs(), refreshPlaybackConfig()])
+ } catch (requestError) {
+ setError(requestError.response?.data?.error || t('Playback.SaveFailed'))
+ } finally {
+ setIsSavingDevice(false)
+ }
+ }
+
+ const deleteDevice = async () => {
+ if (!deleteCandidate) return
+ try {
+ await axios.delete(`${playbackDevicesHost()}/${encodeURIComponent(deleteCandidate.id)}`)
+ if (form.id === deleteCandidate.id) resetForm()
+ setMessage(t('Playback.DeviceDeleted'))
+ setDeleteCandidate(null)
+ await Promise.all([loadConfigs(), refreshPlaybackConfig()])
+ } catch (_) {
+ setError(t('Playback.DeleteFailed'))
+ }
+ }
+
+ const testDevice = async id => {
+ setTestingId(id)
+ try {
+ await axios.post(`${playbackDevicesHost()}/${encodeURIComponent(id)}/test`)
+ setMessage(t('Playback.TestSuccessful'))
+ } catch (_) {
+ setError(t('Playback.TestFailed'))
+ } finally {
+ setTestingId('')
+ }
+ }
+
+ const primaryMode = serverSettings.routing_mode === PLAYBACK_ROUTING_MODES.PRIMARY
+ const perBrowserMode = serverSettings.routing_mode === PLAYBACK_ROUTING_MODES.PER_BROWSER
+
+ return (
+
+ {t('Playback.RemotePlayback')}
+
+ }
+ label={t('Playback.EnableRemotePlayback')}
+ labelPlacement='start'
+ />
+ {t('Playback.EnableRemotePlaybackHint')}
+
+
+ {t('Playback.RoutingMode')}
+
+ {t('Playback.RoutingModeHint')}
+
+
+ {serverSettings.enabled && primaryMode && (
+
+ {t('Playback.PrimaryDevice')}
+
+ {t('Playback.PrimaryDeviceHint')}
+
+ )}
+
+ {serverSettings.enabled && perBrowserMode && (
+
+ {t('Playback.BrowserRole')}
+
+ {t('Playback.BrowserRoleHint')}
+
+ )}
+
+
+
+
+
+
+
+ {t('Playback.RemoteDevices')}
+ {t('Playback.RemoteDevicesHint')}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {configs.length === 0 &&
{t('Playback.NoRemoteDevices')}}
+ {configs.map(device => (
+
+
+
{device.name}
+
{device.endpoint}
+
+ {device.fullscreen ? t('Playback.FullscreenEnabled') : t('Playback.FullscreenDisabled')}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {form.id ? t('Playback.EditDevice') : t('Playback.AddDevice')}
+
+
+
+
+
+
}
+ label={t('Playback.OpenFullscreen')}
+ />
+
{t('Playback.OpenFullscreenHint')}
+ {form.id && form.has_token && (
+
}
+ label={t('Playback.ClearToken')}
+ />
+ )}
+
+ {form.id && (
+
+ )}
+
+
+
+
+
+
+ setMessage('')} message={message} />
+ setError('')} message={error} />
+
+ )
+}
+
+export default PlaybackSettings
diff --git a/web/src/components/Settings/SettingsDialog.jsx b/web/src/components/Settings/SettingsDialog.jsx
index 3b3e6a7c5..0e66c0425 100644
--- a/web/src/components/Settings/SettingsDialog.jsx
+++ b/web/src/components/Settings/SettingsDialog.jsx
@@ -23,6 +23,7 @@ import TorznabSettings from './TorznabSettings'
import TMDBSettings from './TMDBSettings'
import GStreamerSettings from './GStreamerSettings'
import WAFSettings from './WAFSettings'
+import PlaybackSettings from './PlaybackSettings'
export default function SettingsDialog({ handleClose }) {
const { t } = useTranslation()
@@ -256,6 +257,7 @@ export default function SettingsDialog({ handleClose }) {
isIinaUsed={isIinaUsed}
setIsIinaUsed={setIsIinaUsed}
/>
+
diff --git a/web/src/components/TorrentCard/index.jsx b/web/src/components/TorrentCard/index.jsx
index dc1b31640..328985048 100644
--- a/web/src/components/TorrentCard/index.jsx
+++ b/web/src/components/TorrentCard/index.jsx
@@ -21,6 +21,7 @@ import {
ListItemText,
Menu,
MenuItem,
+ Snackbar,
useMediaQuery,
useTheme,
} from '@material-ui/core'
@@ -31,6 +32,7 @@ import AddDialog from 'components/Add/AddDialog'
import { StyledDialog } from 'style/CustomMaterialUiStyles'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import { useBooleanPlayerPreference } from 'utils/PlayerPreferences'
+import { usePlayback } from 'playback/PlaybackContext'
import { GETTING_INFO, IN_DB, CLOSED, PRELOAD, WORKING } from 'torrentStates'
import { TORRENT_CATEGORIES } from 'components/categories'
import VideoPlayer from 'components/VideoPlayer'
@@ -159,8 +161,11 @@ const Torrent = ({ torrent }) => {
const [audioMenuAnchor, setAudioMenuAnchor] = useState(null)
const [audioMenuPlayer, setAudioMenuPlayer] = useState(null)
const [isResolvingAudio, setIsResolvingAudio] = useState(false)
+ const [isLaunchingVlc, setIsLaunchingVlc] = useState(false)
+ const [vlcLaunchError, setVlcLaunchError] = useState(false)
const isVlcUsed = useBooleanPlayerPreference('isVlcUsed')
const showQuickVlcButton = useBooleanPlayerPreference('showQuickVlcButton')
+ const { launchVlc, selectedTarget } = usePlayback()
const isMounted = useRef(true)
const episodeButtonRef = useRef(null)
const audioButtonRef = useRef(null)
@@ -257,10 +262,21 @@ const Torrent = ({ torrent }) => {
const singlePlayer = players.length === 1 ? players[0] : null
const audioMenuTracks = audioMenuPlayer ? audioTracksByFile[audioMenuPlayer.id] || [] : []
- const openSingleFileInVlc = () => {
- if (!singlePlayer) return
- const streamUrl = new URL(singlePlayer.downloadSrc, window.location.href)
- window.location.href = `vlc://${streamUrl}`
+ const openSingleFileInVlc = async () => {
+ if (!singlePlayer || !selectedTarget) return
+ setIsLaunchingVlc(true)
+ try {
+ await launchVlc({
+ path: singlePlayer.path,
+ hash,
+ index: singlePlayer.id,
+ streamUrl: singlePlayer.downloadSrc,
+ })
+ } catch (_) {
+ setVlcLaunchError(true)
+ } finally {
+ if (isMounted.current) setIsLaunchingVlc(false)
+ }
}
const playerWithAudio = (player, audio) => ({
@@ -386,8 +402,12 @@ const Torrent = ({ torrent }) => {
{isVlcUsed && showQuickVlcButton && singlePlayer && (
-
-
+
+ {isLaunchingVlc ? : }
VLC
)}
@@ -553,6 +573,13 @@ const Torrent = ({ torrent }) => {
+ setVlcLaunchError(false)}
+ message={t('Playback.LaunchFailed')}
+ />
+
-
+
+
+
,
document.getElementById('root'),
diff --git a/web/src/locales/en/translation.json b/web/src/locales/en/translation.json
index 17cc89c49..ecc3bf572 100644
--- a/web/src/locales/en/translation.json
+++ b/web/src/locales/en/translation.json
@@ -380,5 +380,57 @@
"Uncategorized": "Uncategorized",
"UploadFile": "Upload File",
"UploadSpeed": "Upload speed",
- "Viewed": "Viewed"
+ "Viewed": "Viewed",
+ "Playback": {
+ "AddDevice": "Add playback device",
+ "AgentToken": "Agent token",
+ "AgentTokenHint": "Optional bearer token shared with the playback agent.",
+ "AgentURL": "Agent URL",
+ "AgentURLHint": "Base URL of the playback agent. TorrServer calls /health and /play.",
+ "BrowserRole": "This browser",
+ "BrowserRoleHint": "Controller-only browsers send playback to another device. Controller and playback browsers may also open VLC locally.",
+ "ClearToken": "Remove saved token",
+ "ControlAndPlayback": "Control and playback",
+ "ControlOnly": "Control only",
+ "DeleteConfirm": "Delete “{{name}}”?",
+ "DeleteDevice": "Delete playback device",
+ "DeleteFailed": "Failed to delete playback device",
+ "DeviceDeleted": "Playback device deleted",
+ "DeviceName": "Device name",
+ "DeviceSaved": "Playback device saved",
+ "EditDevice": "Edit playback device",
+ "LaunchFailed": "Failed to start playback on the selected device",
+ "LoadFailed": "Failed to load playback devices",
+ "NoDevice": "No playback device",
+ "NoRemoteDevices": "No remote playback devices configured.",
+ "PlayOn": "Play on",
+ "RemoteDevices": "Remote playback devices",
+ "RemoteDevicesHint": "Devices are shared by all TorrServer users. Agent addresses and tokens stay on the server.",
+ "SaveFailed": "Failed to save playback device",
+ "StreamBaseURL": "TorrServer URL for this device",
+ "StreamBaseURLHint": "Leave empty when the agent can use the current TorrServer address. Set it when the player reaches TorrServer through another URL.",
+ "Test": "Test",
+ "TestFailed": "Playback device is unavailable",
+ "TestSuccessful": "Playback device is available",
+ "ThisDevice": "This device",
+ "TokenConfiguredHint": "A token is already saved. Leave this field empty to keep it.",
+ "RemotePlayback": "Remote playback",
+ "EnableRemotePlayback": "Enable playback devices",
+ "EnableRemotePlaybackHint": "Off by default. When disabled, every browser opens VLC on itself exactly as before.",
+ "RoutingMode": "Playback routing",
+ "RoutingLocal": "Each browser plays locally (legacy)",
+ "RoutingPrimary": "Always use one primary device",
+ "RoutingPerBrowser": "Choose a device in each browser",
+ "RoutingModeHint": "Choose the conservative local behavior, one shared TV device, or a separate target for each browser.",
+ "PrimaryDevice": "Primary playback device",
+ "PrimaryDeviceHint": "All browsers send VLC playback to this device. The target cannot be overridden from another browser.",
+ "PrimaryDeviceActive": "Primary playback device",
+ "SaveRouting": "Save playback mode",
+ "SettingsSaved": "Playback mode saved",
+ "SettingsSaveFailed": "Failed to save playback mode",
+ "OpenFullscreen": "Open VLC in fullscreen",
+ "OpenFullscreenHint": "Applies only to this remote player. Leave off to open VLC normally.",
+ "FullscreenEnabled": "Starts fullscreen",
+ "FullscreenDisabled": "Starts in a normal window"
+ }
}
diff --git a/web/src/locales/ru/translation.json b/web/src/locales/ru/translation.json
index 7540e11f0..c46a1c731 100644
--- a/web/src/locales/ru/translation.json
+++ b/web/src/locales/ru/translation.json
@@ -380,5 +380,57 @@
"Saved": "Списки сохранены и перезагружены",
"LoadFailed": "Не удалось загрузить списки доступа",
"SaveFailed": "Не удалось сохранить списки доступа"
+ },
+ "Playback": {
+ "AddDevice": "Добавить устройство воспроизведения",
+ "AgentToken": "Токен агента",
+ "AgentTokenHint": "Необязательный Bearer-токен, общий для TorrServer и агента.",
+ "AgentURL": "Адрес агента",
+ "AgentURLHint": "Базовый адрес агента. TorrServer обращается к /health и /play.",
+ "BrowserRole": "Этот браузер",
+ "BrowserRoleHint": "Браузер в режиме управления запускает фильм на другом устройстве. В режиме управления и просмотра VLC можно открыть и здесь.",
+ "ClearToken": "Удалить сохранённый токен",
+ "ControlAndPlayback": "Управление и просмотр",
+ "ControlOnly": "Только управление",
+ "DeleteConfirm": "Удалить устройство «{{name}}»?",
+ "DeleteDevice": "Удаление устройства воспроизведения",
+ "DeleteFailed": "Не удалось удалить устройство воспроизведения",
+ "DeviceDeleted": "Устройство воспроизведения удалено",
+ "DeviceName": "Название устройства",
+ "DeviceSaved": "Устройство воспроизведения сохранено",
+ "EditDevice": "Изменить устройство воспроизведения",
+ "LaunchFailed": "Не удалось запустить фильм на выбранном устройстве",
+ "LoadFailed": "Не удалось загрузить устройства воспроизведения",
+ "NoDevice": "Нет устройства воспроизведения",
+ "NoRemoteDevices": "Удалённые устройства пока не добавлены.",
+ "PlayOn": "Воспроизводить на",
+ "RemoteDevices": "Удалённые устройства воспроизведения",
+ "RemoteDevicesHint": "Список общий для всех пользователей TorrServer. Адреса агентов и токены остаются на сервере.",
+ "SaveFailed": "Не удалось сохранить устройство воспроизведения",
+ "StreamBaseURL": "Адрес TorrServer для этого устройства",
+ "StreamBaseURLHint": "Оставьте пустым, если агент может использовать текущий адрес TorrServer. Укажите другой адрес, если проигрыватель подключается к серверу иначе.",
+ "Test": "Проверить",
+ "TestFailed": "Устройство воспроизведения недоступно",
+ "TestSuccessful": "Устройство воспроизведения доступно",
+ "ThisDevice": "Это устройство",
+ "TokenConfiguredHint": "Токен уже сохранён. Оставьте поле пустым, чтобы не менять его.",
+ "RemotePlayback": "Удалённое воспроизведение",
+ "EnableRemotePlayback": "Включить устройства воспроизведения",
+ "EnableRemotePlaybackHint": "По умолчанию выключено. Пока функция выключена, каждый браузер открывает VLC у себя — как раньше.",
+ "RoutingMode": "Куда запускать VLC",
+ "RoutingLocal": "Каждый браузер открывает у себя (как раньше)",
+ "RoutingPrimary": "Всегда открывать на одном основном устройстве",
+ "RoutingPerBrowser": "Выбирать устройство в каждом браузере",
+ "RoutingModeHint": "Можно сохранить старое поведение, назначить один телевизор или выбирать цель отдельно на каждом телефоне и компьютере.",
+ "PrimaryDevice": "Основное устройство воспроизведения",
+ "PrimaryDeviceHint": "Все браузеры будут запускать VLC на этом устройстве. Из другого браузера цель изменить нельзя.",
+ "PrimaryDeviceActive": "Используется основное устройство воспроизведения",
+ "SaveRouting": "Сохранить режим воспроизведения",
+ "SettingsSaved": "Режим воспроизведения сохранён",
+ "SettingsSaveFailed": "Не удалось сохранить режим воспроизведения",
+ "OpenFullscreen": "Открывать VLC во весь экран",
+ "OpenFullscreenHint": "Настройка относится только к этому удалённому проигрывателю. Выключите, чтобы VLC открывался как обычно.",
+ "FullscreenEnabled": "Запуск во весь экран",
+ "FullscreenDisabled": "Запуск в обычном окне"
}
}
diff --git a/web/src/playback/PlaybackContext.jsx b/web/src/playback/PlaybackContext.jsx
new file mode 100644
index 000000000..25abd1c87
--- /dev/null
+++ b/web/src/playback/PlaybackContext.jsx
@@ -0,0 +1,165 @@
+import axios from 'axios'
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { playbackDevicesHost, playbackPlayHost } from 'utils/Hosts'
+
+export const LOCAL_PLAYBACK_DEVICE_ID = 'this-device'
+export const PLAYBACK_CLIENT_MODES = {
+ CONTROL_ONLY: 'control-only',
+ CONTROL_AND_PLAYBACK: 'control-and-playback',
+}
+export const PLAYBACK_ROUTING_MODES = {
+ LOCAL: 'local',
+ PRIMARY: 'primary',
+ PER_BROWSER: 'per-browser',
+}
+
+const MODE_STORAGE_KEY = 'playbackClientMode'
+const TARGET_STORAGE_KEY = 'playbackTargetId'
+
+const PlaybackContext = createContext(null)
+
+const defaultConfig = {
+ enabled: false,
+ routing_mode: PLAYBACK_ROUTING_MODES.LOCAL,
+ primary_device_id: '',
+ devices: [],
+}
+
+const storedMode = () => {
+ const value = localStorage.getItem(MODE_STORAGE_KEY)
+ return Object.values(PLAYBACK_CLIENT_MODES).includes(value) ? value : PLAYBACK_CLIENT_MODES.CONTROL_AND_PLAYBACK
+}
+
+const normalizeConfig = data => {
+ if (Array.isArray(data)) return { ...defaultConfig, devices: data }
+ return {
+ ...defaultConfig,
+ ...(data || {}),
+ devices: Array.isArray(data?.devices) ? data.devices : [],
+ }
+}
+
+export const PlaybackProvider = ({ children }) => {
+ const { t } = useTranslation()
+ const [config, setConfig] = useState(defaultConfig)
+ const [isLoadingDevices, setIsLoadingDevices] = useState(true)
+ const [mode, setModeState] = useState(storedMode)
+ const [targetId, setTargetIdState] = useState(localStorage.getItem(TARGET_STORAGE_KEY) || '')
+
+ const refreshPlaybackConfig = useCallback(async () => {
+ setIsLoadingDevices(true)
+ try {
+ const { data } = await axios.get(playbackDevicesHost())
+ setConfig(normalizeConfig(data))
+ } finally {
+ setIsLoadingDevices(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ refreshPlaybackConfig().catch(() => setConfig(defaultConfig))
+ }, [refreshPlaybackConfig])
+
+ const localTarget = useMemo(
+ () => ({ id: LOCAL_PLAYBACK_DEVICE_ID, name: t('Playback.ThisDevice'), isLocal: true }),
+ [t],
+ )
+ const remoteTargets = useMemo(() => config.devices.map(device => ({ ...device, isLocal: false })), [config.devices])
+
+ const targets = useMemo(() => {
+ if (!config.enabled || config.routing_mode === PLAYBACK_ROUTING_MODES.LOCAL) return [localTarget]
+ if (config.routing_mode === PLAYBACK_ROUTING_MODES.PRIMARY) {
+ const primary = remoteTargets.find(device => device.id === config.primary_device_id)
+ return primary ? [primary] : []
+ }
+ if (mode === PLAYBACK_CLIENT_MODES.CONTROL_ONLY) return remoteTargets
+ return [localTarget, ...remoteTargets]
+ }, [config.enabled, config.primary_device_id, config.routing_mode, localTarget, mode, remoteTargets])
+
+ useEffect(() => {
+ if (config.routing_mode !== PLAYBACK_ROUTING_MODES.PER_BROWSER || !config.enabled) return
+ if (targets.some(target => target.id === targetId)) return
+ const fallback = targets[0]?.id || ''
+ setTargetIdState(fallback)
+ if (fallback) localStorage.setItem(TARGET_STORAGE_KEY, fallback)
+ else localStorage.removeItem(TARGET_STORAGE_KEY)
+ }, [config.enabled, config.routing_mode, targetId, targets])
+
+ const setMode = useCallback(value => {
+ if (!Object.values(PLAYBACK_CLIENT_MODES).includes(value)) return
+ setModeState(value)
+ localStorage.setItem(MODE_STORAGE_KEY, value)
+ }, [])
+
+ const setTargetId = useCallback(value => {
+ setTargetIdState(value)
+ if (value) localStorage.setItem(TARGET_STORAGE_KEY, value)
+ else localStorage.removeItem(TARGET_STORAGE_KEY)
+ }, [])
+
+ const selectedTarget = useMemo(() => {
+ if (!config.enabled || config.routing_mode === PLAYBACK_ROUTING_MODES.LOCAL) return localTarget
+ if (config.routing_mode === PLAYBACK_ROUTING_MODES.PRIMARY) return targets[0] || null
+ return targets.find(target => target.id === targetId) || null
+ }, [config.enabled, config.routing_mode, localTarget, targetId, targets])
+
+ const launchVlc = useCallback(
+ async ({ path, hash, index, streamUrl }) => {
+ if (!selectedTarget) throw new Error('playback target is not selected')
+
+ if (selectedTarget.isLocal) {
+ const absoluteStreamUrl = new URL(streamUrl, window.location.href)
+ window.location.href = `vlc://${absoluteStreamUrl}`
+ return
+ }
+
+ await axios.post(playbackPlayHost(), {
+ device_id: selectedTarget.id,
+ path,
+ hash,
+ index,
+ })
+ },
+ [selectedTarget],
+ )
+
+ const value = useMemo(
+ () => ({
+ config,
+ devices: config.devices,
+ isLoadingDevices,
+ launchVlc,
+ mode,
+ refreshPlaybackConfig,
+ remotePlaybackEnabled: config.enabled,
+ routingMode: config.routing_mode,
+ selectedTarget,
+ setMode,
+ setTargetId,
+ showTargetSelector: config.enabled && config.routing_mode === PLAYBACK_ROUTING_MODES.PER_BROWSER,
+ targetId,
+ targets,
+ }),
+ [
+ config,
+ isLoadingDevices,
+ launchVlc,
+ mode,
+ refreshPlaybackConfig,
+ selectedTarget,
+ setMode,
+ setTargetId,
+ targetId,
+ targets,
+ ],
+ )
+
+ return {children}
+}
+
+export const usePlayback = () => {
+ const context = useContext(PlaybackContext)
+ if (!context) throw new Error('usePlayback must be used inside PlaybackProvider')
+ return context
+}
diff --git a/web/src/utils/Hosts.js b/web/src/utils/Hosts.js
index b545b959a..8abf09175 100644
--- a/web/src/utils/Hosts.js
+++ b/web/src/utils/Hosts.js
@@ -17,6 +17,10 @@ export const torznabTestHost = () => `${torrserverHost}/torznab/test`
export const tmdbSettingsHost = () => `${torrserverHost}/tmdb/settings`
export const gstSettingsHost = () => `${torrserverHost}/gst/settings`
export const wafHost = () => `${torrserverHost}/waf`
+export const playbackDevicesHost = () => `${torrserverHost}/playback/devices`
+export const playbackDeviceConfigsHost = () => `${torrserverHost}/playback/devices/config`
+export const playbackSettingsHost = () => `${torrserverHost}/playback/settings`
+export const playbackPlayHost = () => `${torrserverHost}/playback/play`
export const getTorrServerHost = () => torrserverHost
export const setTorrServerHost = host => {
From d805f1df20dd0418b1d2dfc75e3742004101125e Mon Sep 17 00:00:00 2001
From: Renat Sharipov <6128858+SHAREN@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:14:30 +0500
Subject: [PATCH 3/3] feat(extras): add Linux VLC playback agent
---
README.md | 2 +-
extras/vlc-agent/.gitignore | 2 +
extras/vlc-agent/README.md | 127 +++++++
extras/vlc-agent/install-user-service.sh | 81 ++++
extras/vlc-agent/test_agent.py | 184 +++++++++
extras/vlc-agent/torrserver-vlc-agent.service | 18 +
extras/vlc-agent/torrserver_vlc_agent.py | 348 ++++++++++++++++++
extras/vlc-agent/vlc-agent.env.example | 14 +
8 files changed, 775 insertions(+), 1 deletion(-)
create mode 100644 extras/vlc-agent/.gitignore
create mode 100644 extras/vlc-agent/README.md
create mode 100755 extras/vlc-agent/install-user-service.sh
create mode 100644 extras/vlc-agent/test_agent.py
create mode 100644 extras/vlc-agent/torrserver-vlc-agent.service
create mode 100755 extras/vlc-agent/torrserver_vlc_agent.py
create mode 100644 extras/vlc-agent/vlc-agent.env.example
diff --git a/README.md b/README.md
index f16d191d5..ddb8260c2 100644
--- a/README.md
+++ b/README.md
@@ -469,7 +469,7 @@ TorrServer sends the optional bearer token and the following JSON to `/play`:
}
```
-Use **TorrServer URL for this device** when the agent reaches TorrServer through a different hostname or address than the browser. A reference Linux/VLC agent is maintained as a separate optional component.
+Use **TorrServer URL for this device** when the agent reaches TorrServer through a different hostname or address than the browser. A reference [Linux/VLC playback agent](extras/vlc-agent/README.md) is included as an optional component.
## Retrackers
diff --git a/extras/vlc-agent/.gitignore b/extras/vlc-agent/.gitignore
new file mode 100644
index 000000000..7a60b85e1
--- /dev/null
+++ b/extras/vlc-agent/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/extras/vlc-agent/README.md b/extras/vlc-agent/README.md
new file mode 100644
index 000000000..b592eac4c
--- /dev/null
+++ b/extras/vlc-agent/README.md
@@ -0,0 +1,127 @@
+# TorrServer VLC agent for Linux
+
+This optional agent lets TorrServer start VLC on a Linux computer connected to a TV while the Web UI is controlled from a phone or another computer.
+
+The agent is intentionally small:
+
+- Python standard library only;
+- opens VLC in a normal window or fullscreen according to the selected device setting in TorrServer;
+- replaces only the VLC process started by this agent;
+- supports a bearer token;
+- can restrict accepted TorrServer hostnames;
+- validates the torrent hash, file index, filename, and stream URL before launching VLC;
+- never executes a shell command.
+
+## Requirements
+
+- Linux with a graphical desktop;
+- Python 3.10 or newer;
+- VLC;
+- systemd user services for the installer below.
+
+## Install
+
+Run this as the desktop user who should own the VLC window:
+
+```bash
+cd extras/vlc-agent
+./install-user-service.sh --listen-lan --allowed-host 192.168.1.10
+```
+
+Replace `192.168.1.10` with the hostname or IP address used in TorrServer stream URLs. Repeat `--allowed-host` when the same TorrServer is reached by several names.
+
+The installer:
+
+- copies the agent to `~/.local/lib/torrserver-vlc-agent/`;
+- installs a user service in `~/.config/systemd/user/`;
+- creates `~/.config/torrserver-vlc-agent.env` with mode `0600`;
+- generates a random bearer token;
+- enables and starts the service.
+
+Read the generated token from the environment file and register the device in **TorrServer → Settings → Application → Playback devices**:
+
+```text
+Name: Living room TV
+Agent URL: http://PLAYER_IP:8092
+Agent token: value from VLC_AGENT_TOKEN
+TorrServer URL for this device: http://TORRSERVER_IP:8090
+```
+
+The last field is optional. Set it when the player reaches TorrServer through a different address than the browser, for example when the browser uses a public HTTPS name but the TV computer should stream over the LAN.
+
+## Configuration
+
+Edit:
+
+```text
+~/.config/torrserver-vlc-agent.env
+```
+
+Then restart:
+
+```bash
+systemctl --user restart torrserver-vlc-agent.service
+```
+
+Useful options:
+
+```text
+VLC_AGENT_HOST=0.0.0.0
+VLC_AGENT_PORT=8092
+VLC_AGENT_ALLOWED_HOSTS=192.168.1.10,movies.example.net
+VLC_AGENT_PLAYER=/usr/bin/vlc
+VLC_AGENT_PLAYER_ARGS="--no-one-instance --no-video-title-show --network-caching=3000 --http-reconnect"
+```
+
+TorrServer sends the device's **Open VLC in fullscreen** checkbox with every play request. The agent appends either `--fullscreen` or `--no-fullscreen` after the configured arguments, so the checkbox has an unambiguous result and remains off by default.
+
+Additional VLC flags may be appended to `VLC_AGENT_PLAYER_ARGS`. Examples:
+
+- `--qt-dark-palette` for VLC builds that support the dark Qt palette;
+- `--aout=pulse --no-spdif --stereo-mode=1` when HDMI passthrough causes audio problems.
+
+## Security
+
+The default listener is `127.0.0.1`. Direct LAN access requires `VLC_AGENT_HOST=0.0.0.0` or `--listen-lan`.
+
+A non-loopback listener refuses to start without a bearer token unless `--allow-unauthenticated-network` is explicitly used. That override is intended only for isolated test networks.
+
+Also restrict TCP port `8092` with the host firewall so only the TorrServer machine can reach it. `VLC_AGENT_ALLOWED_HOSTS` limits the hostname accepted inside `stream_url`; it does not replace a firewall or bearer token.
+
+## Desktop session troubleshooting
+
+The service runs as a systemd **user** service so VLC can connect to that user's display and audio session. On desktops that do not import graphical variables into the user manager, run this once from a terminal inside the desktop session:
+
+```bash
+systemctl --user import-environment DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS PULSE_SERVER
+systemctl --user restart torrserver-vlc-agent.service
+```
+
+Logs and status:
+
+```bash
+systemctl --user status torrserver-vlc-agent.service
+journalctl --user -u torrserver-vlc-agent.service -f
+```
+
+Health check:
+
+```bash
+curl -H "Authorization: Bearer TOKEN_FROM_ENV_FILE" http://127.0.0.1:8092/health
+```
+
+## Run without installing
+
+The agent can be started directly:
+
+```bash
+VLC_AGENT_TOKEN="TOKEN" \
+VLC_AGENT_ALLOWED_HOSTS="192.168.1.10" \
+python3 torrserver_vlc_agent.py --host 0.0.0.0
+```
+
+## Tests
+
+```bash
+python3 -m unittest -v test_agent.py
+```
diff --git a/extras/vlc-agent/install-user-service.sh b/extras/vlc-agent/install-user-service.sh
new file mode 100755
index 000000000..453acbd29
--- /dev/null
+++ b/extras/vlc-agent/install-user-service.sh
@@ -0,0 +1,81 @@
+#!/bin/sh
+set -eu
+
+listen_host=127.0.0.1
+allowed_hosts=
+
+usage() {
+ cat <<'EOF'
+Usage: ./install-user-service.sh [--listen-lan] [--allowed-host HOST]
+
+ --listen-lan listen on 0.0.0.0 instead of loopback
+ --allowed-host HOST allow stream URLs from this TorrServer host; repeatable
+EOF
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --listen-lan)
+ listen_host=0.0.0.0
+ shift
+ ;;
+ --allowed-host)
+ [ "$#" -ge 2 ] || { echo "--allowed-host requires a value" >&2; exit 2; }
+ if [ -n "$allowed_hosts" ]; then
+ allowed_hosts="$allowed_hosts,$2"
+ else
+ allowed_hosts=$2
+ fi
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; }
+player=$(command -v vlc || true)
+[ -n "$player" ] || { echo "VLC is required" >&2; exit 1; }
+command -v systemctl >/dev/null 2>&1 || { echo "systemd is required" >&2; exit 1; }
+
+source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+agent_dir="$HOME/.local/lib/torrserver-vlc-agent"
+unit_dir="$HOME/.config/systemd/user"
+env_file="$HOME/.config/torrserver-vlc-agent.env"
+unit_file="$unit_dir/torrserver-vlc-agent.service"
+
+install -d -m 0755 "$agent_dir" "$unit_dir"
+install -m 0755 "$source_dir/torrserver_vlc_agent.py" "$agent_dir/torrserver_vlc_agent.py"
+install -m 0644 "$source_dir/torrserver-vlc-agent.service" "$unit_file"
+
+if [ ! -e "$env_file" ]; then
+ token=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
+ umask 077
+ cat >"$env_file" < None:
+ self.calls: list[tuple[str, bool]] = []
+
+ def launch(self, stream_url: str, fullscreen: bool) -> None:
+ self.calls.append((stream_url, fullscreen))
+
+ def is_running(self) -> bool:
+ return bool(self.calls)
+
+
+class AgentServerTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.player = RecordingPlayer()
+ self.config = Config(
+ host="127.0.0.1",
+ port=0,
+ token=TOKEN,
+ allowed_hosts=frozenset({"movies.local"}),
+ player="vlc",
+ player_args=(),
+ stop_timeout=1,
+ )
+ self.server = AgentHTTPServer((self.config.host, 0), self.config, self.player) # type: ignore[arg-type]
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+ self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}"
+
+ def tearDown(self) -> None:
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=2)
+
+ def request(self, path: str, *, method: str = "GET", payload: dict | None = None, token: str | None = None):
+ data = None
+ headers = {}
+ if payload is not None:
+ data = json.dumps(payload).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+ if token is not None:
+ headers["Authorization"] = f"Bearer {token}"
+ request = Request(self.base_url + path, data=data, headers=headers, method=method)
+ return urlopen(request, timeout=3)
+
+ def test_health_requires_configured_token(self) -> None:
+ with self.assertRaises(HTTPError) as unauthorized:
+ self.request("/health")
+ self.assertEqual(unauthorized.exception.code, 401)
+
+ with self.request("/health", token=TOKEN) as response:
+ self.assertEqual(response.status, 200)
+ self.assertFalse(json.load(response)["player_running"])
+
+ def test_valid_play_request_starts_player(self) -> None:
+ payload = {
+ "path": "Movie.mkv",
+ "hash": HASH,
+ "index": 1,
+ "stream_url": STREAM_URL,
+ "fullscreen": True,
+ }
+ with self.request("/play", method="POST", payload=payload, token=TOKEN) as response:
+ self.assertEqual(response.status, 202)
+ self.assertEqual(self.player.calls, [(STREAM_URL, True)])
+
+ def test_disallowed_stream_host_is_rejected(self) -> None:
+ payload = {
+ "path": "Movie.mkv",
+ "hash": HASH,
+ "index": 1,
+ "stream_url": STREAM_URL.replace("movies.local", "other.local"),
+ }
+ with self.assertRaises(HTTPError) as rejected:
+ self.request("/play", method="POST", payload=payload, token=TOKEN)
+ self.assertEqual(rejected.exception.code, 400)
+ self.assertEqual(self.player.calls, [])
+
+
+class ValidationTest(unittest.TestCase):
+ def test_request_fields_must_match_stream_url(self) -> None:
+ request = validate_play_request(
+ {"path": "folder/Movie.mkv", "hash": HASH.upper(), "index": 1, "stream_url": STREAM_URL},
+ frozenset({"movies.local"}),
+ )
+ self.assertEqual(request.path, "Movie.mkv")
+ self.assertEqual(request.hash, HASH)
+ self.assertFalse(request.fullscreen)
+
+ fullscreen_request = validate_play_request(
+ {
+ "path": "Movie.mkv",
+ "hash": HASH,
+ "index": 1,
+ "stream_url": STREAM_URL,
+ "fullscreen": True,
+ },
+ frozenset({"movies.local"}),
+ )
+ self.assertTrue(fullscreen_request.fullscreen)
+
+ invalid_payloads = [
+ {"path": "Movie.mkv", "hash": "bad", "index": 1, "stream_url": STREAM_URL},
+ {"path": "Other.mkv", "hash": HASH, "index": 1, "stream_url": STREAM_URL},
+ {"path": "Movie.mkv", "hash": HASH, "index": 2, "stream_url": STREAM_URL},
+ {"path": "Movie.mkv", "hash": HASH, "index": 1, "stream_url": STREAM_URL, "fullscreen": "yes"},
+ {
+ "path": "Movie.mkv",
+ "hash": HASH,
+ "index": 1,
+ "stream_url": STREAM_URL.replace("play=", "missing="),
+ },
+ ]
+ for payload in invalid_payloads:
+ with self.subTest(payload=payload), self.assertRaises(ValueError):
+ validate_play_request(payload, frozenset({"movies.local"}))
+
+ def test_network_listener_requires_token_by_default(self) -> None:
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ parse_config(["--host", "0.0.0.0"])
+
+ config = parse_config(["--host", "0.0.0.0", "--token", TOKEN])
+ self.assertEqual(config.token, TOKEN)
+
+
+class PlayerManagerTest(unittest.TestCase):
+ @patch("torrserver_vlc_agent.subprocess.Popen")
+ def test_manager_appends_requested_window_mode(self, popen: Mock) -> None:
+ process = Mock()
+ process.poll.return_value = 0
+ popen.return_value = process
+ manager = PlayerManager("vlc", ("--no-one-instance", "--fullscreen"), stop_timeout=1)
+
+ manager.launch("http://example/windowed", False)
+ command = popen.call_args.args[0]
+ self.assertEqual(command[-2:], ["--no-fullscreen", "http://example/windowed"])
+
+ manager.launch("http://example/fullscreen", True)
+ command = popen.call_args.args[0]
+ self.assertEqual(command[-2:], ["--fullscreen", "http://example/fullscreen"])
+
+ def test_manager_replaces_only_its_own_process(self) -> None:
+ manager = PlayerManager(
+ sys.executable,
+ ("-c", "import time; time.sleep(30)"),
+ stop_timeout=1,
+ )
+ try:
+ manager.launch("http://example/first", False)
+ first = manager._process # noqa: SLF001 - intentional white-box lifecycle test
+ self.assertIsNotNone(first)
+ self.assertTrue(manager.is_running())
+
+ manager.launch("http://example/second", True)
+ second = manager._process # noqa: SLF001
+ self.assertIsNotNone(second)
+ self.assertNotEqual(first.pid, second.pid)
+ self.assertIsNotNone(first.poll())
+ self.assertTrue(manager.is_running())
+ finally:
+ manager.stop()
+ self.assertFalse(manager.is_running())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/extras/vlc-agent/torrserver-vlc-agent.service b/extras/vlc-agent/torrserver-vlc-agent.service
new file mode 100644
index 000000000..6faf23696
--- /dev/null
+++ b/extras/vlc-agent/torrserver-vlc-agent.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=TorrServer VLC playback agent
+Documentation=https://github.com/YouROK/TorrServer
+After=network-online.target graphical-session.target
+Wants=network-online.target
+PartOf=graphical-session.target
+
+[Service]
+Type=simple
+EnvironmentFile=%h/.config/torrserver-vlc-agent.env
+ExecStart=/usr/bin/python3 %h/.local/lib/torrserver-vlc-agent/torrserver_vlc_agent.py
+Restart=on-failure
+RestartSec=2
+KillMode=control-group
+NoNewPrivileges=true
+
+[Install]
+WantedBy=default.target
diff --git a/extras/vlc-agent/torrserver_vlc_agent.py b/extras/vlc-agent/torrserver_vlc_agent.py
new file mode 100755
index 000000000..b2718f88f
--- /dev/null
+++ b/extras/vlc-agent/torrserver_vlc_agent.py
@@ -0,0 +1,348 @@
+#!/usr/bin/env python3
+"""Small HTTP agent that opens TorrServer streams in VLC on this Linux desktop."""
+
+from __future__ import annotations
+
+import argparse
+import hmac
+import ipaddress
+import json
+import logging
+import os
+import re
+import shlex
+import signal
+import subprocess
+import threading
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import PurePath
+from typing import Any, Iterable
+from urllib.parse import parse_qs, unquote, urlparse
+
+MAX_BODY_BYTES = 16 * 1024
+MAX_STREAM_URL_LENGTH = 8192
+HASH_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
+DEFAULT_PLAYER_ARGS = (
+ "--no-one-instance",
+ "--no-video-title-show",
+ "--network-caching=3000",
+ "--http-reconnect",
+)
+
+
+@dataclass(frozen=True)
+class Config:
+ host: str
+ port: int
+ token: str
+ allowed_hosts: frozenset[str]
+ player: str
+ player_args: tuple[str, ...]
+ stop_timeout: float
+
+
+@dataclass(frozen=True)
+class PlayRequest:
+ path: str
+ hash: str
+ index: int
+ stream_url: str
+ fullscreen: bool
+
+
+class PlayerManager:
+ """Owns one player process and never touches unrelated VLC instances."""
+
+ def __init__(self, player: str, player_args: Iterable[str], stop_timeout: float) -> None:
+ self._player = player
+ self._player_args = tuple(player_args)
+ self._stop_timeout = stop_timeout
+ self._process: subprocess.Popen[bytes] | None = None
+ self._lock = threading.Lock()
+
+ def launch(self, stream_url: str, fullscreen: bool) -> None:
+ with self._lock:
+ self._stop_locked()
+ window_mode = "--fullscreen" if fullscreen else "--no-fullscreen"
+ command = [self._player, *self._player_args, window_mode, stream_url]
+ logging.info("Starting player: %s", " ".join(shlex.quote(value) for value in command))
+ self._process = subprocess.Popen(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=None,
+ start_new_session=True,
+ close_fds=True,
+ )
+
+ def stop(self) -> None:
+ with self._lock:
+ self._stop_locked()
+
+ def is_running(self) -> bool:
+ with self._lock:
+ return self._process is not None and self._process.poll() is None
+
+ def _stop_locked(self) -> None:
+ process = self._process
+ self._process = None
+ if process is None or process.poll() is not None:
+ return
+ logging.info("Stopping previous player process group %s", process.pid)
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ try:
+ process.wait(timeout=self._stop_timeout)
+ except subprocess.TimeoutExpired:
+ logging.warning("Player did not stop in %.1fs; killing it", self._stop_timeout)
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait(timeout=self._stop_timeout)
+
+
+class AgentHTTPServer(ThreadingHTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+ def __init__(self, address: tuple[str, int], config: Config, player: PlayerManager) -> None:
+ super().__init__(address, AgentHandler)
+ self.config = config
+ self.player = player
+
+
+class AgentHandler(BaseHTTPRequestHandler):
+ server: AgentHTTPServer
+ server_version = "TorrServerVLCAgent/1.0"
+
+ def do_GET(self) -> None: # noqa: N802
+ if self.path != "/health":
+ self._json(404, {"ok": False, "error": "not found"})
+ return
+ if not self._authorized():
+ self._unauthorized()
+ return
+ self._json(200, {"ok": True, "player_running": self.server.player.is_running()})
+
+ def do_POST(self) -> None: # noqa: N802
+ if self.path != "/play":
+ self._json(404, {"ok": False, "error": "not found"})
+ return
+ if not self._authorized():
+ self._unauthorized()
+ return
+
+ try:
+ payload = self._read_json()
+ request = validate_play_request(payload, self.server.config.allowed_hosts)
+ self.server.player.launch(request.stream_url, request.fullscreen)
+ except ValueError as error:
+ self._json(400, {"ok": False, "error": str(error)})
+ return
+ except OSError as error:
+ logging.exception("Failed to start player")
+ self._json(500, {"ok": False, "error": f"failed to start player: {error}"})
+ return
+
+ self._json(202, {"ok": True})
+
+ def _authorized(self) -> bool:
+ expected = self.server.config.token
+ if not expected:
+ return True
+ header = self.headers.get("Authorization", "")
+ prefix = "Bearer "
+ if not header.startswith(prefix):
+ return False
+ return hmac.compare_digest(header[len(prefix) :].encode(), expected.encode())
+
+ def _unauthorized(self) -> None:
+ body = json.dumps({"ok": False, "error": "unauthorized"}).encode("utf-8")
+ self.send_response(401)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("WWW-Authenticate", "Bearer")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _read_json(self) -> dict[str, Any]:
+ content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
+ if content_type != "application/json":
+ raise ValueError("content type must be application/json")
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ except ValueError as error:
+ raise ValueError("invalid content length") from error
+ if length <= 0 or length > MAX_BODY_BYTES:
+ raise ValueError("invalid request body size")
+ try:
+ payload = json.loads(self.rfile.read(length))
+ except json.JSONDecodeError as error:
+ raise ValueError("invalid JSON") from error
+ if not isinstance(payload, dict):
+ raise ValueError("request body must be a JSON object")
+ return payload
+
+ def _json(self, status: int, payload: dict[str, Any]) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, fmt: str, *args: object) -> None:
+ logging.info("%s - %s", self.client_address[0], fmt % args)
+
+
+def validate_play_request(payload: dict[str, Any], allowed_hosts: frozenset[str]) -> PlayRequest:
+ raw_path = payload.get("path")
+ raw_hash = payload.get("hash")
+ raw_index = payload.get("index")
+ raw_stream_url = payload.get("stream_url")
+ raw_fullscreen = payload.get("fullscreen", False)
+
+ if not isinstance(raw_path, str) or not raw_path.strip():
+ raise ValueError("path is required")
+ file_name = PurePath(raw_path.replace("\\", "/")).name
+ if not file_name or file_name in {".", ".."} or len(file_name) > 1024:
+ raise ValueError("invalid path")
+
+ if not isinstance(raw_hash, str) or not HASH_PATTERN.fullmatch(raw_hash):
+ raise ValueError("invalid torrent hash")
+ normalized_hash = raw_hash.lower()
+
+ if isinstance(raw_index, bool):
+ raise ValueError("invalid file index")
+ try:
+ index = int(raw_index)
+ except (TypeError, ValueError) as error:
+ raise ValueError("invalid file index") from error
+ if index < 0 or index > 100_000:
+ raise ValueError("invalid file index")
+ if not isinstance(raw_fullscreen, bool):
+ raise ValueError("fullscreen must be a boolean")
+
+ if not isinstance(raw_stream_url, str) or not raw_stream_url:
+ raise ValueError("stream_url is required")
+ if len(raw_stream_url) > MAX_STREAM_URL_LENGTH:
+ raise ValueError("stream_url is too long")
+
+ parsed = urlparse(raw_stream_url)
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
+ raise ValueError("stream_url must use http or https")
+ if parsed.username is not None or parsed.password is not None or parsed.fragment:
+ raise ValueError("stream_url must not contain credentials or a fragment")
+ if allowed_hosts and parsed.hostname.lower() not in allowed_hosts:
+ raise ValueError("stream_url host is not allowed")
+
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ if query.get("link", [""])[0].lower() != normalized_hash:
+ raise ValueError("stream_url torrent hash does not match the request")
+ if query.get("index", [""])[0] != str(index):
+ raise ValueError("stream_url file index does not match the request")
+ if "play" not in query:
+ raise ValueError("stream_url is missing the play parameter")
+
+ stream_name = unquote(parsed.path.rsplit("/", 1)[-1])
+ if stream_name != file_name:
+ raise ValueError("stream_url filename does not match the request")
+
+ return PlayRequest(
+ path=file_name,
+ hash=normalized_hash,
+ index=index,
+ stream_url=raw_stream_url,
+ fullscreen=raw_fullscreen,
+ )
+
+
+def parse_config(argv: list[str] | None = None) -> Config:
+ env_player_args = shlex.split(os.getenv("VLC_AGENT_PLAYER_ARGS", ""))
+ env_allowed_hosts = split_csv(os.getenv("VLC_AGENT_ALLOWED_HOSTS", ""))
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--host", default=os.getenv("VLC_AGENT_HOST", "127.0.0.1"))
+ parser.add_argument("--port", type=int, default=int(os.getenv("VLC_AGENT_PORT", "8092")))
+ parser.add_argument("--token", default=os.getenv("VLC_AGENT_TOKEN", ""))
+ parser.add_argument("--allowed-host", action="append", default=[])
+ parser.add_argument("--player", default=os.getenv("VLC_AGENT_PLAYER", "vlc"))
+ parser.add_argument("--player-arg", action="append", default=[])
+ parser.add_argument(
+ "--stop-timeout",
+ type=float,
+ default=float(os.getenv("VLC_AGENT_STOP_TIMEOUT", "3")),
+ )
+ parser.add_argument(
+ "--allow-unauthenticated-network",
+ action="store_true",
+ help="allow a non-loopback listener without a bearer token",
+ )
+ args = parser.parse_args(argv)
+
+ if not 1 <= args.port <= 65535:
+ parser.error("port must be between 1 and 65535")
+ if args.stop_timeout <= 0:
+ parser.error("stop timeout must be positive")
+ if not args.player.strip():
+ parser.error("player command is required")
+ if not args.token and not is_loopback_host(args.host) and not args.allow_unauthenticated_network:
+ parser.error("a bearer token is required for a non-loopback listener")
+
+ player_args = tuple(args.player_arg or env_player_args or DEFAULT_PLAYER_ARGS)
+ allowed_hosts = frozenset(host.lower() for host in [*env_allowed_hosts, *args.allowed_host] if host)
+ return Config(
+ host=args.host,
+ port=args.port,
+ token=args.token,
+ allowed_hosts=allowed_hosts,
+ player=args.player,
+ player_args=player_args,
+ stop_timeout=args.stop_timeout,
+ )
+
+
+def split_csv(value: str) -> list[str]:
+ return [part.strip() for part in value.split(",") if part.strip()]
+
+
+def is_loopback_host(host: str) -> bool:
+ if host.lower() == "localhost":
+ return True
+ try:
+ return ipaddress.ip_address(host).is_loopback
+ except ValueError:
+ return False
+
+
+def run(config: Config) -> None:
+ player = PlayerManager(config.player, config.player_args, config.stop_timeout)
+ server = AgentHTTPServer((config.host, config.port), config, player)
+
+ def request_shutdown(signum: int, _frame: object) -> None:
+ logging.info("Received signal %s; shutting down", signum)
+ threading.Thread(target=server.shutdown, daemon=True).start()
+
+ signal.signal(signal.SIGINT, request_shutdown)
+ signal.signal(signal.SIGTERM, request_shutdown)
+
+ logging.info("TorrServer VLC agent listening on http://%s:%d", config.host, config.port)
+ if config.allowed_hosts:
+ logging.info("Allowed TorrServer hosts: %s", ", ".join(sorted(config.allowed_hosts)))
+ try:
+ server.serve_forever(poll_interval=0.25)
+ finally:
+ server.server_close()
+ player.stop()
+
+
+def main() -> None:
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+ run(parse_config())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/extras/vlc-agent/vlc-agent.env.example b/extras/vlc-agent/vlc-agent.env.example
new file mode 100644
index 000000000..445cb4bcf
--- /dev/null
+++ b/extras/vlc-agent/vlc-agent.env.example
@@ -0,0 +1,14 @@
+# Listen on 127.0.0.1 for a local reverse proxy, or 0.0.0.0 for direct LAN access.
+VLC_AGENT_HOST=127.0.0.1
+VLC_AGENT_PORT=8092
+
+# Required when listening outside loopback. The installer generates this value.
+VLC_AGENT_TOKEN=
+
+# Optional comma-separated TorrServer hostnames or IP addresses accepted in stream_url.
+# Example: 192.168.1.10,movies.example.net
+VLC_AGENT_ALLOWED_HOSTS=
+
+VLC_AGENT_PLAYER=/usr/bin/vlc
+VLC_AGENT_PLAYER_ARGS="--no-one-instance --no-video-title-show --network-caching=3000 --http-reconnect"
+VLC_AGENT_STOP_TIMEOUT=3