From 6a5884d941cff08a84db8bdb1c01583d2efcad18 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 4 Jul 2026 12:43:29 +0200 Subject: [PATCH] basic upnp discovery and cc forwarding --- README.md | 9 +- docs/cli.md | 5 + docs/web-ui.md | 14 +- internal/cli/root.go | 72 +++ internal/controlplane/server.go | 326 ++++++++++- internal/controlplane/webui/src/main.jsx | 401 ++++++++++++- internal/streamendpoint/server.go | 4 + internal/upnp/upnp.go | 696 +++++++++++++++++++++++ 8 files changed, 1503 insertions(+), 24 deletions(-) create mode 100644 internal/upnp/upnp.go diff --git a/README.md b/README.md index c52f50c..19549e1 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ # gaspode -`gaspode` is a Go-powered Chromecast toolbox for local playback, live screen sharing, and a compact embedded control UI. +`gaspode` is a Go-powered Chromecast toolbox for local playback, UPnP-backed media handoff, live screen sharing, and a compact embedded control UI. -It discovers available devices, can stream hosted or uploaded video files, shares full screens or single app windows through an on-the-fly media endpoint, and ships with a browser-based control plane embedded directly into the binary. In practice that means you can send a local or remote video to a TV, use your desktop or a single window as a live Chromecast source, browse host-side media without opening a terminal, and even hand control over to your phone by scanning the built-in QR code for the web UI. +It discovers available devices, can stream hosted or uploaded video files, can browse a UPnP or DLNA media server and relay one of its videos to Chromecast, shares full screens or single app windows through an on-the-fly media endpoint, and ships with a browser-based control plane embedded directly into the binary. In practice that means you can send a local or remote video to a TV, pick a movie from a media server without leaving the UI, use your desktop or a single app window as a live Chromecast source, browse host-side media without opening a terminal, and even hand control over to your phone by scanning the built-in QR code for the web UI. ## Features - Chromecast discovery with mDNS plus network-scan fallback - CLI workflows for listing devices, sending media, and sharing screens -- Embedded web UI for device selection, source picking, audio options, stream control, and QR-based phone handoff +- Embedded web UI for device selection, source picking, audio options, UPnP browsing, stream control, and QR-based phone handoff - Web UI host-file browsing restricted to an explicit media base directory +- UPnP or DLNA media-server browsing with direct Chromecast relay - Local file streaming from the host machine without duplicating large source files - Optional compatibility mode for Chromecast-friendlier MP4/H.264/AAC delivery - Optional seekable-index creation for local media when Chromecast scrubbing matters @@ -104,6 +105,8 @@ Open `http://127.0.0.1:11999` after starting the web UI. If you want to scan the built-in QR code from your phone, bind the web UI to a LAN-reachable address and optionally override the advertised URL with `--web-ui-advertise`. +Inside the web UI you can choose between host-file streaming, uploaded files, UPnP or DLNA media-server browsing, full-screen sharing, and single-window sharing. + ## Example Commands ```powershell diff --git a/docs/cli.md b/docs/cli.md index 557df47..4e74c17 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -82,6 +82,11 @@ Meaning: - `--web-ui` starts the embedded browser UI on the given listen address - `--media-base-path` defines the only directory tree the web UI file browser may access and is required when `--web-ui` is used +Web UI note: + +- the embedded UI can also browse UPnP or DLNA media servers on the network and relay selected videos to Chromecast +- `--media-base-path` does not restrict UPnP browsing because those files are not host-local + ## Discovery Notes - mDNS discovery is attempted first diff --git a/docs/web-ui.md b/docs/web-ui.md index 2a1dbce..1caa676 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -13,8 +13,9 @@ Then open `http://127.0.0.1:11999`. ## What It Can Do - Discover and select available Chromecasts -- Switch between video, screen, and window streaming modes +- Switch between video, UPnP, screen, and window streaming modes - Browse video files directly on the host machine +- Browse videos exposed by a UPnP or DLNA media server - Upload a video file through the browser - Toggle compatibility mode and optional seekable transcoding - Choose latency and audio options for live capture @@ -32,6 +33,17 @@ Browse files directly on the machine running `gaspode` and stream them without c Upload a file through the browser. This is useful when the UI is opened from another device and the selected media is not already present on the host. +## UPnP / DLNA + +The `UPnP` mode discovers active media servers on the local network, lets you choose one, browse its folders, and relay a selected video to Chromecast. + +Important behavior: + +- this browser is remote and does not use `--media-base-path` +- `--media-base-path` still only limits the host-file browser +- compatibility mode is available here too and proxies the remote media through `gaspode` when Chromecast needs a safer stream +- seekable-index creation is only available for host-local files, not for remote UPnP items + ## Compatibility Mode Compatibility mode transcodes video on the fly to a more Chromecast-friendly MP4/H.264/AAC stream. diff --git a/internal/cli/root.go b/internal/cli/root.go index 8218807..3342c76 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -16,6 +16,7 @@ import ( "gaspode/internal/chromecast" "gaspode/internal/controlplane" "gaspode/internal/logging" + "gaspode/internal/upnp" "gaspode/internal/windowlist" "github.com/spf13/cobra" @@ -45,6 +46,10 @@ type audioDevicesOptions struct { toJSON bool } +type upnpScanOptions struct { + toJSON bool +} + type sendOptions struct { mediaURL string mediaType string @@ -139,6 +144,7 @@ func NewRootCmd() *cobra.Command { cmd.PersistentFlags().StringVar(&rootOpts.webUIAdvertise, "web-ui-advertise", "", "Override the advertised web UI URL used by the QR code, for example http://192.168.2.123:11999") cmd.PersistentFlags().StringVar(&rootOpts.mediaBasePath, "media-base-path", "", "Base directory the web UI file browser is allowed to access") cmd.AddCommand(newListCmd(&rootOpts)) + cmd.AddCommand(newUPNPScanCmd(&rootOpts)) cmd.AddCommand(newAudioDevicesCmd()) cmd.AddCommand(newSendCmd(&rootOpts)) cmd.AddCommand(newScreenCmd(&rootOpts)) @@ -221,6 +227,61 @@ func newAudioDevicesCmd() *cobra.Command { return cmd } +// newUPNPScanCmd creates a standalone UPnP or DLNA discovery command. +func newUPNPScanCmd(rootOpts *rootOptions) *cobra.Command { + opts := upnpScanOptions{} + + cmd := &cobra.Command{ + Use: "upnp-scan", + Short: "List active UPnP or DLNA media servers on the local network", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), rootOpts.timeout) + defer cancel() + + var debugWriter io.Writer + logger, loggerErr := newLogger(cmd.ErrOrStderr(), rootOpts) + if loggerErr == nil && (rootOpts.debugDiscovery || logger.Enabled(logging.LevelDebug)) { + debugWriter = cmd.ErrOrStderr() + } + + services, err := upnp.DiscoverWithDebug(ctx, debugWriter) + if err != nil { + services = []upnp.Service{} + } + + if opts.toJSON { + if services == nil { + services = []upnp.Service{} + } + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(services) + } + + for _, service := range services { + name := strings.TrimSpace(service.FriendlyName) + if name == "" { + name = strings.TrimSpace(service.ModelName) + } + if name == "" { + name = service.ID + } + meta := strings.TrimSpace(strings.Join(filterNonEmpty(service.Manufacturer, service.ModelName), " · ")) + if meta != "" { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", name, meta, service.Location) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", name, service.Location) + } + return nil + }, + } + + cmd.Flags().BoolVar(&opts.toJSON, "to-json", false, "Print UPnP services as JSON") + return cmd +} + // newSendCmd creates the one-shot media send command. func newSendCmd(rootOpts *rootOptions) *cobra.Command { sendOpts := sendOptions{} @@ -453,3 +514,14 @@ func newLogger(stderr io.Writer, opts *rootOptions) (*logging.Logger, error) { } return logging.New(stderr, level), nil } + +func filterNonEmpty(values ...string) []string { + var result []string + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + result = append(result, value) + } + } + return result +} diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index d725f16..3faab96 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "mime" "net" @@ -28,6 +29,7 @@ import ( "gaspode/internal/logging" "gaspode/internal/mediautil" "gaspode/internal/screenlist" + "gaspode/internal/upnp" "gaspode/internal/windowlist" ) @@ -59,15 +61,21 @@ type Server struct { loader *chromecast.Loader mu sync.Mutex + refreshMu sync.Mutex session *chromecast.Session httpServer *http.Server + discoveryCancel context.CancelFunc + discoveryLoopDone chan struct{} lastDevices []chromecast.Device lastDevicesAt time.Time lastNonEmptyAudio []audiodevices.Device lastAudioAt time.Time + lastUPNPServices []upnp.Service + lastUPNPAt time.Time } const discoveryGracePeriod = 20 * time.Second +const discoveryRefreshInterval = 8 * time.Second type statusResponse struct { Platform string `json:"platform"` @@ -79,6 +87,20 @@ type statusResponse struct { ActiveSession *activeSession `json:"activeSession"` } +type upnpBrowseResponse struct { + ServiceID string `json:"serviceId"` + ObjectID string `json:"objectId"` + Entries []upnp.Entry `json:"entries"` +} + +type upnpMediaRequest struct { + DeviceName string `json:"deviceName"` + ServiceID string `json:"serviceId"` + MediaURL string `json:"mediaUrl"` + MediaType string `json:"mediaType"` + CompatMode bool `json:"compatMode"` +} + type activeSession struct { Kind string `json:"kind"` Device chromecast.Device `json:"device"` @@ -137,11 +159,16 @@ func (s *Server) ListenAndServe() error { mux.HandleFunc("/api/status", s.handleStatus) mux.HandleFunc("/api/files/roots", s.handleFileRoots) mux.HandleFunc("/api/files", s.handleFileList) + mux.HandleFunc("/api/upnp/services", s.handleUPNPServices) + mux.HandleFunc("/api/upnp/browse", s.handleUPNPBrowse) + mux.HandleFunc("/api/previews/upnp", s.handleUPNPPreview) mux.HandleFunc("/api/previews/file", s.handleFilePreview) mux.HandleFunc("/api/previews/screen", s.handleScreenPreview) mux.HandleFunc("/api/previews/window", s.handleWindowPreview) + mux.HandleFunc("/api/actions/discovery-refresh", s.handleRefreshDiscovery) mux.HandleFunc("/api/actions/media", s.handleStartMedia) mux.HandleFunc("/api/actions/media-path", s.handleStartMediaPath) + mux.HandleFunc("/api/actions/upnp-media", s.handleStartUPNPMedia) mux.HandleFunc("/api/actions/screen", s.handleStartScreen) mux.HandleFunc("/api/actions/window", s.handleStartWindow) mux.HandleFunc("/api/actions/stop", s.handleStop) @@ -154,6 +181,8 @@ func (s *Server) ListenAndServe() error { s.mu.Lock() s.httpServer = server s.mu.Unlock() + s.startDiscoveryLoop() + defer s.stopDiscoveryLoop() return server.ListenAndServe() } @@ -170,6 +199,7 @@ func (s *Server) Shutdown(ctx context.Context) error { if session != nil { _ = session.Stop() } + s.stopDiscoveryLoop() if server == nil { s.debugf("controlplane.shutdown no_server") return nil @@ -226,14 +256,10 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler { } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout) - defer cancel() - - devices, _ := s.loader.ListDevices(ctx) + devices := s.cachedDevices() audio, _ := audiodevices.List() screens, _ := screenlist.List() windows, _ := windowlist.List() - devices = s.stableDevices(devices) audio = s.stableAudioDevices(audio) if devices == nil { devices = []chromecast.Device{} @@ -260,6 +286,83 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } +func (s *Server) startDiscoveryLoop() { + s.mu.Lock() + if s.discoveryCancel != nil { + s.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + s.discoveryCancel = cancel + s.discoveryLoopDone = done + s.mu.Unlock() + + go func() { + defer close(done) + s.refreshDiscoveryCache(ctx, "startup") + + ticker := time.NewTicker(discoveryRefreshInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.refreshDiscoveryCache(ctx, "ticker") + } + } + }() +} + +func (s *Server) stopDiscoveryLoop() { + s.mu.Lock() + cancel := s.discoveryCancel + done := s.discoveryLoopDone + s.discoveryCancel = nil + s.discoveryLoopDone = nil + s.mu.Unlock() + + if cancel != nil { + cancel() + } + if done != nil { + <-done + } +} + +func (s *Server) refreshDiscoveryCache(parent context.Context, source string) { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + + s.debugf("discovery.cache refresh start source=%q", source) + + var debugWriter io.Writer + if s.cfg.Logger != nil && (s.cfg.DebugDiscovery || s.cfg.Logger.Enabled(logging.LevelDebug)) { + debugWriter = s.cfg.Logger.DebugWriter() + } + upnpCtx, upnpCancel := context.WithTimeout(parent, s.cfg.Timeout) + services, upnpErr := upnp.DiscoverWithDebug(upnpCtx, debugWriter) + upnpCancel() + if upnpErr != nil { + s.errorf("discovery.cache upnp_error source=%q err=%v", source, upnpErr) + services = nil + } + services = s.stableUPNPServices(services) + + deviceCtx, deviceCancel := context.WithTimeout(parent, s.cfg.Timeout) + devices, deviceErr := s.loader.ListDevices(deviceCtx) + deviceCancel() + if deviceErr != nil { + s.errorf("discovery.cache chromecast_error source=%q err=%v", source, deviceErr) + devices = nil + } + devices = s.stableDevices(devices) + + s.debugf("discovery.cache refresh done source=%q chromecasts=%d upnp=%d", source, len(devices), len(services)) +} + // stableDevices smooths transient discovery misses so the UI does not flap // when mDNS or the fallback scan briefly returns no Chromecast results. func (s *Server) stableDevices(devices []chromecast.Device) []chromecast.Device { @@ -300,6 +403,16 @@ func (s *Server) cachedDeviceByName(name string) (chromecast.Device, bool) { return chromecast.Device{}, false } +func (s *Server) cachedDevices() []chromecast.Device { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.lastDevices) == 0 { + return nil + } + return append([]chromecast.Device(nil), s.lastDevices...) +} + // stableAudioDevices keeps audio sources visible across short polling gaps. func (s *Server) stableAudioDevices(devices []audiodevices.Device) []audiodevices.Device { s.mu.Lock() @@ -321,6 +434,141 @@ func (s *Server) stableAudioDevices(devices []audiodevices.Device) []audiodevice return devices } +func (s *Server) stableUPNPServices(services []upnp.Service) []upnp.Service { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + if len(services) > 0 { + s.lastUPNPServices = append([]upnp.Service(nil), services...) + s.lastUPNPAt = now + return services + } + + if len(s.lastUPNPServices) > 0 && now.Sub(s.lastUPNPAt) <= discoveryGracePeriod { + return append([]upnp.Service(nil), s.lastUPNPServices...) + } + + s.lastUPNPServices = nil + s.lastUPNPAt = time.Time{} + return services +} + +func (s *Server) cachedUPNPService(serviceID string) (upnp.Service, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, service := range s.lastUPNPServices { + if service.ID == serviceID { + return service, true + } + } + return upnp.Service{}, false +} + +func (s *Server) cachedUPNPServices() []upnp.Service { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.lastUPNPServices) == 0 { + return nil + } + return append([]upnp.Service(nil), s.lastUPNPServices...) +} + +func (s *Server) handleUPNPServices(w http.ResponseWriter, r *http.Request) { + services := s.cachedUPNPServices() + if len(services) == 0 { + s.refreshDiscoveryCache(r.Context(), "http-upnp-services") + services = s.cachedUPNPServices() + } + if services == nil { + services = []upnp.Service{} + } + writeJSON(w, map[string]any{"entries": services}) +} + +func (s *Server) handleRefreshDiscovery(w http.ResponseWriter, r *http.Request) { + s.refreshDiscoveryCache(r.Context(), "manual") + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleUPNPBrowse(w http.ResponseWriter, r *http.Request) { + serviceID := strings.TrimSpace(r.URL.Query().Get("serviceId")) + objectID := strings.TrimSpace(r.URL.Query().Get("objectId")) + if serviceID == "" { + http.Error(w, "serviceId is required", http.StatusBadRequest) + return + } + + service, err := s.resolveUPNPService(r.Context(), serviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout) + defer cancel() + + entries, err := upnp.Browse(ctx, service, objectID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if entries == nil { + entries = []upnp.Entry{} + } + + writeJSON(w, upnpBrowseResponse{ + ServiceID: service.ID, + ObjectID: objectID, + Entries: entries, + }) +} + +func (s *Server) handleUPNPPreview(w http.ResponseWriter, r *http.Request) { + serviceID := strings.TrimSpace(r.URL.Query().Get("serviceId")) + mediaURL := strings.TrimSpace(r.URL.Query().Get("mediaUrl")) + if serviceID == "" { + http.Error(w, "serviceId is required", http.StatusBadRequest) + return + } + if mediaURL == "" { + http.Error(w, "mediaUrl is required", http.StatusBadRequest) + return + } + + parsedURL, err := url.ParseRequestURI(mediaURL) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + http.Error(w, "mediaUrl must be a valid http or https URL", http.StatusBadRequest) + return + } + + if _, err := s.resolveUPNPService(r.Context(), serviceID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout) + defer cancel() + + previewOffset := choosePreviewOffset(ctx, mediaURL) + args := []string{ + "-hide_banner", + "-loglevel", "error", + "-ss", formatFFmpegSeek(previewOffset), + "-i", mediaURL, + "-frames:v", "1", + "-vf", "scale=640:-2", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "pipe:1", + } + if err := writePreviewJPEG(ctx, w, args, nil); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } +} + func (s *Server) handleStartMedia(w http.ResponseWriter, r *http.Request) { s.debugf("action.media.upload start remote=%q", r.RemoteAddr) if err := r.ParseMultipartForm(2 << 30); err != nil { @@ -549,6 +797,62 @@ func (s *Server) handleStartMediaPath(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } +func (s *Server) handleStartUPNPMedia(w http.ResponseWriter, r *http.Request) { + var req upnpMediaRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.errorf("action.upnp.media decode_error=%v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.debugf("action.upnp.media start device=%q service=%q media=%q compat=%t", req.DeviceName, req.ServiceID, req.MediaURL, req.CompatMode) + + if req.DeviceName == "" { + http.Error(w, "deviceName is required", http.StatusBadRequest) + return + } + if req.ServiceID == "" { + http.Error(w, "serviceId is required", http.StatusBadRequest) + return + } + if req.MediaURL == "" { + http.Error(w, "mediaUrl is required", http.StatusBadRequest) + return + } + parsedURL, err := url.ParseRequestURI(req.MediaURL) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + http.Error(w, "mediaUrl must be a valid http or https URL", http.StatusBadRequest) + return + } + + if _, err := s.resolveUPNPService(r.Context(), req.ServiceID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.MediaType == "" { + req.MediaType = "video/mp4" + } + + ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout) + defer cancel() + + session, err := s.startMediaSession(ctx, chromecast.MediaRequest{ + DeviceName: req.DeviceName, + MediaURL: req.MediaURL, + MediaType: req.MediaType, + CompatMode: req.CompatMode, + }) + if err != nil { + s.errorf("action.upnp.media start_session_error device=%q service=%q err=%v", req.DeviceName, req.ServiceID, err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s.replaceSession(session) + s.infof("action.upnp.media ok device=%q service=%q media=%q compat=%t", req.DeviceName, req.ServiceID, req.MediaURL, req.CompatMode) + writeJSON(w, map[string]string{"status": "ok"}) +} + func (s *Server) handleFilePreview(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") if path == "" { @@ -1222,6 +1526,18 @@ func lookupWindow(windowID string) (*windowlist.Window, error) { return nil, fmt.Errorf("windowId was not found") } +func (s *Server) resolveUPNPService(ctx context.Context, serviceID string) (upnp.Service, error) { + if cached, ok := s.cachedUPNPService(serviceID); ok { + return cached, nil + } + + s.refreshDiscoveryCache(ctx, "resolve-upnp-service") + if cached, ok := s.cachedUPNPService(serviceID); ok { + return cached, nil + } + return upnp.Service{}, fmt.Errorf("UPnP service %q was not found", serviceID) +} + func previewWindowRect(window *windowlist.Window) (int, int, int, int) { x := expandedWindowX(window) y := expandedWindowY(window) diff --git a/internal/controlplane/webui/src/main.jsx b/internal/controlplane/webui/src/main.jsx index ab053c8..f770a44 100644 --- a/internal/controlplane/webui/src/main.jsx +++ b/internal/controlplane/webui/src/main.jsx @@ -1304,6 +1304,8 @@ function modeIcon(mode) { switch (mode) { case "video": return "▶"; + case "upnp": + return "◎"; case "screen": return "▣"; case "window": @@ -1313,6 +1315,13 @@ function modeIcon(mode) { } } +const navItems = [ + { value: "video", label: "Video" }, + { value: "upnp", label: "UPnP" }, + { value: "screen", label: "Screen" }, + { value: "window", label: "Window" } +]; + function dropdownChevron(expanded) { return expanded ? "▾" : "▸"; } @@ -1763,6 +1772,13 @@ function App() { const [selectedBrowseFile, setSelectedBrowseFile] = React.useState(""); const [fileBrowserOpen, setFileBrowserOpen] = React.useState(false); const [fileBrowserVisible, setFileBrowserVisible] = React.useState(false); + const [upnpServices, setUPNPServices] = React.useState([]); + const [selectedUPNPService, setSelectedUPNPService] = React.useState(""); + const [upnpEntries, setUPNPEntries] = React.useState([]); + const [upnpObjectStack, setUPNPObjectStack] = React.useState([]); + const [selectedUPNPFile, setSelectedUPNPFile] = React.useState(null); + const [upnpBrowserOpen, setUPNPBrowserOpen] = React.useState(false); + const [upnpBrowserVisible, setUPNPBrowserVisible] = React.useState(false); const [uploadPreviewURL, setUploadPreviewURL] = React.useState(""); const [qrCodeDataURL, setQRCodeDataURL] = React.useState(""); const [flash, setFlash] = React.useState(null); @@ -1770,6 +1786,7 @@ function App() { const [refreshing, setRefreshing] = React.useState(false); const [lastUpdated, setLastUpdated] = React.useState(""); const fileInputRef = React.useRef(null); + const upnpServicesRef = React.useRef([]); const message = flash?.kind === "info" ? flash.text : ""; const error = flash?.kind === "error" ? flash.text : ""; const theme = themes[themeMode] || themes.dark; @@ -1800,6 +1817,11 @@ function App() { title: device.name, meta: device.id })); + const upnpServiceItems = upnpServices.map((service) => ({ + value: service.id, + title: service.friendlyName || service.modelName || service.id, + meta: [service.modelName, service.manufacturer].filter(Boolean).join(" · ") + })); const latencyItems = [ { value: "ultra", @@ -1828,12 +1850,23 @@ function App() { meta: entry.isDir ? "Folder" : `${Math.round((entry.size || 0) / 1024 / 1024)} MB`, isDir: entry.isDir })); + const upnpBrowseItems = upnpEntries.map((entry) => ({ + value: entry.isDir ? `dir:${entry.id}` : `file:${entry.mediaUrl}`, + title: entry.title, + meta: entry.meta || (entry.isDir ? "Folder" : entry.mediaType || "Video"), + isDir: entry.isDir + })); const selectedDeviceItem = deviceItems.find((item) => item.value === selectedDevice) || null; const selectedScreenItem = screenItems.find((item) => item.value === selectedScreen) || null; const selectedWindowItem = windowItems.find((item) => item.value === selectedWindow) || null; + const selectedUPNPServiceItem = upnpServiceItems.find((item) => item.value === selectedUPNPService) || null; const selectedLatencyItem = latencyItems.find((item) => item.value === latencyProfile) || null; const selectedAudioItem = audioItems.find((item) => item.value === selectedAudio) || null; const selectedBrowseEntry = browseEntries.find((entry) => entry.path === selectedBrowseFile) || null; + const upnpCurrentNode = upnpObjectStack[upnpObjectStack.length - 1] || null; + const upnpCurrentObjectID = upnpCurrentNode?.id || "0"; + const upnpCurrentLabel = upnpObjectStack.map((entry) => entry.title).filter(Boolean).join(" / "); + const canBrowseUPNPUp = upnpObjectStack.length > 1; const webUIURL = preferredWebUIURL(status); const showQRInHeader = showQRCode && !!qrCodeDataURL && !(layout.stacked && navOpen); const screenPreviewVersion = React.useMemo( @@ -1853,6 +1886,13 @@ function App() { const browsePreviewURL = selectedBrowseFile ? `/api/previews/file?path=${encodeURIComponent(selectedBrowseFile)}` : ""; + const upnpPreviewVersion = React.useMemo( + () => encodeURIComponent(String(Date.now())), + [selectedUPNPFile?.mediaUrl] + ); + const upnpPreviewURL = selectedUPNPFile?.mediaUrl && selectedUPNPService + ? `/api/previews/upnp?serviceId=${encodeURIComponent(selectedUPNPService)}&mediaUrl=${encodeURIComponent(selectedUPNPFile.mediaUrl)}&v=${upnpPreviewVersion}` + : ""; const normalizeBrowsePath = React.useCallback((value) => String(value || "").replace(/[\\/]+$/, ""), []); const derivedBrowseParent = React.useMemo(() => { if (currentBrowseParent) { @@ -1942,6 +1982,10 @@ function App() { setNavOpen(false); }, []); + React.useEffect(() => { + upnpServicesRef.current = upnpServices; + }, [upnpServices]); + React.useEffect(() => { if (!deviceItems.some((item) => item.value === selectedDevice)) { setSelectedDevice(deviceItems[0]?.value || ""); @@ -1966,6 +2010,15 @@ function App() { } }, [audioItems, selectedAudio]); + React.useEffect(() => { + if (selectedUPNPService) { + return; + } + if (upnpServiceItems.length > 0) { + setSelectedUPNPService(upnpServiceItems[0].value); + } + }, [selectedUPNPService, upnpServiceItems]); + const refresh = React.useCallback(async (silent = false) => { if (!silent) { setRefreshing(true); @@ -1989,16 +2042,46 @@ function App() { } }, []); + const loadUPNPServices = React.useCallback(async () => { + const result = await request("/api/upnp/services"); + setUPNPServices(result.entries || []); + }, []); + + const loadUPNPDirectory = React.useCallback(async (serviceID, objectID = "0", stack = null) => { + const result = await request( + `/api/upnp/browse?serviceId=${encodeURIComponent(serviceID)}&objectId=${encodeURIComponent(objectID)}` + ); + setUPNPEntries(result.entries || []); + if (Array.isArray(stack)) { + setUPNPObjectStack(stack); + } + }, []); + + const refreshDiscovery = React.useCallback(async (silent = false, triggerRefresh = false) => { + if (triggerRefresh) { + await request("/api/actions/discovery-refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }); + } + const nextStatus = await refresh(silent); + if (mode === "upnp") { + await loadUPNPServices(); + } + return nextStatus; + }, [loadUPNPServices, mode, refresh]); + React.useEffect(() => { - refresh().catch((err) => { + refreshDiscovery().catch((err) => { setRefreshing(false); setError(String(err)); }); const id = setInterval(() => { - refresh(true).catch((err) => setError(String(err))); + refreshDiscovery(true).catch((err) => setError(String(err))); }, 4000); return () => clearInterval(id); - }, [refresh]); + }, [refreshDiscovery]); const loadRoots = React.useCallback(async () => { const result = await request("/api/files/roots"); @@ -2031,6 +2114,45 @@ function App() { return () => clearTimeout(timeoutID); }, [fileBrowserOpen]); + React.useEffect(() => { + if (mode !== "upnp") { + setUPNPBrowserOpen(false); + setUPNPBrowserVisible(false); + return; + } + + loadUPNPServices().catch((err) => setError(String(err))); + }, [loadUPNPServices, mode]); + + React.useEffect(() => { + if (upnpBrowserOpen) { + setUPNPBrowserVisible(true); + return undefined; + } + + const timeoutID = setTimeout(() => setUPNPBrowserVisible(false), 220); + return () => clearTimeout(timeoutID); + }, [upnpBrowserOpen]); + + React.useEffect(() => { + if (mode !== "upnp" || !selectedUPNPService) { + setUPNPEntries([]); + setUPNPObjectStack([]); + return; + } + + const service = upnpServicesRef.current.find((entry) => entry.id === selectedUPNPService); + const rootStack = [ + { + id: "0", + title: service?.friendlyName || service?.modelName || "Library" + } + ]; + setSelectedUPNPFile(null); + setUPNPBrowserOpen(true); + loadUPNPDirectory(selectedUPNPService, "0", rootStack).catch((err) => setError(String(err))); + }, [loadUPNPDirectory, mode, selectedUPNPService]); + React.useEffect(() => { if (!selectedFile) { setUploadPreviewURL(""); @@ -2182,6 +2304,35 @@ function App() { }); }; + const startUPNPMedia = async () => { + if (!selectedDevice) { + setError("Select a Chromecast first."); + return; + } + if (!selectedUPNPService) { + setError("Select a UPnP service first."); + return; + } + if (!selectedUPNPFile?.mediaUrl) { + setError("Select a UPnP video first."); + return; + } + + await runAction("Starting UPnP video...", "UPnP video stream is live.", async () => { + await request("/api/actions/upnp-media", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deviceName: selectedDevice, + serviceId: selectedUPNPService, + mediaUrl: selectedUPNPFile.mediaUrl, + mediaType: selectedUPNPFile.mediaType || "video/mp4", + compatMode: videoCompatMode + }) + }); + }); + }; + const stop = async () => { if (!status.activeSession) { setMessage("No active stream to stop."); @@ -2202,6 +2353,8 @@ function App() { const sourceSummary = mode === "screen" ? status.screens.find((screen) => screen.id === selectedScreen)?.name || "no screen selected" + : mode === "upnp" + ? selectedUPNPFile?.title || "no file selected" : mode === "window" ? status.windows.find((window) => window.id === selectedWindow)?.title || "no window selected" : videoSourceMode === "browse" @@ -2239,6 +2392,20 @@ function App() { ); } + if (mode === "upnp") { + return ( + + Stream UPnP video + + ); + } + return ( G - {[ - { value: "video", label: "Video" }, - { value: "screen", label: "Screen" }, - { value: "window", label: "Window" } - ].map((item) => ( + {navItems.map((item) => ( activateMode(item.value)} @@ -2382,11 +2545,7 @@ function App() { G - {[ - { value: "video", label: "Video" }, - { value: "screen", label: "Screen" }, - { value: "window", label: "Window" } - ].map((item) => ( + {navItems.map((item) => ( activateMode(item.value)} @@ -2418,6 +2577,8 @@ function App() { {mode === "video" ? "Send video to Chromecast" + : mode === "upnp" + ? "Browse UPnP and send to Chromecast" : mode === "screen" ? "Share screen live to Chromecast" : "Share window live to Chromecast"} @@ -2435,7 +2596,7 @@ function App() { ) : null} refresh().catch((err) => setError(String(err)))} + onActivate={() => refreshDiscovery(false, true).catch((err) => setError(String(err)))} disabled={refreshing} label={refreshing ? "Refreshing discovery" : "Refresh discovery"} title={refreshing ? "Refreshing discovery" : "Refresh discovery"} @@ -2791,6 +2952,216 @@ function App() { ) : null} + ) : mode === "upnp" ? ( + selectedUPNPServiceItem ? ( + <> + + + + Selected file + {!upnpBrowserOpen && selectedUPNPFile ? ( + + ) : null} + {upnpBrowserVisible ? ( + + +
+
+
+ + +
+
+ +
+
+ {upnpCurrentLabel ? ( +
+
Path
+
+ {upnpCurrentLabel} +
+
+ ) : null} +
+ { + const entry = upnpEntries.find((item) => ( + item.isDir ? `dir:${item.id}` === value : `file:${item.mediaUrl}` === value + )); + if (!entry) { + return; + } + if (entry.isDir) { + const nextStack = [ + ...upnpObjectStack, + { + id: entry.id, + title: entry.title + } + ]; + setSelectedUPNPFile(null); + loadUPNPDirectory(selectedUPNPService, entry.id, nextStack).catch((err) => setError(String(err))); + return; + } + setSelectedUPNPFile(entry); + setUPNPBrowserOpen(false); + }} + styles={styles} + scrollable={true} + emptyTitle="No UPnP videos in this folder" + emptyCopy="Open another folder or refresh the media server list." + /> +
+
+ ) : null} +
+ + + + + + Compatibility mode + + + + + + Proxy and transcode remote UPnP media when Chromecast needs a safer stream. + + + + + + {layout.stacked ? ( + + + {renderActionButton()} + + + ) : null} + + ) : ( + + No UPnP media server available yet + Refresh discovery and make sure your UPnP or DLNA server is online. + + ) ) : mode === "screen" ? ( screenItems.length ? ( <> @@ -2957,7 +3328,7 @@ function App() { Devices - {mode === "screen" ? screenItems.length : mode === "window" ? windowItems.length : 1} + {mode === "screen" ? screenItems.length : mode === "window" ? windowItems.length : mode === "upnp" ? upnpServiceItems.length : 1} Sources diff --git a/internal/streamendpoint/server.go b/internal/streamendpoint/server.go index e6eb880..0dd72c4 100644 --- a/internal/streamendpoint/server.go +++ b/internal/streamendpoint/server.go @@ -54,6 +54,10 @@ func StartMedia(targetURL, bindIP string, opts MediaOptions) (*Server, error) { if isHTTPURL(targetURL) { return start(bindIP, func(mux *http.ServeMux) { mux.HandleFunc("/media", func(w http.ResponseWriter, r *http.Request) { + if opts.CompatMode { + serveMediaCompat(w, r, targetURL) + return + } http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) }) }) diff --git a/internal/upnp/upnp.go b/internal/upnp/upnp.go new file mode 100644 index 0000000..bef6a90 --- /dev/null +++ b/internal/upnp/upnp.go @@ -0,0 +1,696 @@ +package upnp + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "net/textproto" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + ssdpAddress = "239.255.255.250:1900" + ssdpMaxWaitSeconds = 2 + rootObjectID = "0" +) + +var ssdpSearchTargets = []string{ + "urn:schemas-upnp-org:device:MediaServer:1", + "urn:schemas-upnp-org:service:ContentDirectory:1", + "upnp:rootdevice", + "ssdp:all", +} + +// Service describes one discovered UPnP media server that exposes a +// ContentDirectory control endpoint. +// +// Ownership note: this package only discovers and browses remote UPnP content. +// Playback remains the responsibility of the control plane and chromecast packages. +type Service struct { + ID string `json:"id"` + USN string `json:"usn"` + FriendlyName string `json:"friendlyName"` + ModelName string `json:"modelName,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + Location string `json:"location"` + ContentDirectoryURL string `json:"contentDirectoryUrl"` +} + +type discoverOptions struct { + debug io.Writer +} + +// Entry describes one UPnP container or playable item. +type Entry struct { + ID string `json:"id"` + Title string `json:"title"` + IsDir bool `json:"isDir"` + MediaURL string `json:"mediaUrl,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Size int64 `json:"size,omitempty"` + Meta string `json:"meta,omitempty"` +} + +// Discover finds active UPnP media servers on the local network. +func Discover(ctx context.Context) ([]Service, error) { + return DiscoverWithOptions(ctx, discoverOptions{}) +} + +// DiscoverWithDebug finds active UPnP media servers and emits diagnostics when debug is set. +func DiscoverWithDebug(ctx context.Context, debug io.Writer) ([]Service, error) { + return DiscoverWithOptions(ctx, discoverOptions{debug: debug}) +} + +// DiscoverWithOptions is the shared discovery entrypoint for CLI and control plane usage. +func DiscoverWithOptions(ctx context.Context, opts discoverOptions) ([]Service, error) { + locationByUSN, err := discoverLocations(ctx, opts) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 4 * time.Second} + services := make([]Service, 0, len(locationByUSN)) + seen := map[string]struct{}{} + + for usn, location := range locationByUSN { + debugf(opts.debug, "upnp: description fetch usn=%q location=%q", usn, location) + service, err := fetchServiceDescription(ctx, client, usn, location) + if err != nil { + debugf(opts.debug, "upnp: description rejected usn=%q err=%v", usn, err) + continue + } + if _, ok := seen[service.ID]; ok { + debugf(opts.debug, "upnp: duplicate service ignored id=%q name=%q", service.ID, service.FriendlyName) + continue + } + seen[service.ID] = struct{}{} + debugf( + opts.debug, + "upnp: service accepted id=%q name=%q model=%q manufacturer=%q content_dir=%q", + service.ID, + service.FriendlyName, + service.ModelName, + service.Manufacturer, + service.ContentDirectoryURL, + ) + services = append(services, service) + } + debugf(opts.debug, "upnp: discover complete raw_locations=%d services=%d", len(locationByUSN), len(services)) + + return services, nil +} + +// Browse returns the direct children of one container on a given service. +func Browse(ctx context.Context, service Service, objectID string) ([]Entry, error) { + if strings.TrimSpace(service.ContentDirectoryURL) == "" { + return nil, fmt.Errorf("UPnP service is missing a content directory endpoint") + } + if strings.TrimSpace(objectID) == "" { + objectID = rootObjectID + } + + body := fmt.Sprintf(` + + + + %s + BrowseDirectChildren + * + 0 + 0 + + + +`, xmlEscape(objectID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, service.ContentDirectoryURL, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", `text/xml; charset="utf-8"`) + req.Header.Set("SOAPAction", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`) + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + payload, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("UPnP browse failed: %s %s", resp.Status, strings.TrimSpace(string(payload))) + } + + var envelope browseEnvelope + if err := xml.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, err + } + + return parseDIDL(bytes.NewBufferString(envelope.Body.BrowseResponse.Result)) +} + +func discoverLocations(ctx context.Context, opts discoverOptions) (map[string]string, error) { + deadline := time.Now().Add(3 * time.Second) + if limit, ok := ctx.Deadline(); ok && limit.Before(deadline) { + deadline = limit + } + locations := map[string]string{} + var mu sync.Mutex + record := func(source string, headers textproto.MIMEHeader) { + location := headers.Get("Location") + usn := headers.Get("USN") + if location == "" { + debugf(opts.debug, "upnp: %s ignored missing location", source) + return + } + if usn == "" { + usn = location + } + debugf( + opts.debug, + "upnp: %s st=%q nts=%q usn=%q server=%q location=%q", + source, + headers.Get("St"), + headers.Get("Nts"), + usn, + headers.Get("Server"), + location, + ) + mu.Lock() + locations[usn] = location + mu.Unlock() + } + + ifaces := privateIPv4Interfaces() + if len(ifaces) == 0 { + debugf(opts.debug, "upnp: no private IPv4 interfaces found, falling back to wildcard UDP search") + ifaces = []net.IP{nil} + } + + var wg sync.WaitGroup + for _, ip := range ifaces { + wg.Add(1) + go func(localIP net.IP) { + defer wg.Done() + discoverBySearch(ctx, deadline, localIP, opts, record) + }(ip) + } + + wg.Add(1) + go func() { + defer wg.Done() + discoverByNotify(ctx, deadline, opts, record) + }() + wg.Wait() + + return locations, nil +} + +func discoverBySearch(ctx context.Context, deadline time.Time, localIP net.IP, opts discoverOptions, record func(string, textproto.MIMEHeader)) { + var listenAddr *net.UDPAddr + if localIP != nil { + listenAddr = &net.UDPAddr{IP: localIP, Port: 0} + debugf(opts.debug, "upnp: ssdp bind local_ip=%q", localIP.String()) + } else { + listenAddr = &net.UDPAddr{IP: net.IPv4zero, Port: 0} + } + + conn, err := net.ListenUDP("udp4", listenAddr) + if err != nil { + debugf(opts.debug, "upnp: ssdp listen failed local_ip=%q err=%v", ipString(localIP), err) + return + } + defer conn.Close() + _ = conn.SetDeadline(deadline) + + target, err := net.ResolveUDPAddr("udp4", ssdpAddress) + if err != nil { + debugf(opts.debug, "upnp: ssdp resolve failed err=%v", err) + return + } + + for _, searchTarget := range ssdpSearchTargets { + debugf(opts.debug, "upnp: ssdp search local_ip=%q target=%q", ipString(localIP), searchTarget) + search := strings.Join([]string{ + "M-SEARCH * HTTP/1.1", + "HOST: " + ssdpAddress, + `MAN: "ssdp:discover"`, + fmt.Sprintf("MX: %d", ssdpMaxWaitSeconds), + "ST: " + searchTarget, + "", + "", + }, "\r\n") + if _, err := conn.WriteToUDP([]byte(search), target); err != nil { + debugf(opts.debug, "upnp: ssdp write failed local_ip=%q target=%q err=%v", ipString(localIP), searchTarget, err) + } + } + + buf := make([]byte, 64*1024) + for { + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return + } + if ctx.Err() != nil { + return + } + debugf(opts.debug, "upnp: ssdp read failed local_ip=%q err=%v", ipString(localIP), err) + return + } + headers := parseSSDPHeaders(string(buf[:n])) + record("ssdp-response", headers) + } +} + +func discoverByNotify(ctx context.Context, deadline time.Time, opts discoverOptions, record func(string, textproto.MIMEHeader)) { + addr, err := net.ResolveUDPAddr("udp4", ssdpAddress) + if err != nil { + debugf(opts.debug, "upnp: notify resolve failed err=%v", err) + return + } + + conn, err := net.ListenMulticastUDP("udp4", nil, addr) + if err != nil { + debugf(opts.debug, "upnp: notify listen failed err=%v", err) + return + } + defer conn.Close() + _ = conn.SetReadBuffer(64 * 1024) + _ = conn.SetDeadline(deadline) + + debugf(opts.debug, "upnp: notify listen joined=%q", ssdpAddress) + buf := make([]byte, 64*1024) + for { + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return + } + if ctx.Err() != nil { + return + } + debugf(opts.debug, "upnp: notify read failed err=%v", err) + return + } + raw := string(buf[:n]) + if !strings.HasPrefix(strings.ToUpper(raw), "NOTIFY ") { + continue + } + headers := parseSSDPHeaders(raw) + record("ssdp-notify", headers) + } +} + +func debugf(w io.Writer, format string, args ...interface{}) { + if w == nil { + return + } + _, _ = fmt.Fprintf(w, format+"\n", args...) +} + +func privateIPv4Interfaces() []net.IP { + interfaces, err := net.Interfaces() + if err != nil { + return nil + } + + seen := map[string]struct{}{} + var values []net.IP + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP.To4() + if ip == nil || !isPrivateIPv4(ip) { + continue + } + key := ip.String() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + values = append(values, ip) + } + } + return values +} + +func isPrivateIPv4(ip net.IP) bool { + switch { + case ip[0] == 10: + return true + case ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31: + return true + case ip[0] == 192 && ip[1] == 168: + return true + default: + return false + } +} + +func ipString(ip net.IP) string { + if ip == nil { + return "0.0.0.0" + } + return ip.String() +} + +func parseSSDPHeaders(raw string) textproto.MIMEHeader { + header := textproto.MIMEHeader{} + lines := strings.Split(raw, "\r\n") + for _, line := range lines[1:] { + if strings.TrimSpace(line) == "" { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + header.Add(textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(key)), strings.TrimSpace(value)) + } + return header +} + +func fetchServiceDescription(ctx context.Context, client *http.Client, usn string, location string) (Service, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil) + if err != nil { + return Service{}, err + } + + resp, err := client.Do(req) + if err != nil { + return Service{}, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Service{}, fmt.Errorf("device description request failed: %s", resp.Status) + } + + var root rootDescription + if err := xml.NewDecoder(resp.Body).Decode(&root); err != nil { + return Service{}, err + } + + device, controlURL := findMediaServerDevice(root.Device, location, root.URLBase) + if controlURL == "" { + return Service{}, fmt.Errorf("media server does not expose content directory") + } + if device == nil { + return Service{}, fmt.Errorf("device is not a media server") + } + + id := strings.TrimSpace(device.UDN) + if id == "" { + id = strings.TrimSpace(usn) + } + if id == "" { + id = strings.TrimSpace(location) + } + + return Service{ + ID: id, + USN: usn, + FriendlyName: strings.TrimSpace(device.FriendlyName), + ModelName: strings.TrimSpace(device.ModelName), + Manufacturer: strings.TrimSpace(device.Manufacturer), + Location: location, + ContentDirectoryURL: controlURL, + }, nil +} + +func findMediaServerDevice(device rootDeviceRecord, location string, urlBase string) (*rootDeviceRecord, string) { + for _, service := range device.Services { + if service.ServiceType == "urn:schemas-upnp-org:service:ContentDirectory:1" { + controlURL := resolveServiceURL(location, urlBase, service.ControlURL) + return &device, controlURL + } + } + + for _, nested := range device.Devices { + if found, controlURL := findMediaServerDevice(nested, location, urlBase); found != nil && controlURL != "" { + return found, controlURL + } + } + + return nil, "" +} + +func resolveServiceURL(location string, urlBase string, ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + if parsed, err := url.Parse(ref); err == nil && parsed.IsAbs() { + return parsed.String() + } + + base := strings.TrimSpace(urlBase) + if base == "" { + if parsed, err := url.Parse(location); err == nil { + base = parsed.Scheme + "://" + parsed.Host + } + } + if base == "" { + return ref + } + + baseURL, err := url.Parse(base) + if err != nil { + return ref + } + refURL, err := url.Parse(ref) + if err != nil { + return ref + } + return baseURL.ResolveReference(refURL).String() +} + +func parseDIDL(reader io.Reader) ([]Entry, error) { + decoder := xml.NewDecoder(reader) + var entries []Entry + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + start, ok := token.(xml.StartElement) + if !ok { + continue + } + + switch start.Name.Local { + case "container": + entry, err := parseContainer(decoder, start) + if err != nil { + return nil, err + } + entries = append(entries, entry) + case "item": + entry, err := parseItem(decoder, start) + if err != nil { + return nil, err + } + if entry.MediaURL == "" { + continue + } + entries = append(entries, entry) + } + } + + return entries, nil +} + +func parseContainer(decoder *xml.Decoder, start xml.StartElement) (Entry, error) { + entry := Entry{ + ID: attr(start.Attr, "id"), + IsDir: true, + } + + for { + token, err := decoder.Token() + if err != nil { + return Entry{}, err + } + switch value := token.(type) { + case xml.StartElement: + if value.Name.Local == "title" { + var title string + if err := decoder.DecodeElement(&title, &value); err != nil { + return Entry{}, err + } + entry.Title = strings.TrimSpace(title) + } else { + if err := decoder.Skip(); err != nil { + return Entry{}, err + } + } + case xml.EndElement: + if value.Name.Local == start.Name.Local { + if entry.Title == "" { + entry.Title = entry.ID + } + entry.Meta = "Folder" + return entry, nil + } + } + } +} + +func parseItem(decoder *xml.Decoder, start xml.StartElement) (Entry, error) { + entry := Entry{ + ID: attr(start.Attr, "id"), + IsDir: false, + } + className := "" + + for { + token, err := decoder.Token() + if err != nil { + return Entry{}, err + } + switch value := token.(type) { + case xml.StartElement: + switch value.Name.Local { + case "title": + var title string + if err := decoder.DecodeElement(&title, &value); err != nil { + return Entry{}, err + } + entry.Title = strings.TrimSpace(title) + case "class": + if err := decoder.DecodeElement(&className, &value); err != nil { + return Entry{}, err + } + case "res": + var res string + if err := decoder.DecodeElement(&res, &value); err != nil { + return Entry{}, err + } + if entry.MediaURL == "" { + entry.MediaURL = strings.TrimSpace(res) + entry.MediaType = mimeTypeFromProtocolInfo(attr(value.Attr, "protocolInfo")) + entry.Size = parseInt64(attr(value.Attr, "size")) + } + default: + if err := decoder.Skip(); err != nil { + return Entry{}, err + } + } + case xml.EndElement: + if value.Name.Local == start.Name.Local { + if entry.Title == "" { + entry.Title = entry.ID + } + if !strings.Contains(strings.ToLower(className), "videoitem") && !strings.HasPrefix(strings.ToLower(entry.MediaType), "video/") { + entry.MediaURL = "" + } + entry.Meta = mediaMeta(entry.MediaType, entry.Size) + return entry, nil + } + } + } +} + +func mimeTypeFromProtocolInfo(value string) string { + parts := strings.Split(value, ":") + if len(parts) >= 3 { + return strings.TrimSpace(parts[2]) + } + return "" +} + +func mediaMeta(mediaType string, size int64) string { + parts := make([]string, 0, 2) + if mediaType != "" { + parts = append(parts, mediaType) + } + if size > 0 { + parts = append(parts, humanSize(size)) + } + return strings.Join(parts, " · ") +} + +func humanSize(size int64) string { + if size <= 0 { + return "" + } + const mb = 1024 * 1024 + if size < mb { + return fmt.Sprintf("%d KB", size/1024) + } + return fmt.Sprintf("%d MB", size/mb) +} + +func attr(attrs []xml.Attr, name string) string { + for _, attr := range attrs { + if attr.Name.Local == name { + return attr.Value + } + } + return "" +} + +func parseInt64(value string) int64 { + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed +} + +func xmlEscape(value string) string { + var builder strings.Builder + _ = xml.EscapeText(&builder, []byte(value)) + return builder.String() +} + +type rootDescription struct { + XMLName xml.Name `xml:"root"` + URLBase string `xml:"URLBase"` + Device rootDeviceRecord `xml:"device"` +} + +type rootDeviceRecord struct { + DeviceType string `xml:"deviceType"` + UDN string `xml:"UDN"` + FriendlyName string `xml:"friendlyName"` + ModelName string `xml:"modelName"` + Manufacturer string `xml:"manufacturer"` + Services []rootServiceRecord `xml:"serviceList>service"` + Devices []rootDeviceRecord `xml:"deviceList>device"` +} + +type rootServiceRecord struct { + ServiceType string `xml:"serviceType"` + ControlURL string `xml:"controlURL"` +} + +type browseEnvelope struct { + Body struct { + BrowseResponse struct { + Result string `xml:"Result"` + } `xml:"BrowseResponse"` + } `xml:"Body"` +}