Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"gaspode/internal/chromecast"
"gaspode/internal/controlplane"
"gaspode/internal/logging"
"gaspode/internal/upnp"
"gaspode/internal/windowlist"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -45,6 +46,10 @@ type audioDevicesOptions struct {
toJSON bool
}

type upnpScanOptions struct {
toJSON bool
}

type sendOptions struct {
mediaURL string
mediaType string
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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
}
Loading
Loading