├── .gitignore ├── changelog.md ├── dial ├── dial.go ├── dial_test.go ├── ssdp.go ├── ssdp_test.go ├── wol.go └── wol_test.go ├── go.mod ├── license ├── makefile ├── play-on-tv.png ├── readme.md ├── release ├── youtube ├── remote.go ├── remote_test.go ├── util.go └── util_test.go └── ytcast.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.tmp 3 | tags 4 | ytcast 5 | ytcast-v* 6 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | changelog 2 | ========= 3 | 4 | ## v1.4.0 5 | 6 | 2024-01-28 7 | 8 | - added `-i` (interface) flag to specify the local interface (or ip address or 9 | hostname) to use for network operations (`fc5916a`). 10 | this is useful for hosts that have multiple network interfaces and wish to 11 | restrict `ytcast` operations to a specific interface. see issue #8. 12 | 13 | ## v1.3.0 14 | 15 | 2022-02-17 16 | 17 | - added pre-compiled binaries for macOS and Windows in the release assets 18 | (`86e47fd`, `125fd19`, `c973eba`) as requested in issue #5 and #6. 19 | this release doesn't include any changes to go code. 20 | 21 | ## v1.2.0 22 | 23 | 2022-02-15 24 | 25 | - added BUGS and workarounds sections in the readme (`7db2331`). 26 | - added `-pair` flag to manually pair a device with `ytcast` using a TV code (`7152d52`, `6f7b7d9`). 27 | - allow to start playing the first video from a specific time (`e4d504e`, `a595f8e`). 28 | 29 | ## v1.1.0 30 | 31 | 2022-02-04 32 | 33 | - added `-a` (add) flag which allows to add videos to the queue without changing 34 | what's currently playing (`1270918`, `b08e793`). the implementation is not 35 | very pretty due to the Lounge api, but gets the job done (most of the times). 36 | - `ytcast-bin` is available on the Arch User Repository (AUR)! this will make it 37 | easier for Arch Linux users to install the program. I'd like to thank the 38 | maintainer and julianYaman for letting me know. a link to the AUR package has 39 | been added in the readme (`b7741d7`). 40 | 41 | ## v1.0.0 42 | 43 | 2022-01-29 44 | 45 | - `-l` flag is used now to list cached devices without getting an error (`4f19d6c`), 46 | while `-p` is for selecting the last used device (`02f8ec8`). 47 | this is an incompatible change so here we are at `v1.0.0`, the first major release! 48 | - renamed `-n` flag to more intuitive `-d` (device) (`9368df8`). `-n` can still 49 | be used but is deprecated. 50 | - added a quick install script for binaries in the readme (`b3726d6`, `96af820`) 51 | and various other readme updates e.g. a TOC (`ca36350`). 52 | 53 | ## v0.5.0 54 | 55 | 2022-01-19 56 | 57 | - added a `release` script to automatically create new tag versions and GitHub 58 | releases (this release is actually the first one made with this script so it's 59 | kind of a final test for it). 60 | - pre-compiled binaries for different architectures are built with the new 61 | `makefile` target `cross-build` (`34fcee5`) and are attached to the GitHub 62 | release (`7d3910a`). 63 | - all the binaries built with the `makefile` are now statically linked and 64 | stripped (`626dc57`). 65 | 66 | ## v0.4.0 67 | 68 | 2022-01-10 69 | 70 | - various DIAL and SSDP implementation improvements (`1a4671e`, `c15daaa`, `5fdbcb1`). 71 | - print also initial part of USN (unique service name) when showing devices (`e24deb0`). 72 | - if `-n` doesn't match anything trigger a re-discover (`e4932b0`). 73 | - `-s` can now be used along with `-n` (`e4932b0`). 74 | - added `-c` (clear cache) flag (`d60cb9f`). 75 | 76 | ## v0.3.0 77 | 78 | 2022-01-08 79 | 80 | - removed microseconds from `-verbose` log (`5b96d81`). 81 | - rediscover device after Wake-On-Lan since ip address and ports can change (`bff10f5`). 82 | - use `http.Client` with proper timeout (`e390f0b`). 83 | 84 | ## v0.2.0 85 | 86 | 2022-01-05 87 | 88 | - exit with error if more than one device matches `-n` (`3f7f820`). 89 | - `readme.md` is no longer a draft! 90 | - added `install` and `uninstall` targets to `makefile` (`e9c96d3`). 91 | - use `$USER@$HOSTNAME` as "connect" name (`a518a67`). 92 | - fixed some YouTube Lounge api calls that stopped working (`f3fde46`). 93 | 94 | ## v0.1.0 95 | 96 | 2021-12-14 97 | 98 | - repo is public! this is the initial version, core functionality works! 99 | -------------------------------------------------------------------------------- /dial/dial.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | // Package dial implements a basic DIAL (DIscovery And Launch) client. 4 | // See http://www.dial-multiscreen.org/ 5 | package dial 6 | 7 | import ( 8 | "encoding/xml" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net" 14 | "net/http" 15 | "net/url" 16 | "path" 17 | "regexp" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | "time" 22 | ) 23 | 24 | const ( 25 | dialSearchTarget = "urn:dial-multiscreen-org:service:dial:1" 26 | 27 | httpTimeout = 5 * time.Second 28 | 29 | contentType = "text/plain; charset=utf-8" 30 | 31 | wakeupBroadcastAddr = "255.255.255.255:9" 32 | wakeupMinTimeout = 10 * time.Second 33 | wakeupMaxTimeout = 2 * time.Minute 34 | ) 35 | 36 | var ( 37 | wakeupParseRe = regexp.MustCompile(`MAC=(.+);Timeout=(\d+)`) 38 | 39 | errNoAppUrl = errors.New("missing Application-URL header") 40 | errNoMac = errors.New("missing device MAC address") 41 | errNoWakeup = errors.New("unable to wakeup device") 42 | ) 43 | 44 | // Device is a DIAL server device discovered on the network. 45 | type Device struct { 46 | localAddr string // localAddr is the local address the Device instance must use for network operations. 47 | httpClient *http.Client // httpClient is an http.Client setup to use localAddr. 48 | 49 | UniqueServiceName string // UniqueServiceName from the ssdpService. 50 | Location string // Location from the ssdpService. 51 | ApplicationUrl string // base DIAL REST service url. 52 | FriendlyName string // UPnP friendlyName field of the device description. 53 | Wakeup Wakeup // WAKEUP header values from the ssdpService (if available). 54 | } 55 | 56 | // Wakeup contains values of WAKEUP header from the ssdpService that can be used 57 | // to WoL or WoWLAN the device. 58 | type Wakeup struct { 59 | Mac string // MAC address of the device's wired or wireless network interface. 60 | Timeout time.Duration // estimated upper bound of the duration needed to wake the device and start its DIAL server. 61 | } 62 | 63 | // AppInfo contains information about an application on a specific Device. 64 | type AppInfo struct { 65 | Name string `xml:"name"` 66 | 67 | // State valid values are: 68 | // - running: the application is installed and either starting or running; 69 | // - stopped: the application is installed and not running; 70 | // - installable=: the application is not installed but is 71 | // available for installation by sending an HTTP GET request to the 72 | // provided URL; 73 | // - hidden: the application is running but is not visible to the user; 74 | // 75 | // any other value is invalid and should be ignored. 76 | State string `xml:"state"` 77 | 78 | Options struct { 79 | // AllowStop true indicates that the application can be stopped 80 | // (if running) sending an HTTP DELETE request to Link.Href. 81 | AllowStop bool `xml:"allowStop,attr"` 82 | } `xml:"options"` 83 | 84 | // Link is included when the application is running and can be stopped 85 | // using a DELETE request. 86 | Link struct { 87 | // Rel is always "run". 88 | Rel string `xml:"rel,attr"` 89 | 90 | // Href contains instance URL of the running application. 91 | Href string `xml:"href,attr"` 92 | } `xml:"link"` 93 | 94 | Additional struct { 95 | // Additional.Data contains zero or more (dynamic) XML elements 96 | // specific to the application. 97 | Data string `xml:",innerxml"` 98 | } `xml:"additionalData"` 99 | } 100 | 101 | // Discover discovers (unique) DIAL server devices on the network. 102 | func Discover(done chan struct{}, localAddr string, timeout time.Duration) (chan *Device, error) { 103 | hc, err := newHTTPClient(localAddr) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | ssdpCh, err := mSearch(localAddr, dialSearchTarget, done, timeout) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | devCh := make(chan *Device) 114 | var wg sync.WaitGroup 115 | wg.Add(1) 116 | go func() { 117 | defer wg.Done() 118 | seen := make(map[string]bool) 119 | for service := range ssdpCh { 120 | if service.searchTarget != dialSearchTarget || seen[service.uniqueServiceName] { 121 | continue 122 | } 123 | seen[service.uniqueServiceName] = true 124 | wg.Add(1) 125 | go func(service *ssdpService) { 126 | defer wg.Done() 127 | respBody, headers, err := doReq(hc, "GET", service.location, "", "") 128 | if err != nil { 129 | log.Println(err) 130 | return 131 | } 132 | dev, err := parseDevice(service, respBody, headers) 133 | if err != nil { 134 | log.Printf("%s: parseDevice: %s", service.location, err) 135 | return 136 | } 137 | if err := dev.SetLocalAddr(localAddr); err != nil { 138 | log.Printf("%s: SetLocalAddr: %s", dev.FriendlyName, err) 139 | return 140 | } 141 | log.Printf("discovered DIAL device %q", dev.FriendlyName) 142 | select { 143 | case devCh <- dev: 144 | case <-done: 145 | } 146 | }(service) 147 | } 148 | }() 149 | 150 | go func() { 151 | wg.Wait() 152 | close(devCh) 153 | }() 154 | 155 | return devCh, nil 156 | } 157 | 158 | func newHTTPClient(localAddr string) (*http.Client, error) { 159 | hc := &http.Client{Timeout: httpTimeout} 160 | if localAddr == "" { 161 | return hc, nil 162 | } 163 | laddr, err := net.ResolveTCPAddr("tcp", localAddr) 164 | if err != nil { 165 | return nil, err 166 | } 167 | hc.Transport = http.DefaultTransport.(*http.Transport).Clone() 168 | hc.Transport.(*http.Transport).DialContext = (&net.Dialer{ 169 | LocalAddr: laddr, 170 | Timeout: httpTimeout, 171 | KeepAlive: httpTimeout, 172 | }).DialContext 173 | return hc, nil 174 | } 175 | 176 | func doReq(httpClient *http.Client, method, url string, origin, body string) ([]byte, http.Header, error) { 177 | req, err := http.NewRequest(method, url, strings.NewReader(body)) 178 | if err != nil { 179 | return nil, nil, err 180 | } 181 | if origin != "" { 182 | req.Header.Set("Origin", origin) 183 | } 184 | if body != "" { 185 | req.Header.Set("Content-Type", contentType) 186 | } 187 | 188 | log.Printf("%s %s", method, url) 189 | resp, err := httpClient.Do(req) 190 | if err != nil { 191 | return nil, nil, err 192 | } 193 | defer resp.Body.Close() 194 | respBody, err := io.ReadAll(resp.Body) 195 | if err == nil && (resp.StatusCode < 200 || resp.StatusCode > 299) { 196 | err = fmt.Errorf("%s %s: %s: %w", method, url, resp.Status, errBadHttpStatus) 197 | } 198 | return respBody, resp.Header, err 199 | } 200 | 201 | func parseDevice(service *ssdpService, desc []byte, descHeaders http.Header) (*Device, error) { 202 | appUrl := strings.TrimSpace(descHeaders.Get("Application-URL")) 203 | if appUrl == "" { 204 | return nil, errNoAppUrl 205 | } 206 | 207 | var v struct { 208 | FriendlyName string `xml:"device>friendlyName"` 209 | } 210 | if err := xml.Unmarshal(desc, &v); err != nil { 211 | return nil, err 212 | } 213 | 214 | dev := &Device{ 215 | UniqueServiceName: service.uniqueServiceName, 216 | Location: service.location, 217 | ApplicationUrl: appUrl, 218 | FriendlyName: v.FriendlyName, 219 | Wakeup: parseWakeup(service.headers.Get("WAKEUP")), 220 | } 221 | return dev, nil 222 | } 223 | 224 | func parseWakeup(v string) Wakeup { 225 | if v == "" { 226 | return Wakeup{} 227 | } 228 | fields := wakeupParseRe.FindStringSubmatch(v) 229 | if len(fields) != 3 { 230 | return Wakeup{} 231 | } 232 | mac := fields[1] 233 | timeout, err := strconv.Atoi(fields[2]) 234 | if err != nil || timeout < 0 { 235 | return Wakeup{} 236 | } 237 | return Wakeup{Mac: mac, Timeout: time.Duration(timeout) * time.Second} 238 | } 239 | 240 | // SetLocalAddr sets the local address the Device instance must use for network 241 | // operations. 242 | func (d *Device) SetLocalAddr(localAddr string) error { 243 | if d.httpClient != nil && d.localAddr == localAddr { 244 | return nil // localAddr already set, no need to recreate an http.Client. 245 | } 246 | hc, err := newHTTPClient(localAddr) 247 | if err != nil { 248 | return err 249 | } 250 | d.localAddr = localAddr 251 | d.httpClient = hc 252 | return nil 253 | } 254 | 255 | // GetAppInfo returns information about an application on the Device. 256 | // appName should be an application name registered in the DIAL Registry. 257 | // origin (if present) will be passed as Origin HTTP header. 258 | func (d *Device) GetAppInfo(appName, origin string) (*AppInfo, error) { 259 | u, err := urlJoin(d.ApplicationUrl, appName) 260 | if err != nil { 261 | return nil, err 262 | } 263 | respBody, _, err := doReq(d.httpClient, "GET", u, origin, "") 264 | if err != nil { 265 | return nil, err 266 | } 267 | var appInfo AppInfo 268 | if err := xml.Unmarshal(respBody, &appInfo); err != nil { 269 | return nil, err 270 | } 271 | return &appInfo, nil 272 | } 273 | 274 | func urlJoin(base, end string) (string, error) { 275 | u, err := url.Parse(base) 276 | if err != nil { 277 | return "", err 278 | } 279 | u.Path = path.Join(u.Path, end) 280 | return u.String(), nil 281 | } 282 | 283 | // Launch launches (starts) an application on the Device and returns its 284 | // instance url (if available). 285 | // appName should be an application name registered in the DIAL Registry. 286 | // origin (if present) will be passed as Origin HTTP header. 287 | // payload (if present) will be passed as HTTP message body with 288 | // Content-Type: text/plain; charset=utf-8 header. 289 | func (d *Device) Launch(appName, origin, payload string) (string, error) { 290 | u, err := urlJoin(d.ApplicationUrl, appName) 291 | if err != nil { 292 | return "", err 293 | } 294 | _, headers, err := doReq(d.httpClient, "POST", u, origin, payload) 295 | if err != nil { 296 | return "", err 297 | } 298 | return headers.Get("Location"), nil 299 | } 300 | 301 | // TryWakeup tries to Wake-On-Lan the Device sending magic packets to its MAC 302 | // address and waiting for it to become available. It eventually updates 303 | // Location and ApplicationUrl (re-Discover) because the Device may have changed 304 | // ip address and/or service ports. 305 | // Returns nil if it successfully wakes up the Device. 306 | func (d *Device) TryWakeup() error { 307 | if d.Wakeup.Mac == "" { 308 | return errNoMac 309 | } 310 | done := make(chan struct{}) 311 | defer close(done) 312 | timeout := clamp(d.Wakeup.Timeout*2, wakeupMinTimeout, wakeupMaxTimeout) 313 | for start := time.Now(); time.Since(start) < timeout; { 314 | if err := wakeOnLan(d.Wakeup.Mac, d.localAddr, wakeupBroadcastAddr); err != nil { 315 | return err 316 | } 317 | if d.Ping() { 318 | return nil 319 | } 320 | // Ping() may have failed because the device changed ip or port. 321 | devCh, err := Discover(done, d.localAddr, MSearchMinTimeout+1*time.Second) 322 | if err != nil { 323 | return fmt.Errorf("Discover: %w", err) 324 | } 325 | for updatedDev := range devCh { 326 | if updatedDev.UniqueServiceName == d.UniqueServiceName { 327 | *d = *updatedDev 328 | return nil 329 | } 330 | } 331 | } 332 | return errNoWakeup 333 | } 334 | 335 | // Ping returns true if the Device is up i.e. if it responds to requests. 336 | func (d *Device) Ping() bool { 337 | _, _, err := doReq(d.httpClient, "GET", d.ApplicationUrl, "", "") 338 | if err != nil && errors.Is(err, errBadHttpStatus) { 339 | return true 340 | } 341 | return err == nil 342 | } 343 | 344 | // Hostname returns the Device's hostname extracted from ApplicationUrl. 345 | func (d *Device) Hostname() string { 346 | u, err := url.Parse(d.ApplicationUrl) 347 | if err != nil { 348 | return "" 349 | } 350 | return u.Hostname() 351 | } 352 | -------------------------------------------------------------------------------- /dial/dial_test.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package dial 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "encoding/xml" 9 | "io" 10 | "net/http" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestParseDevice(t *testing.T) { 16 | tests := []struct { 17 | resp []byte 18 | mustErr bool 19 | service *ssdpService 20 | device *Device 21 | }{ 22 | { 23 | resp: []byte("HTTP/1.1 200 OK\r\n" + 24 | "Connection: Close\r\n" + 25 | "Application-URL: http://192.168.1.1:12345/apps\r\n" + 26 | "Content-Type: text/plain; charset=US-ASCII\r\n" + 27 | "\r\n" + 28 | ` 29 | 30 | 31 | 1 32 | 0 33 | 34 | 35 | urn:dial-multiscreen-org:device:dial:1 36 | Friendly FOO BAR 37 | FOO 38 | BAR 39 | device-UUID 40 | 41 | 42 | urn:dial-multiscreen-org:service:dial:1 43 | urn:dial-multiscreen-org:serviceId:dial 44 | /upnp/dev/device-UUID/svc/dial-multiscreen-org/dial/desc 45 | /upnp/dev/device-UUID/svc/dial-multiscreen-org/dial/action 46 | /upnp/dev/device-UUID/svc/dial-multiscreen-org/dial/event 47 | 48 | 49 | 50 | `), 51 | mustErr: false, 52 | service: &ssdpService{ 53 | uniqueServiceName: "device-UUID", 54 | location: "http://192.168.1.1:52235/dd.xml", 55 | searchTarget: "urn:dial-multiscreen-org:service:dial:1", 56 | headers: map[string][]string{ 57 | "Server": []string{"OS/version UPnP/1.1 product/version"}, 58 | "Wakeup": []string{"MAC=10:dd:b1:c9:00:e4;Timeout=60"}, 59 | }, 60 | }, 61 | device: &Device{ 62 | UniqueServiceName: "device-UUID", 63 | Location: "http://192.168.1.1:52235/dd.xml", 64 | ApplicationUrl: "http://192.168.1.1:12345/apps", 65 | FriendlyName: "Friendly FOO BAR", 66 | Wakeup: Wakeup{ 67 | Mac: "10:dd:b1:c9:00:e4", 68 | Timeout: 60 * time.Second, 69 | }, 70 | }, 71 | }, { 72 | resp: []byte("HTTP/1.1 200 OK\r\n" + 73 | "Connection: Close\r\n" + 74 | "Content-Type: text/plain; charset=US-ASCII\r\n" + 75 | "\r\n" + 76 | ` 77 | 78 | 79 | `), 80 | mustErr: true, 81 | }, 82 | } 83 | 84 | for i, test := range tests { 85 | respBody, headers := makeResp(t, test.resp) 86 | device, err := parseDevice(test.service, respBody, headers) 87 | if err == nil { 88 | if test.mustErr { 89 | t.Fatalf("tests[%d]: was expecting error but got nil", i) 90 | } 91 | } else { 92 | if !test.mustErr { 93 | t.Fatalf("tests[%d]: unexpected error: %s", i, err) 94 | } 95 | continue 96 | } 97 | if test.device.UniqueServiceName != device.UniqueServiceName { 98 | t.Fatalf("tests[%d]: device.UniqueServiceName: want %q got %q", i, test.device.UniqueServiceName, device.UniqueServiceName) 99 | } 100 | if test.device.Location != device.Location { 101 | t.Fatalf("tests[%d]: device.Location: want %q got %q", i, test.device.Location, device.Location) 102 | } 103 | if test.device.ApplicationUrl != device.ApplicationUrl { 104 | t.Fatalf("tests[%d]: device.ApplicationUrl: want %q got %q", i, test.device.ApplicationUrl, device.ApplicationUrl) 105 | } 106 | if test.device.FriendlyName != device.FriendlyName { 107 | t.Fatalf("tests[%d]: device.FriendlyName: want %q got %q", i, test.device.FriendlyName, device.FriendlyName) 108 | } 109 | if test.device.Wakeup.Mac != device.Wakeup.Mac { 110 | t.Fatalf("tests[%d]: device.Wakeup.Mac: want %q got %q", i, test.device.Wakeup.Mac, device.Wakeup.Mac) 111 | } 112 | if test.device.Wakeup.Timeout != device.Wakeup.Timeout { 113 | t.Fatalf("tests[%d]: device.Wakeup.Timeout: want %d got %d", i, test.device.Wakeup.Timeout, device.Wakeup.Timeout) 114 | } 115 | } 116 | } 117 | 118 | func makeResp(t *testing.T, raw []byte) ([]byte, http.Header) { 119 | resp, err := http.ReadResponse(bufio.NewReader(bytes.NewBuffer(raw)), nil) 120 | if err != nil { 121 | t.Fatalf("unexpected error: %s", err) 122 | } 123 | defer resp.Body.Close() 124 | respBody, err := io.ReadAll(resp.Body) 125 | if err != nil { 126 | t.Fatalf("unexpected error: %s", err) 127 | } 128 | return respBody, resp.Header 129 | } 130 | 131 | func TestUnmarshalAppInfo(t *testing.T) { 132 | tests := []struct { 133 | resp []byte 134 | appInfo *AppInfo 135 | }{ 136 | { 137 | resp: []byte(` 138 | 139 | YouTube 140 | 141 | running 142 | 143 | `), 144 | appInfo: &AppInfo{ 145 | Name: "YouTube", 146 | State: "running", 147 | Options: struct { 148 | AllowStop bool `xml:"allowStop,attr"` 149 | }{AllowStop: true}, 150 | Link: struct { 151 | Rel string `xml:"rel,attr"` 152 | Href string `xml:"href,attr"` 153 | }{Rel: "run", Href: "run"}, 154 | }, 155 | }, { 156 | resp: []byte(` 157 | 158 | 159 | YouTube 160 | 161 | running 162 | 163 | screen123token123 164 | `), 165 | appInfo: &AppInfo{ 166 | Name: "YouTube", 167 | State: "running", 168 | Options: struct { 169 | AllowStop bool `xml:"allowStop,attr"` 170 | }{AllowStop: true}, 171 | Link: struct { 172 | Rel string `xml:"rel,attr"` 173 | Href string `xml:"href,attr"` 174 | }{Rel: "run", Href: "run"}, 175 | Additional: struct { 176 | Data string `xml:",innerxml"` 177 | }{Data: "screen123token123"}, 178 | }, 179 | }, 180 | } 181 | 182 | for i, test := range tests { 183 | var appInfo AppInfo 184 | err := xml.Unmarshal(test.resp, &appInfo) 185 | if err != nil { 186 | t.Fatalf("tests[%d]: unexpected error: %q", i, err) 187 | } 188 | if test.appInfo.Name != appInfo.Name { 189 | t.Fatalf("tests[%d]: appInfo.Name: want %q got %q", i, test.appInfo.Name, appInfo.Name) 190 | } 191 | if test.appInfo.State != appInfo.State { 192 | t.Fatalf("tests[%d]: appInfo.State: want %q got %q", i, test.appInfo.State, appInfo.State) 193 | } 194 | if test.appInfo.Options.AllowStop != appInfo.Options.AllowStop { 195 | t.Fatalf("tests[%d]: appInfo.Options.AllowStop: want %t got %t", i, test.appInfo.Options.AllowStop, appInfo.Options.AllowStop) 196 | } 197 | if test.appInfo.Link.Rel != appInfo.Link.Rel { 198 | t.Fatalf("tests[%d]: appInfo.Link.Rel: want %q got %q", i, test.appInfo.Link.Rel, appInfo.Link.Rel) 199 | } 200 | if test.appInfo.Link.Href != appInfo.Link.Href { 201 | t.Fatalf("tests[%d]: appInfo.Link.Href: want %q got %q", i, test.appInfo.Link.Href, appInfo.Link.Href) 202 | } 203 | if test.appInfo.Additional.Data != appInfo.Additional.Data { 204 | t.Fatalf("tests[%d]: appInfo.Additional.Data: want %q got %q", i, test.appInfo.Additional.Data, appInfo.Additional.Data) 205 | } 206 | } 207 | } 208 | 209 | func TestParseWakeup(t *testing.T) { 210 | tests := []struct { 211 | value string 212 | wakeup Wakeup 213 | }{ 214 | {value: "MAC=10:dd:b1:c9:00:e4;Timeout=10", wakeup: Wakeup{Mac: "10:dd:b1:c9:00:e4", Timeout: 10 * time.Second}}, 215 | {value: "MAC=foo Timeout=bar", wakeup: Wakeup{Mac: "", Timeout: 0}}, 216 | {value: "", wakeup: Wakeup{Mac: "", Timeout: 0}}, 217 | } 218 | 219 | for i, test := range tests { 220 | wakeup := parseWakeup(test.value) 221 | if test.wakeup.Mac != wakeup.Mac { 222 | t.Fatalf("tests[%d]: wakeup.Mac: want %q got %q", i, test.wakeup.Mac, wakeup.Mac) 223 | } 224 | if test.wakeup.Timeout != wakeup.Timeout { 225 | t.Fatalf("tests[%d]: wakeup.Timeout: want %s got %s", i, test.wakeup.Timeout, wakeup.Timeout) 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /dial/ssdp.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | // This file implements the SSDP (Simple Service Discovery Protocol) portion 4 | // used by the DIAL protocol (i.e. the M-SEARCH request). 5 | 6 | package dial 7 | 8 | import ( 9 | "bufio" 10 | "bytes" 11 | "errors" 12 | "fmt" 13 | "log" 14 | "net" 15 | "net/http" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | const ( 21 | ssdpMulticastAddr = "239.255.255.250:1900" 22 | 23 | mSearchMan = "ssdp:discover" 24 | mSearchMx = 3 25 | 26 | MSearchMinTimeout = time.Duration(mSearchMx)*time.Second + 1*time.Second 27 | MSearchMaxTimeout = 2 * time.Minute 28 | mSearchMaxRespSize = 4096 29 | ) 30 | 31 | var ( 32 | errBadHttpStatus = errors.New("bad HTTP response status") 33 | errNoUSN = errors.New("missing USN header") 34 | errNoLocation = errors.New("missing LOCATION header") 35 | errNoST = errors.New("missing ST header") 36 | ) 37 | 38 | // ssdpService is a network service discovered with an SSDP M-SEARCH request. 39 | type ssdpService struct { 40 | uniqueServiceName string // composite unique service identifier. 41 | location string // URL to the UPnP description of the root device. 42 | searchTarget string // single URI, depends on the ST header sent in the M-SEARCH request. 43 | headers http.Header // all headers contained in the M-SEARCH response. 44 | } 45 | 46 | // mSearch discovers network services sending an SSDP M-SEARCH request. 47 | func mSearch(localAddr, searchTarget string, done chan struct{}, timeout time.Duration) (chan *ssdpService, error) { 48 | timeout = clamp(timeout, MSearchMinTimeout, MSearchMaxTimeout) 49 | 50 | maddr, err := net.ResolveUDPAddr("udp4", ssdpMulticastAddr) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | var laddr *net.UDPAddr 56 | if localAddr != "" { 57 | if laddr, err = net.ResolveUDPAddr("udp4", localAddr); err != nil { 58 | return nil, err 59 | } 60 | } 61 | 62 | conn, err := net.ListenUDP("udp4", laddr) 63 | if err != nil { 64 | return nil, err 65 | } 66 | if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { 67 | conn.Close() // can't defer before goroutine. 68 | return nil, err 69 | } 70 | 71 | req := bytes.NewBufferString("M-SEARCH * HTTP/1.1\r\n") 72 | fmt.Fprintf(req, "HOST: %s\r\n", ssdpMulticastAddr) 73 | fmt.Fprintf(req, "MAN: %q\r\n", mSearchMan) // must be quoted 74 | fmt.Fprintf(req, "ST: %s\r\n", searchTarget) 75 | fmt.Fprintf(req, "MX: %d\r\n", mSearchMx) 76 | req.WriteString("\r\n") 77 | log.Printf("M-SEARCH udp %s ST %q MX %d timeout %s", ssdpMulticastAddr, searchTarget, mSearchMx, timeout) 78 | if _, err := conn.WriteTo(req.Bytes(), maddr); err != nil { 79 | conn.Close() // can't defer before goroutine. 80 | return nil, err 81 | } 82 | 83 | ch := make(chan *ssdpService) 84 | go func() { 85 | defer conn.Close() 86 | defer close(ch) 87 | 88 | buf := make([]byte, mSearchMaxRespSize) 89 | for { 90 | _, raddr, err := conn.ReadFrom(buf) 91 | if err != nil { 92 | log.Println(err) 93 | return 94 | } 95 | service, err := parseMSearchResp(buf) 96 | if err != nil { 97 | log.Printf("parseMSearchResp udp %s: %s", raddr, err) 98 | continue 99 | } 100 | log.Printf("discovered service %s", service.location) 101 | select { 102 | case ch <- service: 103 | case <-done: 104 | return 105 | } 106 | } 107 | }() 108 | return ch, nil 109 | } 110 | 111 | func clamp(d, min, max time.Duration) time.Duration { 112 | if d < min { 113 | return min 114 | } 115 | if d > max { 116 | return max 117 | } 118 | return d 119 | } 120 | 121 | func parseMSearchResp(data []byte) (*ssdpService, error) { 122 | resp, err := http.ReadResponse(bufio.NewReader(bytes.NewBuffer(data)), nil) 123 | if err != nil { 124 | return nil, err 125 | } 126 | defer resp.Body.Close() 127 | 128 | if resp.StatusCode != 200 { 129 | return nil, fmt.Errorf("%s: %w", resp.Status, errBadHttpStatus) 130 | } 131 | 132 | service := &ssdpService{headers: resp.Header} 133 | 134 | if service.uniqueServiceName = strings.TrimSpace(service.headers.Get("USN")); service.uniqueServiceName == "" { 135 | return nil, errNoUSN 136 | } 137 | if service.location = strings.TrimSpace(service.headers.Get("LOCATION")); service.location == "" { 138 | return nil, errNoLocation 139 | } 140 | if service.searchTarget = strings.TrimSpace(service.headers.Get("ST")); service.searchTarget == "" { 141 | return nil, errNoST 142 | } 143 | return service, nil 144 | } 145 | -------------------------------------------------------------------------------- /dial/ssdp_test.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package dial 4 | 5 | import ( 6 | "testing" 7 | ) 8 | 9 | func TestParseMSearchResp(t *testing.T) { 10 | tests := []struct { 11 | resp []byte 12 | mustErr bool 13 | service *ssdpService 14 | }{ 15 | { 16 | resp: []byte("HTTP/1.1 200 OK\r\n" + 17 | "LOCATION: http://192.168.1.1:52235/dd.xml\r\n" + 18 | "CACHE-CONTROL: max-age=1800\r\n" + 19 | "EXT:\r\n" + 20 | "BOOTID.UPNP.ORG: 1\r\n" + 21 | "SERVER: OS/version UPnP/1.1 product/version\r\n" + 22 | "USN: uuid-foo-bar-baz\r\n" + 23 | "ST: urn:dial-multiscreen-org:service:dial:1\r\n" + 24 | "WAKEUP: MAC=10:dd:b1:c9:00:e4;Timeout=10\r\n" + 25 | "\r\n"), 26 | mustErr: false, 27 | service: &ssdpService{ 28 | uniqueServiceName: "uuid-foo-bar-baz", 29 | location: "http://192.168.1.1:52235/dd.xml", 30 | searchTarget: dialSearchTarget, 31 | }, 32 | }, { 33 | resp: []byte("HTTP/1.1 200 OK\r\n" + 34 | "FOO\r\n" + 35 | "BAR\r\n" + 36 | "\r\n"), 37 | mustErr: true, 38 | }, { 39 | resp: []byte("HTTP/1.1 500 Internal Server Error\r\n\r\n"), 40 | mustErr: true, 41 | }, { 42 | 43 | resp: []byte("HTTP/1.1 200 OK\r\n" + 44 | "LOCATION: http://192.168.1.1:52235/dd.xml\r\n" + 45 | "ST: urn:dial-multiscreen-org:service:dial:1\r\n" + 46 | "\r\n"), 47 | mustErr: true, 48 | }, { 49 | 50 | resp: []byte("HTTP/1.1 200 OK\r\n" + 51 | "USN: device UUID\r\n" + 52 | "ST: urn:dial-multiscreen-org:service:dial:1\r\n" + 53 | "\r\n"), 54 | mustErr: true, 55 | }, { 56 | resp: []byte("HTTP/1.1 200 OK\r\n" + 57 | "LOCATION: http://192.168.1.1:52235/dd.xml\r\n" + 58 | "USN: device UUID\r\n" + 59 | "\r\n"), 60 | mustErr: true, 61 | }, 62 | } 63 | 64 | for i, test := range tests { 65 | service, err := parseMSearchResp(test.resp) 66 | if err == nil { 67 | if test.mustErr { 68 | t.Fatalf("tests[%d]: was expecting error but got nil", i) 69 | } 70 | } else { 71 | if !test.mustErr { 72 | t.Fatalf("tests[%d]: unexpected error: %s", i, err) 73 | } 74 | continue 75 | } 76 | if test.service.uniqueServiceName != service.uniqueServiceName { 77 | t.Fatalf("tests[%d]: service.uniqueServiceName: want %q got %q", i, test.service.uniqueServiceName, service.uniqueServiceName) 78 | } 79 | if test.service.location != service.location { 80 | t.Fatalf("tests[%d]: service.location: want %q got %q", i, test.service.location, service.location) 81 | } 82 | if test.service.searchTarget != service.searchTarget { 83 | t.Fatalf("tests[%d]: service.searchTarget: want %q got %q", i, test.service.searchTarget, service.searchTarget) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /dial/wol.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package dial 4 | 5 | import "net" 6 | 7 | // wakeOnLan sends a magic packet to wake-on-lan a computer on the network, see 8 | // https://en.wikipedia.org/wiki/Wake-on-LAN 9 | // The magic packet is composed by 6 times 0xff followed by 16 times the MAC 10 | // address and it's sent using UDP. 11 | // baddr is UDP's destination address, should be a broadcast address, usually 12 | // "255.255.255.255:9" is a good choice (limited broadcast address and 13 | // discard port). 14 | func wakeOnLan(mac, laddr, baddr string) error { 15 | addr, err := net.ParseMAC(mac) 16 | if err != nil { 17 | return err 18 | } 19 | var d net.Dialer 20 | if laddr != "" { 21 | if d.LocalAddr, err = net.ResolveUDPAddr("udp", laddr); err != nil { 22 | return err 23 | } 24 | } 25 | magic := makeMagicPacket(addr) 26 | conn, err := d.Dial("udp", baddr) 27 | if err != nil { 28 | return err 29 | } 30 | defer conn.Close() 31 | _, err = conn.Write(magic) 32 | return err 33 | } 34 | 35 | func makeMagicPacket(addr net.HardwareAddr) []byte { 36 | var magic []byte 37 | for i := 0; i < 6; i++ { 38 | magic = append(magic, 0xff) 39 | } 40 | for i := 0; i < 16; i++ { 41 | magic = append(magic, addr...) 42 | } 43 | return magic 44 | } 45 | -------------------------------------------------------------------------------- /dial/wol_test.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package dial 4 | 5 | import ( 6 | "net" 7 | "testing" 8 | ) 9 | 10 | func TestWakeOnLan(t *testing.T) { 11 | mac := "" // put your target MAC here 12 | baddr := "255.255.255.255:9" 13 | 14 | if mac == "" { 15 | t.SkipNow() 16 | } 17 | if err := wakeOnLan(mac, "", baddr); err != nil { 18 | t.Fatalf("unexpected error: %s", err) 19 | } 20 | } 21 | 22 | func TestMakeMagicPacket(t *testing.T) { 23 | macs := []string{ 24 | "71:5f:9f:60:0c:30", 25 | "e5:c0:7f:91:99:c8", 26 | "4f:6f:de:d5:72:20", 27 | "db:e9:15:e9:f5:9d", 28 | "ea:79:93:77:db:cd", 29 | "ff:e3:50:90:81:00", 30 | "96:10:d6:62:14:a5", 31 | "0b:b7:ad:92:02:a4", 32 | "ec:e7:e3:c2:1d:6e", 33 | "56:53:57:28:6f:6c", 34 | } 35 | 36 | for i, mac := range macs { 37 | addr, err := net.ParseMAC(mac) 38 | if err != nil { 39 | t.Fatalf("%d: unexpected error: %s", i, err) 40 | } 41 | magic := makeMagicPacket(addr) 42 | var j int 43 | for j = 0; j < 6; j++ { 44 | if magic[j] != 0xff { 45 | t.Fatalf("%d: magic[%d]: want 0xff got 0x%02x", i, j, magic[j]) 46 | } 47 | } 48 | for k := 0; k < 16; k++ { 49 | for z := 0; z < len(addr); z, j = z+1, j+1 { 50 | if magic[j] != addr[z] { 51 | f := "%d: %q: magic[%d] != addr[%d]: want 0x%02x got 0x%02x" 52 | t.Fatalf(f, i, mac, j, z, addr[z], magic[j]) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MarcoLucidi01/ytcast 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marco Lucidi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .POSIX: 2 | .PHONY: all build test fmt vet cross-build major-release minor-release patch-release install uninstall clean 3 | 4 | VERSION = $(shell git describe --tags) 5 | GO = go 6 | GOFLAGS = -trimpath -tags netgo,osusergo -ldflags="-w -s -X main.progVersion=$(VERSION)" 7 | RELEASE = ./release 8 | PREFIX = /usr/local 9 | PROGNAME = ytcast 10 | CROSSTARGETS = linux-386 linux-amd64 linux-arm linux-arm64 darwin-amd64 darwin-arm64 windows-386 windows-amd64 windows-arm windows-arm64 11 | 12 | all: fmt vet test build 13 | 14 | build: 15 | $(GO) build $(GOFLAGS) -o $(PROGNAME) 16 | 17 | test: 18 | $(GO) test ./... 19 | 20 | fmt: 21 | $(GO) fmt ./... 22 | 23 | vet: 24 | $(GO) vet ./... 25 | 26 | cross-build: all 27 | for target in $(CROSSTARGETS); do \ 28 | env $$(echo "$$target" | awk -F '-' '{ print "GOOS="$$1, "GOARCH="$$2 }') \ 29 | $(GO) build $(GOFLAGS) -o "$(PROGNAME)-$(VERSION)-$$target"; \ 30 | done 31 | 32 | major-release: all 33 | $(RELEASE) major 34 | 35 | minor-release: all 36 | $(RELEASE) minor 37 | 38 | patch-release: all 39 | $(RELEASE) patch 40 | 41 | install: all 42 | mkdir -p $(DESTDIR)$(PREFIX)/bin 43 | install -m 755 $(PROGNAME) $(DESTDIR)$(PREFIX)/bin 44 | 45 | uninstall: 46 | rm $(DESTDIR)$(PREFIX)/bin/$(PROGNAME) 47 | 48 | clean: 49 | $(GO) clean ./... 50 | rm -rf *.tmp $(PROGNAME)-v* 51 | -------------------------------------------------------------------------------- /play-on-tv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcoLucidi01/ytcast/026d60a7abe207355835c18d1fbcd7e2a4347100/play-on-tv.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ytcast 2 | ====== 3 | 4 | cast YouTube videos to your smart TV from command-line. 5 | 6 | this program does roughly the same thing as the "Play on TV" button that appears 7 | on the player bar when you visit youtube.com with Chrome or when you use the 8 | YouTube smartphone app: 9 | 10 | ![Play on TV button][0] 11 | 12 | ([the feature is also described here][1]). 13 | 14 | I don't use Chrome as my daily driver because of *reasons* and I tend to use my 15 | smartphone the least as possible when I'm at home... but still I want the "Play 16 | on TV" functionality to watch videos on the big television screen without having 17 | to search them with the remote! this is why I wrote this tool. also my computing 18 | workflow is "command-line centric" and `ytcast` fits well in my toolbox (see 19 | [other tools][2]). 20 | 21 | https://user-images.githubusercontent.com/23704923/147848611-0d20563e-f656-487a-9774-9eb6feca1f58.mp4 22 | 23 | ([video demo on YouTube if above doesn't play][3]). 24 | 25 | [0]: play-on-tv.png 26 | [1]: https://support.google.com/youtube/answer/7640706 27 | [2]: #other-tools 28 | [3]: https://www.youtube.com/watch?v=07aWOpi8DVk 29 | 30 | contents 31 | -------- 32 | 33 | - [usage](#usage) 34 | - [install](#install) 35 | - [how it works](#how-it-works) 36 | - [BUGS](#bugs) 37 | - [workarounds](#workarounds) 38 | - [THANKS](#thanks) 39 | - [other tools](#other-tools) 40 | 41 | usage 42 | ----- 43 | 44 | - the computer running `ytcast` and the target device must be on the **same network**. 45 | - the target device must support the **DIAL protocol** (see [how it works][14]). 46 | - the target device must have the **YouTube on TV app already installed**. 47 | 48 | run `ytcast -h` for the full usage, here I'll show the basic options. 49 | 50 | the `-d` (device) option selects the target device matching by name, hostname 51 | (ip), or unique service name: 52 | 53 | $ ytcast -d fire https://www.youtube.com/watch?v=dQw4w9WgXcQ 54 | 55 | to see the already discovered (cached) devices use the `-l` (list) option: 56 | 57 | $ ytcast -l 58 | 28bc7426 192.168.1.35 "FireTVStick di Marco" cached lastused 59 | d0881fbe 192.168.1.227 "[LG] webOS TV UM7100PLB" cached 60 | 61 | to update the devices cache use the `-s` (search) option (it's implicit when the 62 | cache is empty or when `-d` doesn't match anything in the cache): 63 | 64 | $ ytcast -s 65 | 28bc7426 192.168.1.35 "FireTVStick di Marco" lastused 66 | d0881fbe 192.168.1.227 "[LG] webOS TV UM7100PLB" cached 67 | 68 | if your target device doesn't show up, you can try increasing the search timeout 69 | with the `-t` (timeout) option to give the device more time to respond to the 70 | query: 71 | 72 | $ ytcast -s -t 10s 73 | 28bc7426 192.168.1.35 "FireTVStick di Marco" lastused 74 | d0881fbe 192.168.1.227 "[LG] webOS TV UM7100PLB" cached 75 | 76 | remember that the computer and the target device must be on the same network. 77 | if it doesn't show up after several tries, you may consider using the `-pair` 78 | option to skip the discovery process altogether. this adds some limitations 79 | though, see [workarounds][15]. 80 | 81 | to cast to the last used device use the `-p` option: 82 | 83 | $ ytcast -p https://www.youtube.com/watch?v=dQw4w9WgXcQ 84 | 85 | when no url is passed in the arguments, `ytcast` reads video urls (or ids) from 86 | `stdin` one per line: 87 | 88 | $ ytcast -d lg < watchlist 89 | 90 | this makes it easy to combine `ytcast` with other tools like [`ytfzf`][11] or my 91 | `ytfzf` clone [`ytsearch`][12]. 92 | 93 | to see what's going on under the hood use the `-verbose` option: 94 | 95 | $ ytsearch fireplace 10 hours | ytcast -d lg -verbose 96 | 21:13:08 ytcast.go:82: ytcast v0.1.0-6-g8e6daeb 97 | 21:13:08 ytcast.go:168: mkdir -p /home/marco/.cache/ytcast 98 | 21:13:08 ytcast.go:177: loading cache /home/marco/.cache/ytcast/ytcast.json 99 | 21:13:08 ytcast.go:319: reading videos from stdin 100 | 21:13:15 dial.go:153: GET http://192.168.1.227:1754/ 101 | 21:13:15 dial.go:153: GET http://192.168.1.227:36866/apps/YouTube 102 | 21:13:15 ytcast.go:293: "YouTube" is stopped on "[LG] webOS TV UM7100PLB" 103 | 21:13:15 ytcast.go:306: launching "YouTube" on "[LG] webOS TV UM7100PLB" 104 | 21:13:15 dial.go:153: POST http://192.168.1.227:36866/apps/YouTube 105 | 21:13:18 dial.go:153: GET http://192.168.1.227:36866/apps/YouTube 106 | 21:13:18 ytcast.go:293: "YouTube" is running on "[LG] webOS TV UM7100PLB" 107 | 21:13:18 ytcast.go:358: requesting YouTube Lounge to play [cdKop6aixVE] on "[LG] webOS TV UM7100PLB" 108 | 21:13:18 remote.go:233: POST https://www.youtube.com/api/lounge/bc/bind 109 | 21:13:18 remote.go:233: POST https://www.youtube.com/api/lounge/bc/bind 110 | 21:13:18 ytcast.go:197: saving cache /home/marco/.cache/ytcast/ytcast.json 111 | 112 | (please run with `-verbose` and **attach the log** when reporting an [issue][13]). 113 | 114 | [11]: https://github.com/pystardust/ytfzf 115 | [12]: https://github.com/MarcoLucidi01/bin/blob/master/ytsearch 116 | [13]: https://github.com/MarcoLucidi01/ytcast/issues 117 | [14]: #how-it-works 118 | [15]: #workarounds 119 | 120 | install 121 | ------- 122 | 123 | you can get a pre-compiled binary from the [latest release][20] assets and copy 124 | it somewhere in your `$PATH`. 125 | 126 | here a quick and dirty one-liner script to do it fast on unix-like systems 127 | (adjust `target` and `dir` to your needs, lookup available targets in the 128 | [latest release][20] assets): 129 | 130 | (target="linux-amd64"; dir="$HOME/bin"; \ 131 | wget -O - https://api.github.com/repos/MarcoLucidi01/ytcast/releases/latest \ 132 | | jq -r --arg target "$target" '.assets[] | select(.name | match("checksums|"+$target)) | .browser_download_url' \ 133 | | wget -i - \ 134 | && sha256sum -c --ignore-missing ytcast-v*-checksums.txt \ 135 | && tar -vxf ytcast-v*"$target.tar.gz" \ 136 | && install -m 755 ytcast-v*"$target/ytcast" "$dir") 137 | 138 | if you run Arch Linux (btw I don't) you can get [`ytcast-bin` from the AUR][21] 139 | (many thanks to the maintainer)! 140 | 141 | if your os or architecture are not available, or you want to get the latest 142 | changes from `master`, you can compile from source. a `go` compiler and `make` 143 | are required for building and installing: 144 | 145 | $ git clone https://github.com/MarcoLucidi01/ytcast.git 146 | ... 147 | $ cd ytcast 148 | $ make install 149 | ... 150 | 151 | `make install` installs in `/usr/local/bin` by default, you can change `PREFIX` 152 | if you want, for example I like to keep my binaries inside `$HOME/bin` so I 153 | usually install with: 154 | 155 | $ make install PREFIX=$HOME 156 | ... 157 | go build -trimpath -tags netgo,osusergo -ldflags="-w -s -X main.progVersion=v0.5.0-3-gd513b8e" -o ytcast 158 | mkdir -p /home/marco/bin 159 | install -m 755 ytcast /home/marco/bin 160 | 161 | to uninstall run `make uninstall` (with the same `PREFIX` used for `install`). 162 | 163 | [20]: https://github.com/MarcoLucidi01/ytcast/releases/latest 164 | [21]: https://aur.archlinux.org/packages/ytcast-bin 165 | 166 | how it works 167 | ------------ 168 | 169 | I've always been curious to know how my phone can find my TV on my home network 170 | and instruct it to start the YouTube on TV app and play a video right away 171 | without basically any manual pairing. 172 | 173 | I did some research and found about this nice little protocol called [DIAL 174 | (DIscovery And Launch)][30] developed by Netflix and Google which does the 175 | initial part i.e. allows second-screen devices (phone, laptop, etc..) to 176 | discover and launch apps on first-screen devices (TV, set-top, blu-ray, etc..). 177 | there is a 40 pages [specification][31] and a [reference implementation][32] for 178 | this protocol. 179 | 180 | the discovery part of DIAL is actually performed using another protocol, [SSDP 181 | (Simple Service Discovery Protocol)][33], which in turn is part of [UPnP][34]. 182 | 183 | all this is not enough to play videos. once the YouTube on TV app is started by 184 | DIAL, we need some other way to "tell" the app which video we want to play 185 | (actually DIAL allows to pass parameters to an app you want to launch, but this 186 | mechanism is not used by the YouTube on TV app anymore). 187 | 188 | after a little more research, I found about the YouTube Lounge api which is used 189 | by Chrome and the YouTube smartphone app to remotely control the YouTube on TV 190 | app. it allows to start playing videos, pause, unpause, skip, add videos to the 191 | queue and more. the api is **not documented** and understanding how it works 192 | it's not an easy and fun job. luckily lots of people have already reverse 193 | engineered the thing (see [THANKS][35]) so all I had to do was taking the bits I 194 | needed to build `ytcast`. 195 | 196 | the bridge between DIAL and YouTube Lounge api is the `screenId` which as you 197 | can imagine is an identifier for your "screen" (TV app). DIAL allows to get 198 | information about the current "state" of an app on a particular device. some 199 | fields of this state are required by DIAL, other fields are app specific (called 200 | additional data). `screenId` is a YouTube specific field that can be used to get 201 | a token from the YouTube Lounge api: with that token we can control the YouTube 202 | on TV app via api calls. 203 | 204 | putting all together, what `ytcast` does is: 205 | 206 | 1. search DIAL enabled devices on the local network (SSDP) 207 | 2. get the state of the YouTube on TV app on the target device (DIAL) 208 | 3. if the app is stopped, start it (DIAL) 209 | 4. get the `screenId` of the app (DIAL) 210 | 5. get a token for that `screenId` (Lounge) 211 | 6. call the api's "play video endpoint" passing the token and the video urls to 212 | play (Lounge) 213 | 214 | (there is a "devices cache" involved so `ytcast` won't necessarily do all these 215 | steps every time, also if the target device is turned off, `ytcast` tries to 216 | wake it up with [Wake-on-Lan][36]). 217 | 218 | as you may have already guessed, all this **can stop working at any time!** the 219 | weakest point is the YouTube Lounge api since it's **not documented** and 220 | `ytcast` depends heavily on it. moreover, **`ytcast` may not work at all on your 221 | setup!** I use and test `ytcast` with 2 devices: 222 | 223 | - Amazon Fire TV Stick 224 | - LG Smart TV running WebOS 225 | 226 | that's all I have. `ytcast` works great with both these devices but I don't know 227 | if it will work well on setups different than mine (it should, but I don't know 228 | for sure). if it doesn't work on your setup please [open an issue][37] 229 | describing your setup and attach a `-verbose` log so we can investigate what's 230 | wrong and hopefully fix it. 231 | 232 | also **Chromecast**. I don't own a Chromecast and you'll probably need to use 233 | the `-pair` option (see [workarounds][38]) to make `ytcast` work with Chromecast 234 | because it [doesn't use the DIAL protocol anymore, but switched to mDNS for 235 | discovery][39]. 236 | 237 | [30]: http://www.dial-multiscreen.org 238 | [31]: http://www.dial-multiscreen.org/dial-protocol-specification/DIAL-2ndScreenProtocol-2.2.1.pdf 239 | [32]: https://github.com/Netflix/dial-reference 240 | [33]: https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol 241 | [34]: https://en.wikipedia.org/wiki/Universal_Plug_and_Play 242 | [35]: #thanks 243 | [36]: https://en.wikipedia.org/wiki/Wake-on-LAN 244 | [37]: https://github.com/MarcoLucidi01/ytcast/issues 245 | [38]: #workarounds 246 | [39]: https://en.wikipedia.org/wiki/Chromecast#Device_discovery_protocols 247 | 248 | BUGS 249 | ---- 250 | 251 | - sometimes the playing queue gets "messed up" i.e. some videos are added 252 | between others, some videos don't get added at all or even an old queue might 253 | be "reused" so you could see videos from an old session after the ones you 254 | requested to play. unfortunately, using (or misusing) an undocumented api may 255 | lead to these kinds of problems and I haven't bothered too much trying to fix 256 | them. 257 | 258 | - the `-a` (add) option is slower because it does an api call for each video you 259 | want to add and adds a random "sleep delay" *before* each call. without this 260 | delay, the queue gets messed up more easily and videos get lost i.e. they 261 | don't get added to the queue. 262 | 263 | - playing a video from a specific starting time (`t` parameter in urls) works 264 | only for the *first* video and only if you are *not* using the `-a` (add) 265 | option. 266 | 267 | - `ytcast` doesn't appear in `Settings > Linked devices` menu. it used to show 268 | up there and there was a button to "unlink all devices" which caused the 269 | `screenId` to change, but a YouTube update "broke" this feature. 270 | 271 | workarounds 272 | ----------- 273 | 274 | - some devices don't support the DIAL protocol ([notably Chromecast][60]) so 275 | they can't be discovered by `ytcast`. the YouTube on TV app has a ["link with 276 | code" functionality][61] which can be used as workaround to pair `ytcast` with 277 | these devices. the pairing code can be found in `Settings > Link with TV code` 278 | and then you can use the `-pair` option to do the pairing: 279 | 280 | $ ytcast -pair 123456789101 281 | 8a59f138 unknown "YouTube on TV" 282 | 283 | once paired you can cast videos in the usual way: 284 | 285 | $ ytcast -d 8a59 https://www.youtube.com/watch?v=t0Q2otsqC4I 286 | 287 | when using this method, `ytcast` and the target device do *not* need to be on 288 | the same network, but it adds many manual steps i.e. the YouTube on TV app 289 | must be already open because `ytcast` won't be able to start it nor to 290 | Wake-On-Lan the TV and it won't automatically "re-pair" when the `screenId` 291 | changes (I don't know how often that happens). 292 | 293 | - playlist urls don't work with `ytcast`, I haven't found a reliable way to pass 294 | playlist ids to the api. `youtube-dl` comes to the rescue (see also [other 295 | tools][62]) since it can extract all video urls of YouTube playlists: 296 | 297 | $ youtube-dl -j --flat-playlist https://www.youtube.com/playlist?list=PLrOv9FMX8xJHqMvSGB_9G9nZZ_4IgteYf | jq -r '.url' | ytcast -p 298 | 299 | you can of course filter the pipeline as you like and this makes it so 300 | flexible that I actually don't feel the need for `ytcast` to support playlist 301 | urls: less is more! 302 | 303 | - this might sound obvious, but if you are tired of typing the device name (even 304 | a substring of it) every time you want to cast something or if you have 305 | multiple devices with the same name, you can define shell aliases and benefit 306 | from shell tab-completion feature: 307 | 308 | $ alias ytcbed="ytcast -d 'LG 32'" 309 | $ ytcbed https://www.youtube.com/watch?v=dQw4w9WgXcQ 310 | 311 | (see your shell documentation to make aliases persistent, usually you have to 312 | add them in the shell's `rc` file). 313 | 314 | [60]: https://en.wikipedia.org/wiki/Chromecast#Device_discovery_protocols 315 | [61]: https://support.google.com/youtube/answer/3230451 316 | [62]: #other-tools 317 | 318 | THANKS 319 | ------ 320 | 321 | I would like to thank all the people whose work has helped me tremendously in 322 | building `ytcast`, especially the following projects/posts: 323 | 324 | - https://0x41.cf/automation/2021/03/02/google-assistant-youtube-smart-tvs.html 325 | - https://github.com/thedroidgeek/youtube-cast-automation-api 326 | - https://github.com/mutantmonkey/youtube-remote 327 | - https://bugs.xdavidhu.me/google/2021/04/05/i-built-a-tv-that-plays-all-of-your-private-youtube-videos 328 | - https://github.com/aykevl/plaincast 329 | - https://github.com/ur1katz/casttube 330 | 331 | other tools 332 | ----------- 333 | 334 | as I said earlier, my computing environment is very command-line centric and I'd 335 | like to showcase the other tools I use to enjoy a "no frills" YouTube experience 336 | from the terminal! 337 | 338 | - [`youtube-dl`][40] (actually [`yt-dlp`][41] these days) doesn't need 339 | introduction, it's an awesome tool and it's well integrated with [`mpv`][42] 340 | so I can watch videos with my favorite player without having my laptop fan 341 | spin like an airplane engine thanks to this `mpv` config: 342 | 343 | ytdl-format=bestvideo[height<=?1080][vcodec!=?vp9]+bestaudio/best 344 | 345 | - [`ytsearch`][43] is my clone of the initial version of [`ytfzf`][44]. it 346 | allows to search and select video urls from the command-line using the 347 | wonderful [`fzf`][45] (fun fact: it's implemented basically as a single big 348 | pipe ahah). you have already seen it in action in `ytcast` examples, but it 349 | works great with `mpv` too: 350 | 351 | $ ytsearch matrix 4 | xargs mpv 352 | $ ytsearch 9 symphony | xargs mpv --no-video 353 | 354 | - [`ytxrss`][46] allows to extract the rss feed url of a YouTube channel 355 | starting from a video or channel url. I use rss feeds ([`newsboat`][47]) to 356 | keep up-to-date with *things* and I'm really glad YouTube still supports them 357 | for channel uploads. if I'm interested in a channel's future uploads, what I 358 | usually do is: 359 | 360 | $ ytxrss https://www.youtube.com/user/Computerphile >> ~/.newsboat/urls 361 | 362 | [40]: https://github.com/ytdl-org/youtube-dl 363 | [41]: https://github.com/yt-dlp/yt-dlp 364 | [42]: https://github.com/mpv-player/mpv 365 | [43]: https://github.com/MarcoLucidi01/bin/blob/master/ytsearch 366 | [44]: https://github.com/pystardust/ytfzf 367 | [45]: https://github.com/junegunn/fzf 368 | [46]: https://github.com/MarcoLucidi01/bin/blob/master/ytxrss 369 | [47]: https://github.com/newsboat/newsboat 370 | 371 | --- 372 | 373 | see [license file][50] for copyright and license details. 374 | 375 | [50]: license 376 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # See license file for copyright and license details. 3 | 4 | # release: automate creation of a new vX.Y.Z tag version and release. 5 | 6 | set -e 7 | 8 | progname="ytcast" 9 | ghuser="MarcoLucidi01" 10 | reltype="$1" 11 | relbranch="master" 12 | reldate="$(date +'%Y-%m-%d')" 13 | relapiurl="https://api.github.com/repos/$ghuser/$progname/releases" 14 | license="license" 15 | readme="readme.md" 16 | changelog="changelog.md" 17 | changelogurl="https://github.com/$ghuser/$progname/blob/master/$changelog" 18 | editorcmd="vim +8" 19 | netrccmd="gpg --quiet --decrypt $HOME/.netrc.gpg" # for curl --netrc-file /dev/stdin 20 | 21 | log() { 22 | printf "%s: " "$(date +'%H:%M:%S')" >&2 23 | echo "$@" >&2 24 | } 25 | 26 | die() { 27 | log "error:" "$@" 28 | exit 1 29 | } 30 | 31 | if [ "$reltype" != "major" ] && [ "$reltype" != "minor" ] && [ "$reltype" != "patch" ]; then 32 | die "unknown release type \"$reltype\"\nusage: ./release major|minor|patch" 33 | elif [ "$(git branch --show-current)" != "$relbranch" ]; then 34 | die "current branch is not $relbranch" 35 | elif [ -n "$(git status --short --porcelain)" ]; then 36 | die "working tree is not clean" 37 | fi 38 | 39 | currversion="$(git describe --tags --abbrev=0)" 40 | if ! echo "$currversion" | grep -q '^v[0-9]\+\.[0-9]\+\.[0-9]\+$'; then 41 | die "\"$currversion\": current tag version does not match vX.Y.Z format" 42 | fi 43 | relversion="$(echo "$currversion" \ 44 | | cut -c 2- \ 45 | | awk -v "reltype=$reltype" ' 46 | BEGIN { FS="."; OFS="." } 47 | { printf "%s", "v" } 48 | reltype == "major" { print $1+1, 0, 0 } 49 | reltype == "minor" { print $1, $2+1, 0 } 50 | reltype == "patch" { print $1, $2, $3+1 } 51 | ')" 52 | log "new release version is $relversion" 53 | 54 | sed "4i ## $relversion\n\n$reldate\n\n- WRITE CHANGELOG HERE (delete version to abort)\n" "$changelog" > "$changelog.tmp" 55 | $editorcmd "$changelog.tmp" 56 | if ! grep -q "^## $relversion$" "$changelog.tmp"; then 57 | die "changelog aborted" 58 | fi 59 | mv "$changelog.tmp" "$changelog" 60 | 61 | log "committing $changelog and creating new tag $relversion" 62 | git add "$changelog" 63 | git commit --message="$changelog: $relversion" 64 | git tag "$relversion" 65 | 66 | echo "please make sure that everything is all right, --amend now if you have to." 67 | while true; do 68 | printf "push to remote and create new release? [YES/NO] " 69 | read -r ans 70 | case "$ans" in 71 | "YES") 72 | break 73 | ;; 74 | "n" | "N" | "no" | "NO" | "No" | "nO") 75 | die "push aborted" 76 | ;; 77 | esac 78 | done 79 | 80 | log "building binaries" 81 | make clean 82 | make cross-build 83 | 84 | log "creating archives" 85 | mkdir "archive.tmp" 86 | cp "$license" "$readme" "$changelog" "archive.tmp" 87 | for binname in "$progname-$relversion-"*; do 88 | archivecmd="tar -czf" 89 | archivename="$binname.tar.gz" 90 | archivebinname="$progname" 91 | case "$binname" in *"windows"*) 92 | archivecmd="zip -r" 93 | archivename="$binname.zip" 94 | archivebinname="$progname.exe" 95 | ;; 96 | esac 97 | mv "$binname" "archive.tmp/$archivebinname" 98 | mv "archive.tmp" "$binname" 99 | $archivecmd "$archivename" "$binname" 100 | sha256sum "$archivename" >> "$progname-$relversion-sha256-checksums.txt" 101 | mv "$binname" "archive.tmp" 102 | rm "archive.tmp/$archivebinname" 103 | log "created $archivename" 104 | done 105 | rm -rf "archive.tmp" 106 | 107 | log "pushing to remote" 108 | git push 109 | git push --tags 110 | 111 | ghpost() { 112 | $netrccmd | curl --netrc-file /dev/stdin --silent --show-error --fail \ 113 | -X POST \ 114 | -H "Accept: application/vnd.github.v3+json" \ 115 | -H "Content-Type: $1" \ 116 | --data-binary "$2" \ 117 | "$3" 118 | } 119 | 120 | log "creating new release with name $relversion" 121 | reldata="$(jq -n -c --arg relversion "$relversion" --arg changelogurl "$changelogurl" \ 122 | '{ 123 | "tag_name": $relversion, 124 | "name": $relversion, 125 | "body": "[changelog](\($changelogurl)#\($relversion | gsub("\\."; "")))" 126 | }')" 127 | relinfo="$(ghpost "application/json" "$reldata" "$relapiurl")" 128 | log "created new release $(echo "$relinfo" | jq -r '.html_url')" 129 | 130 | reluploadurl="$(echo "$relinfo" | jq -r '.upload_url' | sed 's/{.*$//')" 131 | for asset in "$progname-$relversion-"*; do 132 | log "uploading $asset" 133 | assetinfo="$(ghpost "$(file --brief --mime-type "$asset")" "@$asset" "$reluploadurl?name=$asset")" 134 | log "uploaded $(echo "$assetinfo" | jq -r '.browser_download_url')" 135 | done 136 | 137 | log "done!" 138 | -------------------------------------------------------------------------------- /youtube/remote.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | // Package youtube implements a minimal client for the YouTube Lounge API which 4 | // allows to connect and play videos on a remote "screen" (YouTube on TV app). 5 | // The API is not public so this code CAN BREAK AT ANY TIME. 6 | // 7 | // The implementation derives from the work of various people I found on the web 8 | // that saved me hours of reverse engineering. I'd like to list and thank them 9 | // here: 10 | // 11 | // https://0x41.cf/automation/2021/03/02/google-assistant-youtube-smart-tvs.html 12 | // https://github.com/thedroidgeek/youtube-cast-automation-api 13 | // https://github.com/mutantmonkey/youtube-remote 14 | // https://bugs.xdavidhu.me/google/2021/04/05/i-built-a-tv-that-plays-all-of-your-private-youtube-videos 15 | // https://github.com/aykevl/plaincast 16 | // https://github.com/ur1katz/casttube 17 | package youtube 18 | 19 | import ( 20 | "encoding/json" 21 | "errors" 22 | "fmt" 23 | "io" 24 | "log" 25 | "net" 26 | "net/http" 27 | "net/url" 28 | "strconv" 29 | "strings" 30 | "time" 31 | ) 32 | 33 | const ( 34 | apiBase = "https://www.youtube.com/api/lounge" 35 | apiGetLoungeToken = apiBase + "/pairing/get_lounge_token_batch" 36 | apiGetScreen = apiBase + "/pairing/get_screen" 37 | apiBind = apiBase + "/bc/bind" 38 | 39 | paramApp = "youtube-desktop" 40 | paramCver = "1" 41 | paramDevice = "REMOTE_CONTROL" 42 | paramId = "remote" 43 | paramRidGetSessionIds = "1" 44 | paramRidPlay = "2" 45 | paramVer = "8" 46 | 47 | httpTimeout = 30 * time.Second 48 | reqMinDelay = 2 * time.Second 49 | reqMaxDelay = reqMinDelay + 3*time.Second 50 | 51 | contentType = "application/x-www-form-urlencoded" 52 | userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36" 53 | 54 | // Origin header value for HTTP requests to YouTube services. 55 | Origin = "https://www.youtube.com" 56 | 57 | // YouTube application name registered in the DIAL register. 58 | // See http://www.dial-multiscreen.org/dial-registry/namespace-database 59 | DialAppName = "YouTube" 60 | ) 61 | 62 | var ( 63 | errBadHttpStatus = errors.New("bad HTTP response status") 64 | errNoScreenId = errors.New("missing screenId") 65 | errNoScreens = errors.New("missing screens array") 66 | errNoToken = errors.New("missing loungeToken") 67 | errNoSessionIds = errors.New("missing session ids") 68 | ) 69 | 70 | // Remote holds Lounge session tokens of a connected screen (tv app) and allows 71 | // to play videos on it until Expiration. 72 | type Remote struct { 73 | localAddr string // localAddr is the local address the Remote instance must use for network operations. 74 | httpClient *http.Client // httpClient is an http.Client setup to use localAddr. 75 | 76 | ScreenId string // id of the screen (tv app) we are connected (or connecting) to. 77 | Name string // name displayed on the screen at connection time. 78 | LoungeToken string // token for Lounge API requests. 79 | Expiration int64 // LoungeToken expiration timestamp in milliseconds. 80 | SId string // session id? it can expire very often so we fetch it at each Play() or Add(). 81 | GSessionId string // another session id? google session id? we fetch it along with SId. 82 | 83 | // these fields are present ONLY if connected with code (ConnectWithCode()). 84 | DeviceId string // uuid of the device we are connected to. 85 | ScreenName string // name of the screen we are connected to. 86 | } 87 | 88 | // Connect connects to a screen (tv app) identified by screenId through the 89 | // Lounge API. name will be displayed on the screen at connection time. Returns 90 | // a Remote that can be used to play video on that screen. 91 | func Connect(localAddr, screenId, name string) (*Remote, error) { 92 | r := &Remote{ScreenId: screenId, Name: name} 93 | if err := r.SetLocalAddr(localAddr); err != nil { 94 | return nil, fmt.Errorf("SetLocalAddr: %w", err) 95 | } 96 | if err := r.RefreshToken(); err != nil { 97 | return nil, fmt.Errorf("RefreshToken: %w", err) 98 | } 99 | return r, nil 100 | } 101 | 102 | // ConnectWithCode is like Connect(), but uses a pairing code (generated by the 103 | // tv app) to get ScreenId and LoungeToken. 104 | func ConnectWithCode(localAddr, code, name string) (*Remote, error) { 105 | hc, err := newHTTPClient(localAddr) 106 | if err != nil { 107 | return nil, err 108 | } 109 | q := url.Values{} 110 | q.Set("pairing_code", removeSpaces(code)) 111 | respBody, err := doReq(hc, "GET", apiGetScreen, q, nil) 112 | if err != nil { 113 | return nil, err 114 | } 115 | screenId, tok, exp, deviceId, screenName, err := extractScreenInfo(respBody) 116 | if err != nil { 117 | return nil, err 118 | } 119 | r := &Remote{ 120 | ScreenId: screenId, 121 | Name: name, 122 | LoungeToken: tok, 123 | Expiration: exp, 124 | DeviceId: deviceId, 125 | ScreenName: screenName, 126 | } 127 | if err := r.SetLocalAddr(localAddr); err != nil { 128 | return nil, fmt.Errorf("SetLocalAddr: %w", err) 129 | } 130 | return r, nil 131 | } 132 | 133 | func newHTTPClient(localAddr string) (*http.Client, error) { 134 | hc := &http.Client{Timeout: httpTimeout} 135 | if localAddr == "" { 136 | return hc, nil 137 | } 138 | laddr, err := net.ResolveTCPAddr("tcp", localAddr) 139 | if err != nil { 140 | return nil, err 141 | } 142 | hc.Transport = http.DefaultTransport.(*http.Transport).Clone() 143 | hc.Transport.(*http.Transport).DialContext = (&net.Dialer{ 144 | LocalAddr: laddr, 145 | Timeout: httpTimeout, 146 | KeepAlive: httpTimeout, 147 | }).DialContext 148 | return hc, nil 149 | } 150 | 151 | func extractScreenInfo(data []byte) (string, string, int64, string, string, error) { 152 | var v struct { 153 | Screen struct { 154 | ScreenId string `json:"screenId"` 155 | LoungeToken string `json:"loungeToken"` 156 | Expiration int64 `json:"expiration"` 157 | DeviceId string `json:"deviceId"` 158 | Name string `json:"name"` 159 | } `json:"screen"` 160 | } 161 | if err := json.Unmarshal(data, &v); err != nil { 162 | return "", "", 0, "", "", err 163 | } 164 | if v.Screen.ScreenId == "" { 165 | return "", "", 0, "", "", errNoScreenId 166 | } 167 | if v.Screen.LoungeToken == "" { 168 | return "", "", 0, "", "", errNoToken 169 | } 170 | return v.Screen.ScreenId, v.Screen.LoungeToken, v.Screen.Expiration, v.Screen.DeviceId, v.Screen.Name, nil 171 | } 172 | 173 | // SetLocalAddr sets the local address the Remote instance must use for network 174 | // operations. 175 | func (r *Remote) SetLocalAddr(localAddr string) error { 176 | if r.httpClient != nil && r.localAddr == localAddr { 177 | return nil // localAddr already set, no need to recreate an http.Client. 178 | } 179 | hc, err := newHTTPClient(localAddr) 180 | if err != nil { 181 | return err 182 | } 183 | r.localAddr = localAddr 184 | r.httpClient = hc 185 | return nil 186 | } 187 | 188 | // RefreshToken gets a new LoungeToken for the screenId. Should be used when the 189 | // token has Expired(). 190 | func (r *Remote) RefreshToken() error { 191 | b := url.Values{} 192 | b.Set("screen_ids", r.ScreenId) 193 | respBody, err := doReq(r.httpClient, "POST", apiGetLoungeToken, nil, b) 194 | if err != nil { 195 | return err 196 | } 197 | tok, exp, err := extractLoungeToken(respBody) 198 | if err != nil { 199 | return err 200 | } 201 | r.LoungeToken, r.Expiration = tok, exp 202 | return nil 203 | } 204 | 205 | func extractLoungeToken(data []byte) (string, int64, error) { 206 | var v struct { 207 | Screens []struct { 208 | LoungeToken string `json:"loungeToken"` 209 | Expiration int64 `json:"expiration"` 210 | } `json:"screens"` 211 | } 212 | if err := json.Unmarshal(data, &v); err != nil { 213 | return "", 0, err 214 | } 215 | if len(v.Screens) == 0 { 216 | return "", 0, errNoScreens 217 | } 218 | if v.Screens[0].LoungeToken == "" { 219 | return "", 0, errNoToken 220 | } 221 | return v.Screens[0].LoungeToken, v.Screens[0].Expiration, nil 222 | } 223 | 224 | // Expired returns true if the LoungeToken has expired. 225 | func (r *Remote) Expired() bool { 226 | exp := time.Unix(0, r.Expiration*int64(time.Millisecond)) 227 | return time.Now().After(exp) 228 | } 229 | 230 | func (r *Remote) getSessionIds() error { 231 | q := url.Values{} 232 | q.Set("CVER", paramCver) 233 | q.Set("RID", paramRidGetSessionIds) 234 | q.Set("VER", paramVer) 235 | q.Set("app", paramApp) 236 | q.Set("device", paramDevice) 237 | q.Set("id", paramId) 238 | q.Set("loungeIdToken", r.LoungeToken) 239 | q.Set("name", r.Name) 240 | respBody, err := doReq(r.httpClient, "POST", apiBind, q, nil) 241 | if err != nil { 242 | return err 243 | } 244 | sId, gSessionId, err := extractSessionIds(respBody) 245 | if err != nil { 246 | return err 247 | } 248 | r.SId, r.GSessionId = sId, gSessionId 249 | return nil 250 | } 251 | 252 | func extractSessionIds(data []byte) (string, string, error) { 253 | // first thing we get is a number that we can safely skip (I think it's 254 | // payload length). 255 | for i, c := range data { 256 | if c == '[' { 257 | data = data[i:] 258 | break 259 | } 260 | } 261 | 262 | // next we have a bunch of json arrays containing mixed type values, 263 | // that's why interface{}. See remote_test.go for an example. 264 | var v [][]interface{} 265 | if err := json.Unmarshal(data, &v); err != nil { 266 | return "", "", err 267 | } 268 | var sId string 269 | var gsessionId string 270 | for _, a1 := range v { 271 | if len(a1) < 2 { 272 | continue 273 | } 274 | a2, ok := a1[1].([]interface{}) 275 | if !ok || len(a2) < 2 { 276 | continue 277 | } 278 | 279 | var key, value string 280 | if key, ok = a2[0].(string); !ok { 281 | continue 282 | } 283 | if value, ok = a2[1].(string); !ok { 284 | continue 285 | } 286 | switch key { 287 | case "c": 288 | sId = value 289 | case "S": 290 | gsessionId = value 291 | default: 292 | continue 293 | } 294 | 295 | if sId != "" && gsessionId != "" { 296 | return sId, gsessionId, nil 297 | } 298 | } 299 | return "", "", errNoSessionIds 300 | } 301 | 302 | // Play requests the Lounge API to play immediately the first video on the 303 | // tv app and to enqueue the others. Accepts both video urls and video ids. 304 | func (r *Remote) Play(videos []string) error { 305 | if len(videos) == 0 { 306 | return nil 307 | } 308 | if err := r.getSessionIds(); err != nil { 309 | return fmt.Errorf("getSessionIds: %w", err) 310 | } 311 | q := url.Values{} 312 | q.Set("CVER", paramCver) 313 | q.Set("RID", paramRidPlay) 314 | q.Set("SID", r.SId) 315 | q.Set("VER", paramVer) 316 | q.Set("gsessionid", r.GSessionId) 317 | q.Set("loungeIdToken", r.LoungeToken) 318 | b := url.Values{} 319 | b.Set("count", "1") 320 | b.Set("req0__sc", "setPlaylist") 321 | // start time can be set only for the first video. 322 | id, startTime := extractVideoInfo(videos[0]) 323 | b.Set("req0_videoId", id) 324 | b.Set("req0_currentTime", strconv.FormatInt(int64(startTime.Seconds()), 10)) 325 | b.Set("req0_currentIndex", "0") 326 | var videoIds []string 327 | for _, v := range videos { 328 | id, _ := extractVideoInfo(v) 329 | videoIds = append(videoIds, id) 330 | } 331 | b.Set("req0_videoIds", strings.Join(videoIds, ",")) 332 | _, err := doReq(r.httpClient, "POST", apiBind, q, b) 333 | return err 334 | } 335 | 336 | // Add requests the Lounge API to add videos to the queue without changing 337 | // what's currently playing on the tv app. Accepts both video urls and video ids. 338 | func (r *Remote) Add(videos []string) error { 339 | if len(videos) == 0 { 340 | return nil 341 | } 342 | if err := r.getSessionIds(); err != nil { 343 | return fmt.Errorf("getSessionIds: %w", err) 344 | } 345 | q := url.Values{} 346 | q.Set("CVER", paramCver) 347 | q.Set("RID", paramRidPlay) 348 | q.Set("SID", r.SId) 349 | q.Set("VER", paramVer) 350 | q.Set("gsessionid", r.GSessionId) 351 | q.Set("loungeIdToken", r.LoungeToken) 352 | for i, v := range videos { 353 | // addVideo doesn't have reqX_videoIds parameter so we send a 354 | // request for each video, but without this random delay the 355 | // queue may get messed up and some video may get "lost". also, 356 | // each reqX_ needs to have its own index for the same reason. 357 | randDelay(reqMinDelay, reqMaxDelay) 358 | b := url.Values{} 359 | b.Set("count", "1") 360 | b.Set(fmt.Sprintf("req%d__sc", i), "addVideo") 361 | id, _ := extractVideoInfo(v) 362 | b.Set(fmt.Sprintf("req%d_videoId", i), id) 363 | if _, err := doReq(r.httpClient, "POST", apiBind, q, b); err != nil { 364 | return err 365 | } 366 | } 367 | return nil 368 | } 369 | 370 | func doReq(httpClient *http.Client, method, url string, query, body url.Values) ([]byte, error) { 371 | req, err := http.NewRequest(method, url, strings.NewReader(body.Encode())) 372 | if err != nil { 373 | return nil, err 374 | } 375 | if len(query) > 0 { 376 | req.URL.RawQuery = query.Encode() 377 | } 378 | if len(body) > 0 { 379 | req.Header.Set("Content-Type", contentType) 380 | } 381 | req.Header.Set("Origin", Origin) // doesn't hurt 382 | req.Header.Set("User-Agent", userAgent) 383 | 384 | log.Printf("%s %s", method, url) 385 | resp, err := httpClient.Do(req) 386 | if err != nil { 387 | return nil, err 388 | } 389 | defer resp.Body.Close() 390 | respBody, err := io.ReadAll(resp.Body) 391 | if err == nil && resp.StatusCode != 200 { 392 | err = fmt.Errorf("%s %s: %s: %w", method, url, resp.Status, errBadHttpStatus) 393 | } 394 | return respBody, err 395 | } 396 | -------------------------------------------------------------------------------- /youtube/remote_test.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package youtube 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func connectOrSkip(t *testing.T, name, screenId string) *Remote { 11 | if screenId == "" { 12 | t.SkipNow() 13 | } 14 | r, err := Connect("", screenId, name) 15 | if err != nil { 16 | t.Fatalf("unexpected error: %s", err) 17 | } 18 | return r 19 | } 20 | 21 | func TestPlay(t *testing.T) { 22 | r := connectOrSkip(t, "TestPlay", "") // put your screenId here 23 | if err := r.Play([]string{"dQw4w9WgXcQ", "7BqJ8dzygtU", "EY6q5dv_B-o"}); err != nil { 24 | t.Fatalf("unexpected error: %s", err) 25 | } 26 | } 27 | 28 | func TestPlayAndAdd(t *testing.T) { 29 | r := connectOrSkip(t, "TestPlayAndAdd", "") // put your screenId here 30 | if err := r.Play([]string{"Opqgwn8TdlM", "0MLaYe3y0BU"}); err != nil { 31 | t.Fatalf("unexpected error: %s", err) 32 | } 33 | if err := r.Add([]string{"RzWB5jL5RX0", "fPU7Uq4TtNU"}); err != nil { 34 | t.Fatalf("unexpected error: %s", err) 35 | } 36 | if err := r.Add([]string{"BK5x7IUTIyU"}); err != nil { 37 | t.Fatalf("unexpected error: %s", err) 38 | } 39 | if err := r.Add([]string{"ci1PJexnfNE"}); err != nil { 40 | t.Fatalf("unexpected error: %s", err) 41 | } 42 | } 43 | 44 | func TestPlayFromTimestamp(t *testing.T) { 45 | r := connectOrSkip(t, "TestPlayFromTimestamp", "") // put your screenId here 46 | if err := r.Play([]string{"OgO1gpXSUzU&t=363"}); err != nil { 47 | t.Fatalf("unexpected error: %s", err) 48 | } 49 | time.Sleep(5 * time.Second) 50 | if err := r.Play([]string{"0JUN9aDxVmI&t=10m"}); err != nil { 51 | t.Fatalf("unexpected error: %s", err) 52 | } 53 | } 54 | 55 | func TestConnectWithCode(t *testing.T) { 56 | code := "" // put your TV code here 57 | if code == "" { 58 | t.SkipNow() 59 | } 60 | r, err := ConnectWithCode("", code, "TestConnectWithCode") 61 | if err != nil { 62 | t.Fatalf("unexpected error: %s", err) 63 | } 64 | if err := r.Play([]string{"w3Wluvzoggg"}); err != nil { 65 | t.Fatalf("unexpected error: %s", err) 66 | } 67 | } 68 | 69 | func TestExtractScreenInfo(t *testing.T) { 70 | tests := []struct { 71 | data []byte 72 | screenId string 73 | loungeToken string 74 | expiration int64 75 | deviceId string 76 | screenName string 77 | }{ 78 | { 79 | data: []byte(` 80 | { 81 | "screen": { 82 | "accessType": "permanent", 83 | "screenId": "screen-id-foo-bar-baz", 84 | "dialAdditionalDataSupportLevel": "unknown", 85 | "loungeTokenRefreshIntervalMs": 1123200000, 86 | "loungeToken": "lounge-token-foo-bar-baz", 87 | "clientName": "tvhtml5", 88 | "name": "YouTube on TV", 89 | "expiration": 1645614559007, 90 | "deviceId": "device-id-foo-bar-baz" 91 | } 92 | }`), 93 | screenId: "screen-id-foo-bar-baz", 94 | loungeToken: "lounge-token-foo-bar-baz", 95 | expiration: int64(1645614559007), 96 | deviceId: "device-id-foo-bar-baz", 97 | screenName: "YouTube on TV", 98 | }, 99 | } 100 | 101 | for i, test := range tests { 102 | screenId, loungeToken, expiration, deviceId, screenName, err := extractScreenInfo(test.data) 103 | if err != nil { 104 | t.Fatalf("tests[%d]: unexpected error: %s", i, err) 105 | } 106 | if test.screenId != screenId { 107 | t.Fatalf("tests[%d]: screenId: want %q got %q", i, test.screenId, screenId) 108 | } 109 | if test.loungeToken != loungeToken { 110 | t.Fatalf("tests[%d]: loungeToken: want %q got %q", i, test.loungeToken, loungeToken) 111 | } 112 | if test.expiration != expiration { 113 | t.Fatalf("tests[%d]: expiration: want %q got %q", i, test.expiration, expiration) 114 | } 115 | if test.deviceId != deviceId { 116 | t.Fatalf("tests[%d]: deviceId: want %q got %q", i, test.deviceId, deviceId) 117 | } 118 | if test.screenName != screenName { 119 | t.Fatalf("tests[%d]: screenName: want %q got %q", i, test.screenName, screenName) 120 | } 121 | } 122 | } 123 | 124 | func TestExtractLoungeToken(t *testing.T) { 125 | tests := []struct { 126 | data []byte 127 | loungeToken string 128 | expiration int64 129 | }{ 130 | { 131 | data: []byte(` 132 | { 133 | "screens": [ 134 | { 135 | "screenId": "screen-id-foo-bar-baz", 136 | "refreshIntervalInMillis": 1123200000, 137 | "remoteRefreshIntervalMs": 79200000, 138 | "refreshIntervalMs": 1123200000, 139 | "loungeTokenLifespanMs": 1209600000, 140 | "loungeToken": "lounge-token-foo-bar-baz", 141 | "remoteRefreshIntervalInMillis": 79200000, 142 | "expiration": 1637512182177 143 | } 144 | ] 145 | }`), 146 | loungeToken: "lounge-token-foo-bar-baz", 147 | expiration: int64(1637512182177), 148 | }, 149 | } 150 | 151 | for i, test := range tests { 152 | loungeToken, expiration, err := extractLoungeToken(test.data) 153 | if err != nil { 154 | t.Fatalf("tests[%d]: unexpected error: %s", i, err) 155 | } 156 | if test.loungeToken != loungeToken { 157 | t.Fatalf("tests[%d]: loungeToken: want %q got %q", i, test.loungeToken, loungeToken) 158 | } 159 | if test.expiration != expiration { 160 | t.Fatalf("tests[%d]: expiration: want %d got %d", i, test.expiration, expiration) 161 | } 162 | } 163 | } 164 | 165 | func TestExtractSessionIds(t *testing.T) { 166 | tests := []struct { 167 | data []byte 168 | sId string 169 | gSessionId string 170 | }{ 171 | { 172 | data: []byte(` 173 | 270 174 | [[0,["c","sid-foo-bar-baz","",8]] 175 | ,[1,["S","gsessionid-foo-bar-baz"]] 176 | ,[2,["loungeStatus",{}]] 177 | ,[3,["playlistModified",{}]] 178 | ,[4,["onAutoplayModeChanged",{"autoplayMode":"UNSUPPORTED"}]] 179 | ,[5,["onPlaylistModeChanged",{"shuffleEnabled":"false","loopEnabled":"false"}]] 180 | ]`), 181 | sId: "sid-foo-bar-baz", 182 | gSessionId: "gsessionid-foo-bar-baz", 183 | }, 184 | } 185 | 186 | for i, test := range tests { 187 | sId, gSessionId, err := extractSessionIds(test.data) 188 | if err != nil { 189 | t.Fatalf("tests[%d]: unexpected error: %s", i, err) 190 | } 191 | if test.sId != sId { 192 | t.Fatalf("tests[%d]: sId: want %q got %q", i, test.sId, sId) 193 | } 194 | if test.gSessionId != gSessionId { 195 | t.Fatalf("tests[%d]: gSessionId: want %q got %q", i, test.gSessionId, gSessionId) 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /youtube/util.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package youtube 4 | 5 | import ( 6 | "encoding/xml" 7 | "fmt" 8 | "math/rand" 9 | "net/url" 10 | "path" 11 | "regexp" 12 | "strings" 13 | "time" 14 | "unicode" 15 | ) 16 | 17 | var ( 18 | // taken from this awesome answer https://webapps.stackexchange.com/a/101153 19 | videoIdRe = regexp.MustCompile(`^[0-9A-Za-z_-]{10}[048AEIMQUYcgkosw]$`) 20 | ) 21 | 22 | func init() { 23 | rand.Seed(time.Now().UnixNano()) 24 | } 25 | 26 | func randDelay(min, max time.Duration) { 27 | time.Sleep(min + time.Duration(rand.Int63n(int64(max-min)))) 28 | } 29 | 30 | // ExtractScreenId extracts the screen id of a YouTube TV app from the xml tag 31 | // fetched with a GET request on the Application-URL (see DIAL 32 | // protocol and dial.GetAppInfo()). 33 | func ExtractScreenId(data string) (string, error) { 34 | // TODO dial.AppInfo.Additional.Data it's not wrapped in a root element, 35 | // I add a dummy root here but I think data should already be wrapped in 36 | // a root element. 37 | data = fmt.Sprintf("%s", data) 38 | var v struct { 39 | ScreenId string `xml:"screenId"` 40 | } 41 | if err := xml.Unmarshal([]byte(data), &v); err != nil { 42 | return "", err 43 | } 44 | return strings.TrimSpace(v.ScreenId), nil 45 | } 46 | 47 | // extractVideoInfo extracts video information from a video url or query string 48 | // (see util_test.go for examples). It's not very smart. 49 | func extractVideoInfo(v string) (string, time.Duration) { 50 | v = strings.ReplaceAll(strings.TrimSpace(v), "?", "&") // treat urls as query strings. 51 | q, _ := url.ParseQuery(v) 52 | id := extractVideoId(v, q) 53 | if id == "" { 54 | return v, 0 // assume v is a videoId we weren't able to extract. 55 | } 56 | return id, extractStartTime(q) 57 | } 58 | 59 | func extractVideoId(p string, q url.Values) string { 60 | if id := q.Get("v"); id != "" { 61 | return id 62 | } 63 | bp := path.Base(p) 64 | if i := strings.IndexRune(bp, '&'); i > -1 { 65 | // strip "invalid" query parameters from base path, e.g. 66 | // https://youtu.be/jNQXAC9IVRw&feature=channel 67 | // this also works for query strings like jNQXAC9IVRw&t=25 68 | bp = bp[:i] 69 | } 70 | // YouTube makes no guarantee on videoId format (see https://webapps.stackexchange.com/questions/54443) 71 | // we use the regex only when we can't find it in the query parameters. 72 | if videoIdRe.MatchString(bp) { 73 | return bp 74 | } 75 | return "" 76 | } 77 | 78 | func extractStartTime(q url.Values) time.Duration { 79 | t := q.Get("t") 80 | if t == "" { 81 | return 0 82 | } 83 | if unicode.IsDigit(rune(t[len(t)-1])) { 84 | t += "s" 85 | } 86 | if d, err := time.ParseDuration(t); err == nil && d > 0 { 87 | return d 88 | } 89 | return 0 90 | } 91 | 92 | func removeSpaces(s string) string { 93 | m := func(r rune) rune { 94 | if unicode.IsSpace(r) { 95 | return -1 96 | } 97 | return r 98 | } 99 | return strings.Map(m, s) 100 | } 101 | -------------------------------------------------------------------------------- /youtube/util_test.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package youtube 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestExtractScreenId(t *testing.T) { 11 | tests := []string{ 12 | "foo-bar-baz", 13 | } 14 | 15 | const want = "foo-bar-baz" 16 | for i, test := range tests { 17 | screenId, err := ExtractScreenId(test) 18 | if err != nil { 19 | t.Fatalf("tests[%d]: unexpected error: %q", i, err) 20 | } 21 | if screenId != want { 22 | t.Fatalf("tests[%d]: screenId: want %q got %q", i, want, screenId) 23 | } 24 | } 25 | } 26 | 27 | func TestExtractVideoInfo(t *testing.T) { 28 | // most examples are from https://gist.github.com/rodrigoborgesdeoliveira/987683cfbfcc8d800192da1e73adc486 29 | tests := []struct { 30 | u string 31 | id string 32 | startTime time.Duration 33 | }{ 34 | {u: "jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 35 | {u: "jNQXAC9IVRw&t=25", id: "jNQXAC9IVRw", startTime: 25 * time.Second}, 36 | {u: "v=jNQXAC9IVRw&t=25", id: "jNQXAC9IVRw", startTime: 25 * time.Second}, 37 | {u: "t=25&v=jNQXAC9IVRw", id: "jNQXAC9IVRw", startTime: 25 * time.Second}, 38 | 39 | {u: "youtube.com/watch?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 40 | {u: "www.youtube.com/watch?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 41 | {u: "m.youtube.com/watch?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 42 | {u: "http://www.youtube.com/watch?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 43 | {u: "https://www.youtube.com/watch?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 44 | {u: "https://m.youtube.com/watch?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 45 | {u: "https://youtu.be/jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 46 | 47 | {u: "https://www.youtube-nocookie.com/embed/jNQXAC9IVRw?rel=0", id: "jNQXAC9IVRw"}, 48 | {u: "https://www.youtube-nocookie.com/v/jNQXAC9IVRw?version=3&hl=en_US&rel=0", id: "jNQXAC9IVRw"}, 49 | {u: "https://www.youtube.com/?feature=player_embedded&v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 50 | {u: "https://www.youtube.com/?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 51 | {u: "https://www.youtube.com/e/jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 52 | {u: "https://www.youtube.com/embed/jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 53 | {u: "https://www.youtube.com/embed/jNQXAC9IVRw?rel=0", id: "jNQXAC9IVRw"}, 54 | {u: "https://www.youtube.com/v/jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 55 | {u: "https://www.youtube.com/v/jNQXAC9IVRw?fs=1&hl=en_US&rel=0", id: "jNQXAC9IVRw"}, 56 | {u: "https://www.youtube.com/v/jNQXAC9IVRw?version=3&autohide=1", id: "jNQXAC9IVRw"}, 57 | {u: "https://www.youtube.com/watch?feature=player_embedded&v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 58 | {u: "https://www.youtube.com/watch?v=jNQXAC9IVRw&feature=em-uploademail", id: "jNQXAC9IVRw"}, 59 | {u: "https://www.youtube.com/watch?v=jNQXAC9IVRw&feature=youtu.be", id: "jNQXAC9IVRw"}, 60 | {u: "https://www.youtube.com/watch?v=jNQXAC9IVRw&list=PLBGH6psvCLx46lC91XTNSwi5RPryOhhde&index=106&shuffle=2655", id: "jNQXAC9IVRw"}, 61 | {u: "https://www.youtube.com/watch?v=jNQXAC9IVRw&playnext_from=TL&videos=osPknwzXEas&feature=sub", id: "jNQXAC9IVRw"}, 62 | {u: "https://www.youtube.com/ytscreeningroom?v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 63 | {u: "https://youtu.be/jNQXAC9IVRw&feature=channel", id: "jNQXAC9IVRw"}, 64 | {u: "https://youtu.be/jNQXAC9IVRw?feature=youtube_gdata_player", id: "jNQXAC9IVRw"}, 65 | {u: "https://youtu.be/jNQXAC9IVRw?list=PLBGH6psvCLx46lC91XTNSwi5RPryOhhde", id: "jNQXAC9IVRw"}, 66 | {u: "https://youtube.com/?feature=channel&v=jNQXAC9IVRw", id: "jNQXAC9IVRw"}, 67 | {u: "https://youtube.com/?v=jNQXAC9IVRw&feature=youtube_gdata_player", id: "jNQXAC9IVRw"}, 68 | {u: "https://youtube.com/v/jNQXAC9IVRw?feature=youtube_gdata_player", id: "jNQXAC9IVRw"}, 69 | {u: "https://youtube.com/watch?v=jNQXAC9IVRw&feature=channel", id: "jNQXAC9IVRw"}, 70 | 71 | {u: "https://youtu.be/k8vpB7GCYPE?t=110", id: "k8vpB7GCYPE", startTime: 110 * time.Second}, 72 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=0", id: "k8vpB7GCYPE", startTime: 0}, 73 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=1", id: "k8vpB7GCYPE", startTime: 1 * time.Second}, 74 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=0s", id: "k8vpB7GCYPE", startTime: 0}, 75 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=110s", id: "k8vpB7GCYPE", startTime: 110 * time.Second}, 76 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=1m50s", id: "k8vpB7GCYPE", startTime: 1*time.Minute + 50*time.Second}, 77 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=45m", id: "k8vpB7GCYPE", startTime: 45 * time.Minute}, 78 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=1h14m33s", id: "k8vpB7GCYPE", startTime: 1*time.Hour + 14*time.Minute + 33*time.Second}, 79 | {u: "https://www.youtube.com/watch?v=k8vpB7GCYPE&t=-10", id: "k8vpB7GCYPE", startTime: 0}, 80 | } 81 | 82 | for i, test := range tests { 83 | id, startTime := extractVideoInfo(test.u) 84 | if test.id != id { 85 | t.Fatalf("tests[%d]: %q: id: want %q got %q", i, test.u, test.id, id) 86 | } 87 | if test.startTime != startTime { 88 | t.Fatalf("tests[%d]: %q: startTime: want %q got %q", i, test.u, test.startTime, startTime) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /ytcast.go: -------------------------------------------------------------------------------- 1 | // See license file for copyright and license details. 2 | 3 | package main 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net" 14 | "os" 15 | "os/user" 16 | "path/filepath" 17 | "sort" 18 | "strings" 19 | "time" 20 | 21 | "github.com/MarcoLucidi01/ytcast/dial" 22 | "github.com/MarcoLucidi01/ytcast/youtube" 23 | ) 24 | 25 | const ( 26 | progName = "ytcast" 27 | progRepo = "https://github.com/MarcoLucidi01/ytcast" 28 | 29 | xdgCache = "XDG_CACHE_HOME" 30 | fallbackCacheDir = ".cache" // used if xdgCache is not set, stored in $HOME 31 | cacheFileName = progName + ".json" 32 | 33 | launchTimeout = 1 * time.Minute 34 | launchCheckInterval = 3 * time.Second 35 | 36 | fallbackIdFormat = "0405.0000.2006010215" // poor man's UUID. 37 | ) 38 | 39 | var ( 40 | progVersion = "vX.Y.Z-dev" // set with -ldflags at build time 41 | 42 | errNoAddr = errors.New("no valid address for interface") 43 | errNoDevFound = errors.New("no device found") 44 | errNoDevLastUsed = errors.New("no device last used") 45 | errNoDevMatch = errors.New("no device matches") 46 | errMoreDevMatch = errors.New("more than one device matches") 47 | errNoDevSelected = errors.New("no device selected") 48 | errNoLaunch = errors.New("unable to launch app and get screenId") 49 | errNoVideo = errors.New("no video to play") 50 | errUnknownAppState = errors.New("unknown app state") 51 | errInvalidCode = errors.New("invalid pairing code") 52 | 53 | flagAdd = flag.Bool("a", false, "add video(s) to queue, don't change what's currently playing") 54 | flagClearCache = flag.Bool("c", false, "clear cache") 55 | flagDevName = flag.String("d", "", "select device by substring of name, hostname (ip) or unique service name") 56 | flagNetInterface = flag.String("i", "", "specify network interface (or ip or hostname) to use for network operations") 57 | flagLastUsed = flag.Bool("p", false, "select last used device") 58 | flagList = flag.Bool("l", false, "list cached devices") 59 | flagPairCode = flag.String("pair", "", "manual pair using TV code, skip device discovery") 60 | flagSearch = flag.Bool("s", false, "search (discover) devices on the network and update cache") 61 | flagTimeout = flag.Duration("t", dial.MSearchMinTimeout, fmt.Sprintf("search timeout (max %s)", dial.MSearchMaxTimeout)) 62 | flagVerbose = flag.Bool("verbose", false, "enable verbose logging") 63 | flagVersion = flag.Bool("v", false, "print program version") 64 | ) 65 | 66 | // cast contains a dial.Device and the youtube.Remote connected to that Device. 67 | // Device will be nil if Remote was manually paired using a TV code. 68 | // It's stored in the cache. 69 | type cast struct { 70 | Device *dial.Device 71 | Remote *youtube.Remote 72 | LastUsed bool // true if Device is the last successfully used Device. 73 | cached bool // true if Device was fetched from the cache and not just discovered/updated. 74 | } 75 | 76 | func main() { 77 | flag.StringVar(flagDevName, "n", "", "deprecated, same as -d") 78 | flag.Usage = func() { 79 | fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [-a|-c|-d|-i|-l|-p|-s|-t|-v|-pair|-verbose] [video...]\n\n", progName) 80 | fmt.Fprintf(flag.CommandLine.Output(), "cast YouTube videos to your smart TV.\n\n") 81 | flag.PrintDefaults() 82 | fmt.Fprintf(flag.CommandLine.Output(), "\n%s %s\n%s\n", progName, progVersion, progRepo) 83 | } 84 | flag.Parse() 85 | 86 | if *flagVersion { 87 | fmt.Printf("%s %s\n", progName, progVersion) 88 | return 89 | } 90 | log.SetFlags(log.Ltime | log.Lshortfile) 91 | if !*flagVerbose { 92 | log.SetOutput(io.Discard) 93 | } 94 | log.Printf("%s %s\n", progName, progVersion) 95 | 96 | if err := run(); err != nil { 97 | log.Println(err) 98 | fmt.Fprintf(os.Stderr, "%s: %s\n", progName, err) 99 | os.Exit(1) 100 | } 101 | } 102 | 103 | func run() error { 104 | cacheFilePath := filepath.Join(mkCacheDir(), cacheFileName) 105 | cache := make(map[string]*cast) 106 | if !*flagClearCache { 107 | cache = loadCache(cacheFilePath) 108 | } 109 | defer saveCache(cacheFilePath, cache) 110 | 111 | var err error 112 | localAddr := "" 113 | if *flagNetInterface != "" { 114 | localAddr, err = addrFromInterface(*flagNetInterface) 115 | if err != nil { 116 | if errors.Is(err, errNoAddr) { 117 | return fmt.Errorf("%s: addrFromInterface: %s", *flagNetInterface, err) 118 | } 119 | log.Printf("%s: addrFromInterface: %s", *flagNetInterface, err) 120 | localAddr = *flagNetInterface // -i is not a valid interface, maybe it's an ip or hostname 121 | } 122 | log.Printf("using local address %s for network operations", localAddr) 123 | localAddr += ":0" // use a random port 124 | } 125 | 126 | if *flagPairCode != "" { 127 | return manualPair(cache, localAddr, *flagPairCode) 128 | } 129 | if len(cache) == 0 || *flagSearch { 130 | if err := discoverDevices(cache, localAddr, *flagTimeout); err != nil { 131 | return err 132 | } 133 | } 134 | 135 | var selected *cast 136 | switch { 137 | case *flagDevName != "": 138 | if selected, err = matchOneDevice(cache, *flagDevName); err == nil { 139 | break 140 | } 141 | if !errors.Is(err, errNoDevMatch) { 142 | return err 143 | } 144 | if err = discoverDevices(cache, localAddr, *flagTimeout); err != nil { 145 | return err 146 | } 147 | if len(cache) == 0 { 148 | return errNoDevFound 149 | } 150 | if selected, err = matchOneDevice(cache, *flagDevName); err != nil { 151 | return err 152 | } 153 | 154 | case *flagLastUsed: 155 | if selected = findLastUsedDevice(cache); selected == nil { 156 | return errNoDevLastUsed 157 | } 158 | 159 | case len(cache) == 0: 160 | // this check is done here and NOT immediately after the first 161 | // discoverDevices() to give a chance to rediscover in -d case. 162 | return errNoDevFound 163 | 164 | case *flagList, *flagSearch: 165 | listDevices(cache) 166 | return nil 167 | 168 | default: 169 | listDevices(cache) 170 | return errNoDevSelected 171 | } 172 | 173 | videos := flag.Args() 174 | if len(videos) == 0 || (len(videos) == 1 && videos[0] == "-") { 175 | if videos, err = readVideosFromStdin(); err != nil { 176 | return err 177 | } 178 | if len(videos) == 0 { 179 | return errNoVideo 180 | } 181 | } 182 | 183 | // Device and (or) Remote could come from the cache, we need to make sure 184 | // they use localAddr for network operations 185 | if selected.Device != nil { 186 | if err := selected.Device.SetLocalAddr(localAddr); err != nil { 187 | return fmt.Errorf("%q: SetLocalAddr: %w", selected.name(), err) 188 | } 189 | } 190 | if selected.Remote != nil { 191 | if err := selected.Remote.SetLocalAddr(localAddr); err != nil { 192 | return fmt.Errorf("%q: SetLocalAddr: %w", selected.name(), err) 193 | } 194 | } 195 | 196 | screenId := "" 197 | if selected.wasManuallyPaired() { 198 | // try to reuse the screenId since we can't know if it changed. 199 | screenId = selected.Remote.ScreenId 200 | } else { 201 | if !selected.Device.Ping() { 202 | log.Printf("%q is not awake, trying waking it up...", selected.name()) 203 | if err := selected.Device.TryWakeup(); err != nil { 204 | return fmt.Errorf("%q: TryWakeup: %w", selected.name(), err) 205 | } 206 | } 207 | if screenId, err = launchYouTubeApp(selected.Device); err != nil { 208 | return err 209 | } 210 | } 211 | for _, entry := range cache { 212 | entry.LastUsed = entry == selected 213 | } 214 | 215 | if needsToConnect(selected.Remote, screenId) { 216 | log.Printf("connecting to %q via YouTube Lounge", selected.name()) 217 | remote, err := youtube.Connect(localAddr, screenId, getConnectName()) 218 | if err != nil { 219 | return fmt.Errorf("Connect: %w", err) 220 | } 221 | if selected.wasManuallyPaired() { 222 | // these fields must be maintained because they are not 223 | // returned by Connect(), but only by ConnectWithCode(). 224 | remote.DeviceId = selected.Remote.DeviceId 225 | remote.ScreenName = selected.Remote.ScreenName 226 | } 227 | selected.Remote = remote 228 | } 229 | if *flagAdd { 230 | log.Printf("requesting YouTube Lounge to add %v to %q's playing queue", videos, selected.name()) 231 | if err := selected.Remote.Add(videos); err != nil { 232 | return fmt.Errorf("Add: %w", err) 233 | } 234 | return nil 235 | } 236 | log.Printf("requesting YouTube Lounge to play %v on %q", videos, selected.name()) 237 | if err := selected.Remote.Play(videos); err != nil { 238 | return fmt.Errorf("Play: %w", err) 239 | } 240 | return nil 241 | } 242 | 243 | func mkCacheDir() string { 244 | cacheDir := os.Getenv(xdgCache) 245 | if cacheDir == "" { 246 | homeDir, err := os.UserHomeDir() 247 | if err != nil { 248 | log.Println(err) 249 | return "." // current directory 250 | } 251 | cacheDir = filepath.Join(homeDir, fallbackCacheDir) 252 | } 253 | cacheDir = filepath.Join(cacheDir, progName) 254 | log.Printf("mkdir -p %s", cacheDir) 255 | if err := os.MkdirAll(cacheDir, 0755); err != nil { 256 | log.Println(err) 257 | return "." 258 | } 259 | return cacheDir 260 | } 261 | 262 | func loadCache(fpath string) map[string]*cast { 263 | log.Printf("loading cache %s", fpath) 264 | cache := make(map[string]*cast) 265 | data, err := os.ReadFile(fpath) 266 | if err != nil { 267 | log.Println(err) 268 | return cache 269 | } 270 | var cacheValues []*cast 271 | if err = json.Unmarshal(data, &cacheValues); err != nil { 272 | log.Printf("unmarshal cache: %s", err) 273 | return cache 274 | } 275 | for _, entry := range cacheValues { 276 | entry.cached = true 277 | cache[entry.uuid()] = entry 278 | } 279 | return cache 280 | } 281 | 282 | func saveCache(fpath string, cache map[string]*cast) { 283 | log.Printf("saving cache %s", fpath) 284 | var cacheValues []*cast 285 | for _, entry := range cache { 286 | cacheValues = append(cacheValues, entry) 287 | } 288 | data, err := json.Marshal(cacheValues) 289 | if err != nil { 290 | log.Printf("marshal cache: %s", err) 291 | return 292 | } 293 | if err := os.WriteFile(fpath, data, 0600); err != nil { 294 | log.Println(err) 295 | } 296 | } 297 | 298 | func addrFromInterface(name string) (string, error) { 299 | iface, err := net.InterfaceByName(name) 300 | if err != nil { 301 | return "", err 302 | } 303 | addrs, err := iface.Addrs() 304 | if err != nil { 305 | return "", fmt.Errorf("%w: Addrs: %w", errNoAddr, err) 306 | } 307 | for _, addr := range addrs { 308 | if a, ok := addr.(*net.IPNet); ok { 309 | return a.IP.String(), nil 310 | } 311 | } 312 | return "", errNoAddr 313 | } 314 | 315 | func manualPair(cache map[string]*cast, localAddr, code string) error { 316 | if code = strings.TrimSpace(code); code == "" { 317 | return errInvalidCode 318 | } 319 | log.Println("connecting to device via YouTube Lounge and pairing code") 320 | remote, err := youtube.ConnectWithCode(localAddr, code, getConnectName()) 321 | if err != nil { 322 | return fmt.Errorf("ConnectWithCode: %w", err) 323 | } 324 | if remote.DeviceId == "" { 325 | remote.DeviceId = strings.ReplaceAll(time.Now().Format(fallbackIdFormat), ".", "") 326 | } 327 | if remote.ScreenName == "" { 328 | remote.ScreenName = remote.DeviceId 329 | } 330 | if entry, ok := cache[remote.DeviceId]; ok { 331 | entry.Remote = remote 332 | entry.cached = false 333 | } else { 334 | cache[remote.DeviceId] = &cast{Remote: remote} 335 | } 336 | fmt.Println(cache[remote.DeviceId]) 337 | return nil 338 | } 339 | 340 | func discoverDevices(cache map[string]*cast, localAddr string, timeout time.Duration) error { 341 | devCh, err := dial.Discover(nil, localAddr, timeout) 342 | if err != nil { 343 | return fmt.Errorf("Discover: %w", err) 344 | } 345 | for dev := range devCh { 346 | if entry, ok := cache[dev.UniqueServiceName]; ok { 347 | entry.Device = dev 348 | entry.cached = false 349 | } else { 350 | cache[dev.UniqueServiceName] = &cast{Device: dev} 351 | } 352 | } 353 | return nil 354 | } 355 | 356 | func matchOneDevice(cache map[string]*cast, name string) (*cast, error) { 357 | nameLow := strings.ToLower(strings.TrimSpace(name)) 358 | var matched []*cast 359 | for _, entry := range cache { 360 | matches := strings.Contains(strings.ToLower(entry.name()), nameLow) || 361 | strings.Contains(strings.ToLower(entry.hostname()), nameLow) || 362 | strings.Contains(strings.ToLower(entry.uuid()), nameLow) 363 | if matches { 364 | matched = append(matched, entry) 365 | } 366 | } 367 | if len(matched) == 1 { 368 | return matched[0], nil 369 | } 370 | if len(matched) == 0 { 371 | return nil, fmt.Errorf("%w %q", errNoDevMatch, name) 372 | } 373 | var matchedStr strings.Builder 374 | for _, m := range matched { 375 | matchedStr.WriteRune('\n') 376 | matchedStr.WriteString(m.String()) 377 | } 378 | return nil, fmt.Errorf("%w %q:%s", errMoreDevMatch, name, matchedStr.String()) 379 | } 380 | 381 | func findLastUsedDevice(cache map[string]*cast) *cast { 382 | for _, entry := range cache { 383 | if entry.LastUsed { 384 | return entry 385 | } 386 | } 387 | return nil 388 | } 389 | 390 | func listDevices(cache map[string]*cast) { 391 | var entries []*cast 392 | for _, entry := range cache { 393 | entries = append(entries, entry) 394 | } 395 | sort.Slice(entries, func(i, j int) bool { 396 | switch { 397 | case !entries[i].cached && entries[j].cached: 398 | return true 399 | case entries[i].cached && !entries[j].cached: 400 | return false 401 | case entries[i].LastUsed: 402 | return true 403 | case entries[j].LastUsed: 404 | return false 405 | } 406 | return entries[i].name() < entries[j].name() 407 | }) 408 | for _, entry := range entries { 409 | fmt.Println(entry) 410 | } 411 | } 412 | 413 | func (c *cast) wasManuallyPaired() bool { 414 | return c.Device == nil // implies c.Remote != nil 415 | } 416 | 417 | func (c *cast) uuid() string { 418 | if c.wasManuallyPaired() { 419 | return c.Remote.DeviceId 420 | } 421 | return c.Device.UniqueServiceName 422 | } 423 | 424 | func (c *cast) name() string { 425 | if c.wasManuallyPaired() { 426 | return c.Remote.ScreenName 427 | } 428 | return c.Device.FriendlyName 429 | } 430 | 431 | func (c *cast) hostname() string { 432 | if c.wasManuallyPaired() { 433 | return "unknown" 434 | } 435 | return c.Device.Hostname() 436 | } 437 | 438 | func (c *cast) String() string { 439 | var info []string 440 | if c.cached { 441 | info = append(info, "cached") 442 | } 443 | if c.LastUsed { 444 | info = append(info, "lastused") 445 | } 446 | return fmt.Sprintf("%.8s %-15s %-30q %s", 447 | strings.TrimPrefix(c.uuid(), "uuid:"), c.hostname(), c.name(), strings.Join(info, " ")) 448 | } 449 | 450 | func launchYouTubeApp(dev *dial.Device) (string, error) { 451 | for start := time.Now(); time.Since(start) < launchTimeout; time.Sleep(launchCheckInterval) { 452 | app, err := dev.GetAppInfo(youtube.DialAppName, youtube.Origin) 453 | if err != nil { 454 | return "", fmt.Errorf("%q: GetAppInfo: %q: %w", dev.FriendlyName, youtube.DialAppName, err) 455 | } 456 | 457 | log.Printf("%q is %s on %q", youtube.DialAppName, app.State, dev.FriendlyName) 458 | switch app.State { 459 | case "running": 460 | screenId, err := youtube.ExtractScreenId(app.Additional.Data) 461 | if err != nil { 462 | return "", err 463 | } 464 | if screenId != "" { 465 | return screenId, nil 466 | } 467 | log.Println("screenId not available") 468 | 469 | case "stopped", "hidden": 470 | log.Printf("launching %q on %q", youtube.DialAppName, dev.FriendlyName) 471 | if _, err := dev.Launch(youtube.DialAppName, youtube.Origin, ""); err != nil { 472 | return "", fmt.Errorf("%q: Launch: %q: %w", dev.FriendlyName, youtube.DialAppName, err) 473 | } 474 | 475 | default: 476 | return "", fmt.Errorf("%q: %q: %q: %w", dev.FriendlyName, youtube.DialAppName, app.State, errUnknownAppState) 477 | } 478 | } 479 | return "", fmt.Errorf("%q: %q: %w", dev.FriendlyName, youtube.DialAppName, errNoLaunch) 480 | } 481 | 482 | func readVideosFromStdin() ([]string, error) { 483 | log.Println("reading videos from stdin") 484 | scanner := bufio.NewScanner(os.Stdin) 485 | var videos []string 486 | for scanner.Scan() { 487 | if v := strings.TrimSpace(scanner.Text()); v != "" { 488 | videos = append(videos, v) 489 | } 490 | } 491 | return videos, scanner.Err() 492 | } 493 | 494 | func needsToConnect(remote *youtube.Remote, screenId string) bool { 495 | switch { 496 | case remote == nil: 497 | return true 498 | case remote.ScreenId != screenId: 499 | log.Println("screenId changed") 500 | return true 501 | case remote.Expired(): 502 | log.Println("LoungeToken expired, trying refreshing it") 503 | if err := remote.RefreshToken(); err != nil { 504 | log.Printf("RefreshToken: %s", err) 505 | return true 506 | } 507 | } 508 | return false 509 | } 510 | 511 | func getConnectName() string { 512 | u, err := user.Current() 513 | if err != nil { 514 | log.Println(err) 515 | return progName 516 | } 517 | h, err := os.Hostname() 518 | if err != nil { 519 | log.Println(err) 520 | return progName 521 | } 522 | return fmt.Sprintf("%s@%s", u.Username, h) 523 | } 524 | --------------------------------------------------------------------------------