package discovery import ( "context" "log/slog" "net/url" "sync" "time" ) const ( mediaServerDeviceType = "urn:schemas-upnp-org:device:MediaServer:1" cdsServiceType = "urn:schemas-upnp-org:service:ContentDirectory:1" // descFetchTimeout is a separate budget for description fetches so the // overall SSDP sweep timing does not cut them off. descFetchTimeout = 8 * time.Second ) // MediaServer is a discovered DLNA UPnP MediaServer that exposes a // ContentDirectory service. type MediaServer struct { // UDN is the stable unique device name (uuid:...) from the UPnP description. UDN string // FriendlyName is the human-readable device name, e.g. "FRITZ!Box 7590". FriendlyName string // Manufacturer and ModelName let callers show a useful device subtitle. Manufacturer string ModelName string // Address is the "host:port" of the device description endpoint. Address string // CDSControlURL is the fully resolved URL for ContentDirectory SOAP actions. // Empty string means the device does not expose ContentDirectory. CDSControlURL string // IconURL is the first icon the device advertised, resolved to absolute form. IconURL string } // DiscoverMediaServers sends SSDP M-SEARCH requests for MediaServer devices, // fetches each device description concurrently, and returns only the servers // that expose a ContentDirectory service. Deduplicated by UDN. func DiscoverMediaServers(ctx context.Context, timeout time.Duration) ([]MediaServer, error) { if timeout <= 0 { timeout = defaultTimeout } opts := SearchOptions{ Targets: []string{ mediaServerDeviceType, "ssdp:all", }, Timeout: timeout, } responses, err := SearchSSDP(ctx, opts) if err != nil { return nil, err } if len(responses) == 0 { return nil, nil } // Fetch descriptions concurrently. Use a fresh context so that the // description fetches are not cut off by the already-elapsed SSDP timeout. fctx, fcancel := context.WithTimeout(ctx, descFetchTimeout) defer fcancel() type fetchResult struct { srv MediaServer ok bool } results := make(chan fetchResult, len(responses)) var wg sync.WaitGroup for _, resp := range responses { wg.Add(1) go func(loc string) { defer wg.Done() desc, err := FetchDescription(fctx, loc) if err != nil { slog.Warn("mediaserver: description fetch failed", "location", loc, "err", err.Error()) results <- fetchResult{} return } srv, ok := mediaServerFromDescription(desc) results <- fetchResult{srv: srv, ok: ok} }(resp.Location) } wg.Wait() close(results) seen := map[string]struct{}{} var out []MediaServer for r := range results { if !r.ok || r.srv.CDSControlURL == "" || r.srv.UDN == "" { continue } if _, dup := seen[r.srv.UDN]; dup { continue } seen[r.srv.UDN] = struct{}{} out = append(out, r.srv) } return out, nil } // mediaServerFromDescription maps a parsed Description to a MediaServer. // Returns ok=false when the description does not expose a ContentDirectory // service (i.e. the device is not a usable DLNA media server). // // It walks the device tree so that nested MediaServer sub-devices (e.g. // FRITZ!Box root device nesting the NAS MediaServer) are found correctly. func mediaServerFromDescription(desc *Description) (MediaServer, bool) { if desc == nil { return MediaServer{}, false } svc, ok := desc.FindService(cdsServiceType) if !ok || svc.ControlURL == "" { return MediaServer{}, false } srv := MediaServer{ UDN: desc.Root.UDN, FriendlyName: desc.Root.FriendlyName, Manufacturer: desc.Root.Manufacturer, ModelName: desc.Root.ModelName, CDSControlURL: svc.ControlURL, } // Populate Address from the CDS control URL host so callers know which // host:port to reach the device on. The control URL is absolute after // FetchDescription resolves it; parse errors leave Address empty. if u, err := url.Parse(svc.ControlURL); err == nil { srv.Address = u.Host } // Walk sub-devices to fill in UDN / FriendlyName if the root is sparse // (some devices put it all in the sub-device, e.g. FRITZ!Box). fillFromTree(desc, &srv) if ic, ok := desc.FirstIcon(); ok { srv.IconURL = ic.URL } return srv, true } // fillFromTree walks the description tree to fill in missing fields on srv // from sub-devices. Only fills in fields that are still empty. func fillFromTree(desc *Description, srv *MediaServer) { walkDevice(&desc.Root, srv) } func walkDevice(dev *Device, srv *MediaServer) { if srv.FriendlyName == "" && dev.FriendlyName != "" { srv.FriendlyName = dev.FriendlyName } if srv.UDN == "" && dev.UDN != "" { srv.UDN = dev.UDN } if srv.Manufacturer == "" && dev.Manufacturer != "" { srv.Manufacturer = dev.Manufacturer } if srv.ModelName == "" && dev.ModelName != "" { srv.ModelName = dev.ModelName } for i := range dev.Devices { walkDevice(&dev.Devices[i], srv) } }