├── .github └── workflows │ ├── goreleaser.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── application ├── application.go ├── application_test.go ├── errors.go ├── info.go └── mocks │ ├── App.go │ ├── ApplicationOption.go │ └── CastMessageFunc.go ├── cast ├── connection.go ├── mocks │ ├── Conn.go │ └── Payload.go ├── payload.go └── proto │ ├── cast_channel.pb.go │ └── cast_channel.proto ├── cmd ├── httpserver.go ├── load-app.go ├── load.go ├── ls.go ├── mute.go ├── next.go ├── pause.go ├── playlist.go ├── previous.go ├── restart.go ├── rewind.go ├── root.go ├── scan.go ├── seek-to.go ├── seek.go ├── skipad.go ├── slideshow.go ├── status.go ├── stop.go ├── togglepause.go ├── transcode.go ├── tts.go ├── ui.go ├── unmute.go ├── unpause.go ├── utils.go ├── volume-down.go ├── volume-up.go ├── volume.go └── watch.go ├── dns ├── dns.go └── dns_test.go ├── go-chromecast-ui.png ├── go.mod ├── go.sum ├── http ├── handlers.go └── types.go ├── main.go ├── main_test.go ├── playlists ├── playlist_test.go ├── playlists.go ├── testdata │ ├── indiepop130.m3u │ └── indiepop64.pls └── utlis.go ├── regenerate_mocks.sh ├── storage └── storage.go ├── testdata ├── hello.txt ├── indiepop64.pls └── version.txt ├── todo.txt ├── tts └── tts.go └── ui ├── key_bindings.go ├── ui.go ├── update_status.go ├── view_keys.go ├── view_log.go ├── view_progress.go ├── view_status.go ├── view_volume.go └── views.go /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@master 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24.2' 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | version: '~> v2' 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | go-version: [1.21.x, 1.22.x, 1.23.x, 1.24.x] 14 | platform: [ubuntu-latest, macos-latest] 15 | fail-fast: false 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - name: Install Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | - name: Cache & restore go mod 25 | uses: actions/cache@v4 26 | id: cache 27 | with: 28 | path: ~/go/pkg/mod 29 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 30 | restore-keys: | 31 | ${{ runner.os }}-go- 32 | - name: Install Dependencies 33 | if: steps.cache.outputs.cache-hit != 'true' 34 | run: go mod download 35 | - name: Test 36 | run: go test ./... 37 | build: 38 | strategy: 39 | matrix: 40 | go-version: [1.21.x, 1.22.x, 1.23.x, 1.24.x] 41 | platform: [ubuntu-latest, macos-latest] 42 | fail-fast: false 43 | runs-on: ${{ matrix.platform }} 44 | steps: 45 | - name: Install Go 46 | uses: actions/setup-go@v5 47 | with: 48 | go-version: ${{ matrix.go-version }} 49 | - name: Checkout code 50 | uses: actions/checkout@v4 51 | - name: Cache & restore go mod 52 | uses: actions/cache@v4 53 | id: cache 54 | with: 55 | path: ~/go/pkg/mod 56 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 57 | restore-keys: | 58 | ${{ runner.os }}-go- 59 | - name: Install Dependencies 60 | if: steps.cache.outputs.cache-hit != 'true' 61 | run: go mod download 62 | - name: Build 63 | run: go build 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | chromecast 3 | go-chromecast 4 | dist/ 5 | go-chromecast.exe -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | version: 2 8 | 9 | before: 10 | hooks: 11 | # You may remove this if you don't use go modules. 12 | - go mod tidy 13 | # you may remove this if you don't need go generate 14 | - go generate ./... 15 | 16 | builds: 17 | - env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - linux 21 | - windows 22 | - darwin 23 | ldflags: 24 | - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}} 25 | 26 | archives: 27 | - formats: [tar.gz] 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | formats: [zip] 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | 48 | release: 49 | footer: >- 50 | 51 | --- 52 | 53 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [2018] [Jonathan Pentecost] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chromecast 2 | 3 | Implements a small number of the google chromecast commands. Other than the basic commands, it also allows you to play media files from your computer either individually or in a playlist; the `playlist` command will look at all the files in a folder and play them sorted by numerically. It also lets you play a slideshow of images with the `slideshow` command. 4 | 5 | ## Playable Media Content 6 | 7 | Can load a local media file or a file hosted on the internet on your chromecast with the following format: 8 | 9 | ``` 10 | Supported Media formats: 11 | - MP3 12 | - AVI 13 | - MKV 14 | - MP4 15 | - WebM 16 | - FLAC 17 | - WAV 18 | ``` 19 | 20 | If an unknown video file is found, it will use `ffmpeg` to transcode it to MP4 and stream it to the chromecast. 21 | 22 | ## Play Local Media Files 23 | 24 | We are able to play local media files by creating a http server that will stream the media file to the cast device. 25 | 26 | ## Cast DNS Lookup 27 | 28 | A DNS multicast is used to determine the Chromecast and Google Home devices. 29 | 30 | The cast DNS entry is also cached, this means that if you pass through the device name, `-n `, or the 31 | device uuid, `-u `, the results will be cached and it will connect to the chromecast device instantly. 32 | 33 | ## Installing 34 | 35 | ### Install release binaries 36 | 37 | https://github.com/vishen/go-chromecast/releases 38 | 39 | - If using Linux: Download the latest release, unzip using `tar -xzf go-chromecast.tar.gz`, and install using `sudo install ./go-chromecast /usr/bin/` 40 | 41 | ### Install the usual Go way: 42 | 43 | #### Go 1.18 and above 44 | 45 | ``` 46 | $ go install github.com/vishen/go-chromecast@latest 47 | ``` 48 | 49 | #### Go 1.17 and below 50 | 51 | ``` 52 | $ go get -u github.com/vishen/go-chromecast 53 | ``` 54 | 55 | ## Commands 56 | 57 | ``` 58 | Control your Google Chromecast or Google Home Mini from the 59 | command line. 60 | 61 | Usage: 62 | go-chromecast [flags] 63 | go-chromecast [command] 64 | 65 | Available Commands: 66 | help Help about any command 67 | httpserver Start the HTTP server 68 | load Load and play media on the chromecast 69 | load-app Load and play content on a chromecast app 70 | ls List devices 71 | mute Mute the chromecast 72 | next Play the next available media 73 | pause Pause the currently playing media on the chromecast 74 | playlist Load and play media on the chromecast 75 | previous Play the previous available media 76 | restart Restart the currently playing media 77 | rewind Rewind by seconds the currently playing media 78 | seek Seek by seconds into the currently playing media 79 | seek-to Seek to the in the currently playing media 80 | slideshow Play a slideshow of photos 81 | status Current chromecast status 82 | stop Stop casting 83 | transcode Transcode and play media on the chromecast 84 | tts text-to-speech 85 | ui Run the UI 86 | unmute Unmute the chromecast 87 | unpause Unpause the currently playing media on the chromecast 88 | volume Get or set volume 89 | volume-down Turn down volume 90 | volume-up Turn up volume 91 | watch Watch all events sent from a chromecast device 92 | 93 | Flags: 94 | -a, --addr string Address of the chromecast device 95 | -v, --debug debug logging 96 | -d, --device string chromecast device, ie: 'Chromecast' or 'Google Home Mini' 97 | -n, --device-name string chromecast device name 98 | --disable-cache disable the cache 99 | --dns-timeout int Multicast DNS timeout in seconds when searching for chromecast DNS entries (default 3) 100 | --first Use first cast device found 101 | -h, --help help for go-chromecast 102 | -i, --iface string Network interface to use when looking for a local address to use for the http server or for use with multicast dns discovery 103 | -p, --port string Port of the chromecast device if 'addr' is specified (default "8009") 104 | -u, --uuid string chromecast device uuid 105 | --verbose verbose logging 106 | --version display command version 107 | --with-ui run with a UI 108 | 109 | Use "go-chromecast [command] --help" for more information about a command. 110 | ``` 111 | 112 | ## Usage 113 | 114 | ``` 115 | # View available cast devices. 116 | $ go-chromecast ls 117 | Found 2 cast devices 118 | 1) device="Chromecast" device_name="MarieGotGame?" address="192.168.0.115:8009" status="" uuid="b380c5847b3182e4fb2eb0d0e270bf16" 119 | 2) device="Google Home Mini" device_name="Living Room Speaker" address="192.168.0.52:8009" status="" uuid="b87d86bed423a6feb8b91a7d2778b55c" 120 | 121 | # Status of a cast device. 122 | $ go-chromecast status 123 | Found 2 cast dns entries, select one: 124 | 1) device="Chromecast" device_name="MarieGotGame?" address="192.168.0.115:8009" status="" uuid="b380c5847b3182e4fb2eb0d0e270bf16" 125 | 2) device="Google Home Mini" device_name="Living Room Speaker" address="192.168.0.52:8009" status="" uuid="b87d86bed423a6feb8b91a7d2778b55c" 126 | Enter selection: 1 127 | Idle (Backdrop), volume=1.00 muted=false 128 | 129 | # Specify a cast device name. 130 | $ go-chromecast status -n "Living Room Speaker" 131 | Idle, volume=0.17 muted=false 132 | 133 | # Specify a cast device by ip address. 134 | $ go-chromecast status -a 192.168.0.52 135 | Idle, volume=0.17 muted=false 136 | 137 | # Specify a cast device uuid. 138 | $ go-chromecast status -u b87d86bed423a6feb8b91a7d2778b55c 139 | Idle (Default Media Receiver), volume=0.17 muted=false 140 | 141 | # Play a file hosted on the internet 142 | $ go-chromecast load https://example.com/path/to/media.mp4 143 | 144 | # Load a local media file (can play both audio and video). 145 | $ go-chromecast load ~/Downloads/SampleAudio_0.4mb.mp3 146 | Found 2 cast dns entries, select one: 147 | 1) device="Chromecast" device_name="MarieGotGame?" address="192.168.0.115:8009" status="" uuid="b380c5847b3182e4fb2eb0d0e270bf16" 148 | 2) device="Google Home Mini" device_name="Living Room Speaker" address="192.168.0.52:8009" status="" uuid="b87d86bed423a6feb8b91a7d2778b55c" 149 | Enter selection: 2 150 | 151 | # Status of cast device running an audio file. 152 | $ go-chromecast status 153 | Found 2 cast dns entries, select one: 154 | 1) device="Chromecast" device_name="MarieGotGame?" address="192.168.0.115:8009" status="" uuid="b380c5847b3182e4fb2eb0d0e270bf16" 155 | 2) device="Google Home Mini" device_name="Living Room Speaker" address="192.168.0.52:8009" status="Default Media Receiver" uuid="b87d86bed423a6feb8b91a7d2778b55c" 156 | Enter selection: 2 157 | Default Media Receiver (PLAYING), unknown, time remaining=8s/28s, volume=1.00, muted=false 158 | 159 | # Play a playlist of media files. 160 | $ go-chromecast playlist ~/playlist_test/ -n "Living Room Speaker" 161 | Attemping to play the following media: 162 | - /home/jonathan/playlist_test/SampleAudio_0.4mb.mp3 163 | - /home/jonathan/playlist_test/sample_1.mp3 164 | 165 | # Select where to start a playlist from. 166 | $ go-chromecast playlist ~/playlist_test/ -n "Living Room Speaker" --select 167 | Will play the following items, select where to start from: 168 | 1) /home/jonathan/playlist_test/SampleAudio_0.4mb.mp3: last played "2018-11-25 11:17:25 +0000 GMT" 169 | 2) /home/jonathan/playlist_test/sample_1.mp3: last played "2018-11-25 11:17:28 +0000 GMT" 170 | Enter selection: 2 171 | Attemping to play the following media: 172 | - /home/jonathan/playlist_test/sample_1.mp3 173 | 174 | # Start a playlist from the start, ignoring if you have previously played that playlist. 175 | $ go-chromecast playlist ~/playlist_test/ -n "Living Room Speaker" --continue=false 176 | 177 | # Start a playlist and launch the terminal ui 178 | $ go-chromecast playlist ~/playlist_test/ -n "Living Room Speaker" --with-ui 179 | 180 | # Start a slideshow of images 181 | $ go-chromecast slideshow slideshow_images/*.png --repeat=false 182 | 183 | # Pause the playing media. 184 | $ go-chromecast pause 185 | 186 | # Continue playing the currently playing media. 187 | $ go-chromecast play 188 | 189 | # Play the next item in a playlist. 190 | $ go-chromecast next 191 | 192 | # Play the previous item in a playlist. 193 | $ go-chromecast previous 194 | 195 | # Rewind the currently playing media by x seconds. 196 | $ go-chromecast rewind 30 197 | 198 | # Go forward in the currently playing media by x seconds. 199 | $ go-chromecast seek 30 200 | 201 | # Get the current volume level 202 | $ go-chromecast volume 203 | 204 | # Set the volume level 205 | $ go-chromecast volume 0.55 206 | 207 | # Turn up the volume 208 | $ go-chromecast volume-up --step 0.10 209 | 210 | # View what messages a cast device is sending out. 211 | $ go-chromecast watch 212 | 213 | # Use a terminal UI to interact with the cast device 214 | $ go-chromecast ui 215 | ``` 216 | 217 | ## User Interface 218 | 219 | ![User-interface example](go-chromecast-ui.png "User-interface example") 220 | 221 | A basic terminal user-interface is provided, that supports the following controls: 222 | 223 | - Quit: "q" 224 | - Play/Pause: SPACE 225 | - Volume: - / + 226 | - Mute/Unmute: "m" 227 | - Seek (15s): <- / -> 228 | - Previous/Next: PgUp / PgDn 229 | - Stop: "s" 230 | 231 | It can be run in the following ways: 232 | 233 | ### Standalone 234 | 235 | If you just want to remote-control a chromecast that is already playing something: 236 | 237 | ``` 238 | $ go-chromecast ui 239 | ``` 240 | 241 | ### Playlist 242 | 243 | Use the UI in combination with the `playlist` command (detailed above): 244 | 245 | ``` 246 | $ go-chromecast --with-ui playlist /path/to/directory 247 | ``` 248 | 249 | ### Load 250 | 251 | Use the UI in combination with the `load` command (detailed above): 252 | 253 | ``` 254 | $ go-chromecast --with-ui load /path/to/file.flac 255 | ``` 256 | 257 | ## HTTP API Server 258 | 259 | There is a HTTP API server provided that has the following api: 260 | 261 | ``` 262 | GET /devices?wait=...&iface=... 263 | POST /connect?uuid=&addr=&port= 264 | POST /connect-all?wait=...&iface=... 265 | POST /disconnect?uuid= 266 | POST /disconnect-all 267 | GET /status?uuid= 268 | GET /statuses (Deprecated use status-all) 269 | GET /status-all 270 | POST /pause?uuid= 271 | POST /unpause?uuid= 272 | POST /skipad?uuid= 273 | POST /mute?uuid= 274 | POST /unmute?uuid= 275 | POST /stop?uuid= 276 | GET /volume?uuid= 277 | POST /volume?uuid=&volume= 278 | POST /rewind?uuid=&seconds= 279 | POST /seek?uuid=&seconds= 280 | POST /seek-to?uuid=&seconds= 281 | POST /load?uuid=&path=&content_type=&start_time= 282 | ``` 283 | 284 | ``` 285 | $ go-chromecast httpserver 286 | 287 | Start the HTTP server which provides an HTTP 288 | api to control chromecast devices on a network. 289 | 290 | Usage: 291 | go-chromecast httpserver [flags] 292 | 293 | Flags: 294 | -h, --help help for httpserver 295 | --http-addr string addr for the http server to listen on (default "0.0.0.0") 296 | --http-port string port for the http server to listen on (default "8011") 297 | ``` 298 | 299 | ## Playlist 300 | 301 | There is support for playing media items as a playlist. 302 | 303 | If playing from a playlist, you are able to pass though the `--select` flag, and this will allow you to select 304 | the media to start playing from. This is useful if you have already played some of the media and want to start 305 | from one you haven't played yet. 306 | 307 | A cache is kept of played media, so if you are playing media from a playlist, it will check to see what 308 | media files you have recently played and play the next one from the playlist. `--continue=false` can be passed 309 | through and this will start the playlist from the start. 310 | 311 | ## Discover sent and received events from a Device 312 | 313 | If you would like to see what a device is sending, you are able to `watch` the protobuf messages being sent from your device: 314 | 315 | ``` 316 | $ go-chromecast watch 317 | ``` 318 | 319 | ### Text To Speech 320 | 321 | Experimental text-to-speech support has been added. This uses [Google 322 | Cloud's Text-to-Speech](https://cloud.google.com/text-to-speech/) to 323 | turn text into an mp3 audio file, this is then streamed to the device. 324 | 325 | Text-to-speech api needs to be enabled https://console.cloud.google.com/flows/enableapi?apiid=texttospeech.googleapis.com and a google service account is required https://console.cloud.google.com/apis/credentials/serviceaccountkey 326 | 327 | ``` 328 | $ go-chromecast tts --google-service-account=/path/to/service/account.json 329 | ``` 330 | 331 | For non en-US languages 332 | 333 | ``` 334 | $ go-chromecast tts --google-service-account=/path/to/service/account.json \ 335 | --voice-name en-US-Wavenet-G --speaking-rate 1.05 --pitch 0.9 336 | ``` 337 | 338 | List of available voices (voice-name) can be found here: https://cloud.google.com/text-to-speech/ 339 | 340 | Use [SSML](https://cloud.google.com/text-to-speech/docs/ssml) 341 | 342 | ``` 343 | $ go-chromecast tts 'Helloworld.' \ 344 | --google-service-account=/path/to/service/account.json \ 345 | --ssml 346 | ``` 347 | -------------------------------------------------------------------------------- /application/application_test.go: -------------------------------------------------------------------------------- 1 | package application_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/mock" 8 | "github.com/stretchr/testify/require" 9 | "github.com/vishen/go-chromecast/application" 10 | "github.com/vishen/go-chromecast/cast" 11 | mockCast "github.com/vishen/go-chromecast/cast/mocks" 12 | pb "github.com/vishen/go-chromecast/cast/proto" 13 | ) 14 | 15 | var mockAddr = "foo.bar" 16 | var mockPort = 42 17 | 18 | func TestApplicationStart(t *testing.T) { 19 | assertions := require.New(t) 20 | 21 | recvChan := make(chan *pb.CastMessage, 5) 22 | conn := &mockCast.Conn{} 23 | conn.On("MsgChan").Return(recvChan) 24 | conn.On("Start", mockAddr, mockPort).Return(nil) 25 | conn.On("Send", mock.IsType(0), mock.IsType(&cast.PayloadHeader{}), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")). 26 | Run(func(args mock.Arguments) { 27 | payload := cast.GetStatusHeader 28 | payload.SetRequestId(args.Int(0)) 29 | payloadBytes, err := json.Marshal(&cast.ReceiverStatusResponse{PayloadHeader: payload}) 30 | assertions.NoError(err) 31 | payloadString := string(payloadBytes) 32 | protocolVersion := pb.CastMessage_CASTV2_1_0 33 | payloadType := pb.CastMessage_STRING 34 | recvChan <- &pb.CastMessage{ 35 | ProtocolVersion: &protocolVersion, 36 | PayloadType: &payloadType, 37 | PayloadUtf8: &payloadString, 38 | PayloadBinary: payloadBytes, 39 | } 40 | }).Return(nil) 41 | conn.On("Send", mock.IsType(0), mock.IsType(&cast.ConnectHeader), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil) 42 | app := application.NewApplication(application.WithConnection(conn)) 43 | assertions.NoError(app.Start(mockAddr, mockPort)) 44 | } 45 | -------------------------------------------------------------------------------- /application/errors.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | ErrApplicationNotSet = errors.New("application isn't set") 7 | ErrMediaNotYetInitialised = errors.New("media not yet initialised") 8 | ErrNoMediaNext = errors.New("media not yet initialised, there is nothing to go to next") 9 | ErrNoMediaPause = errors.New("media not yet initialised, there is nothing to pause") 10 | ErrNoMediaPrevious = errors.New("media not yet initialised, there is nothing previous") 11 | ErrNoMediaSkip = errors.New("media not yet initialised, there is nothing to skip") 12 | ErrNoMediaStop = errors.New("media not yet initialised, there is nothing to stop") 13 | ErrNoMediaUnpause = errors.New("media not yet initialised, there is nothing to unpause") 14 | ErrNoMediaTogglePause = errors.New("media not yet initialised, there is nothing to (un)pause") 15 | ErrNoMediaSkipad = errors.New("No ad detected, there is nothing to skip") 16 | ErrVolumeOutOfRange = errors.New("specified volume is out of range (0 - 1)") 17 | ErrAdMaxLoop = errors.New("Unable to skip ad for unknown reason") 18 | ) 19 | -------------------------------------------------------------------------------- /application/info.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/vishen/go-chromecast/cast" 10 | ) 11 | 12 | // getInfo uses the http://:8008/setup/eureka_endpoint to obtain more 13 | // information about the cast-device. 14 | // OBS: The 8008 seems to be pure http, whereas 8009 is typically the port 15 | // to use for protobuf-communication, 16 | 17 | func GetInfo(ip string) (info *cast.DeviceInfo, err error) { 18 | // Note: Services exposed not on 8009 port are "Google Cast Group"s 19 | // The only way to find the true device (group) name, is using mDNS outside of this function. 20 | url := fmt.Sprintf("http://%v:8008/setup/eureka_info", ip) 21 | resp, err := http.Get(url) 22 | if err != nil { 23 | return nil, err 24 | } 25 | defer resp.Body.Close() 26 | data, err := io.ReadAll(resp.Body) 27 | if err != nil { 28 | return nil, err 29 | } 30 | info = new(cast.DeviceInfo) 31 | if err := json.Unmarshal(data, info); err != nil { 32 | return nil, err 33 | } 34 | return info, nil 35 | } 36 | -------------------------------------------------------------------------------- /application/mocks/App.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.49.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | application "github.com/vishen/go-chromecast/application" 7 | cast "github.com/vishen/go-chromecast/cast" 8 | 9 | mock "github.com/stretchr/testify/mock" 10 | 11 | net "net" 12 | ) 13 | 14 | // App is an autogenerated mock type for the App type 15 | type App struct { 16 | mock.Mock 17 | } 18 | 19 | // AddMessageFunc provides a mock function with given fields: f 20 | func (_m *App) AddMessageFunc(f application.CastMessageFunc) { 21 | _m.Called(f) 22 | } 23 | 24 | // Close provides a mock function with given fields: stopMedia 25 | func (_m *App) Close(stopMedia bool) error { 26 | ret := _m.Called(stopMedia) 27 | 28 | if len(ret) == 0 { 29 | panic("no return value specified for Close") 30 | } 31 | 32 | var r0 error 33 | if rf, ok := ret.Get(0).(func(bool) error); ok { 34 | r0 = rf(stopMedia) 35 | } else { 36 | r0 = ret.Error(0) 37 | } 38 | 39 | return r0 40 | } 41 | 42 | // Info provides a mock function with given fields: 43 | func (_m *App) Info() (*cast.DeviceInfo, error) { 44 | ret := _m.Called() 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for Info") 48 | } 49 | 50 | var r0 *cast.DeviceInfo 51 | var r1 error 52 | if rf, ok := ret.Get(0).(func() (*cast.DeviceInfo, error)); ok { 53 | return rf() 54 | } 55 | if rf, ok := ret.Get(0).(func() *cast.DeviceInfo); ok { 56 | r0 = rf() 57 | } else { 58 | if ret.Get(0) != nil { 59 | r0 = ret.Get(0).(*cast.DeviceInfo) 60 | } 61 | } 62 | 63 | if rf, ok := ret.Get(1).(func() error); ok { 64 | r1 = rf() 65 | } else { 66 | r1 = ret.Error(1) 67 | } 68 | 69 | return r0, r1 70 | } 71 | 72 | // Load provides a mock function with given fields: filenameOrUrl, startTime, contentType, transcode, detach, forceDetach 73 | func (_m *App) Load(filenameOrUrl string, startTime int, contentType string, transcode bool, detach bool, forceDetach bool) error { 74 | ret := _m.Called(filenameOrUrl, startTime, contentType, transcode, detach, forceDetach) 75 | 76 | if len(ret) == 0 { 77 | panic("no return value specified for Load") 78 | } 79 | 80 | var r0 error 81 | if rf, ok := ret.Get(0).(func(string, int, string, bool, bool, bool) error); ok { 82 | r0 = rf(filenameOrUrl, startTime, contentType, transcode, detach, forceDetach) 83 | } else { 84 | r0 = ret.Error(0) 85 | } 86 | 87 | return r0 88 | } 89 | 90 | // LoadApp provides a mock function with given fields: appID, contentID 91 | func (_m *App) LoadApp(appID string, contentID string) error { 92 | ret := _m.Called(appID, contentID) 93 | 94 | if len(ret) == 0 { 95 | panic("no return value specified for LoadApp") 96 | } 97 | 98 | var r0 error 99 | if rf, ok := ret.Get(0).(func(string, string) error); ok { 100 | r0 = rf(appID, contentID) 101 | } else { 102 | r0 = ret.Error(0) 103 | } 104 | 105 | return r0 106 | } 107 | 108 | // Next provides a mock function with given fields: 109 | func (_m *App) Next() error { 110 | ret := _m.Called() 111 | 112 | if len(ret) == 0 { 113 | panic("no return value specified for Next") 114 | } 115 | 116 | var r0 error 117 | if rf, ok := ret.Get(0).(func() error); ok { 118 | r0 = rf() 119 | } else { 120 | r0 = ret.Error(0) 121 | } 122 | 123 | return r0 124 | } 125 | 126 | // Pause provides a mock function with given fields: 127 | func (_m *App) Pause() error { 128 | ret := _m.Called() 129 | 130 | if len(ret) == 0 { 131 | panic("no return value specified for Pause") 132 | } 133 | 134 | var r0 error 135 | if rf, ok := ret.Get(0).(func() error); ok { 136 | r0 = rf() 137 | } else { 138 | r0 = ret.Error(0) 139 | } 140 | 141 | return r0 142 | } 143 | 144 | // PlayableMediaType provides a mock function with given fields: filename 145 | func (_m *App) PlayableMediaType(filename string) bool { 146 | ret := _m.Called(filename) 147 | 148 | if len(ret) == 0 { 149 | panic("no return value specified for PlayableMediaType") 150 | } 151 | 152 | var r0 bool 153 | if rf, ok := ret.Get(0).(func(string) bool); ok { 154 | r0 = rf(filename) 155 | } else { 156 | r0 = ret.Get(0).(bool) 157 | } 158 | 159 | return r0 160 | } 161 | 162 | // PlayedItems provides a mock function with given fields: 163 | func (_m *App) PlayedItems() map[string]application.PlayedItem { 164 | ret := _m.Called() 165 | 166 | if len(ret) == 0 { 167 | panic("no return value specified for PlayedItems") 168 | } 169 | 170 | var r0 map[string]application.PlayedItem 171 | if rf, ok := ret.Get(0).(func() map[string]application.PlayedItem); ok { 172 | r0 = rf() 173 | } else { 174 | if ret.Get(0) != nil { 175 | r0 = ret.Get(0).(map[string]application.PlayedItem) 176 | } 177 | } 178 | 179 | return r0 180 | } 181 | 182 | // Previous provides a mock function with given fields: 183 | func (_m *App) Previous() error { 184 | ret := _m.Called() 185 | 186 | if len(ret) == 0 { 187 | panic("no return value specified for Previous") 188 | } 189 | 190 | var r0 error 191 | if rf, ok := ret.Get(0).(func() error); ok { 192 | r0 = rf() 193 | } else { 194 | r0 = ret.Error(0) 195 | } 196 | 197 | return r0 198 | } 199 | 200 | // QueueLoad provides a mock function with given fields: filenames, contentType, transcode 201 | func (_m *App) QueueLoad(filenames []string, contentType string, transcode bool) error { 202 | ret := _m.Called(filenames, contentType, transcode) 203 | 204 | if len(ret) == 0 { 205 | panic("no return value specified for QueueLoad") 206 | } 207 | 208 | var r0 error 209 | if rf, ok := ret.Get(0).(func([]string, string, bool) error); ok { 210 | r0 = rf(filenames, contentType, transcode) 211 | } else { 212 | r0 = ret.Error(0) 213 | } 214 | 215 | return r0 216 | } 217 | 218 | // Seek provides a mock function with given fields: value 219 | func (_m *App) Seek(value int) error { 220 | ret := _m.Called(value) 221 | 222 | if len(ret) == 0 { 223 | panic("no return value specified for Seek") 224 | } 225 | 226 | var r0 error 227 | if rf, ok := ret.Get(0).(func(int) error); ok { 228 | r0 = rf(value) 229 | } else { 230 | r0 = ret.Error(0) 231 | } 232 | 233 | return r0 234 | } 235 | 236 | // SeekFromStart provides a mock function with given fields: value 237 | func (_m *App) SeekFromStart(value int) error { 238 | ret := _m.Called(value) 239 | 240 | if len(ret) == 0 { 241 | panic("no return value specified for SeekFromStart") 242 | } 243 | 244 | var r0 error 245 | if rf, ok := ret.Get(0).(func(int) error); ok { 246 | r0 = rf(value) 247 | } else { 248 | r0 = ret.Error(0) 249 | } 250 | 251 | return r0 252 | } 253 | 254 | // SeekToTime provides a mock function with given fields: value 255 | func (_m *App) SeekToTime(value float32) error { 256 | ret := _m.Called(value) 257 | 258 | if len(ret) == 0 { 259 | panic("no return value specified for SeekToTime") 260 | } 261 | 262 | var r0 error 263 | if rf, ok := ret.Get(0).(func(float32) error); ok { 264 | r0 = rf(value) 265 | } else { 266 | r0 = ret.Error(0) 267 | } 268 | 269 | return r0 270 | } 271 | 272 | // SetCacheDisabled provides a mock function with given fields: _a0 273 | func (_m *App) SetCacheDisabled(_a0 bool) { 274 | _m.Called(_a0) 275 | } 276 | 277 | // SetConn provides a mock function with given fields: conn 278 | func (_m *App) SetConn(conn cast.Conn) { 279 | _m.Called(conn) 280 | } 281 | 282 | // SetConnectionRetries provides a mock function with given fields: _a0 283 | func (_m *App) SetConnectionRetries(_a0 int) { 284 | _m.Called(_a0) 285 | } 286 | 287 | // SetDebug provides a mock function with given fields: _a0 288 | func (_m *App) SetDebug(_a0 bool) { 289 | _m.Called(_a0) 290 | } 291 | 292 | // SetIface provides a mock function with given fields: _a0 293 | func (_m *App) SetIface(_a0 *net.Interface) { 294 | _m.Called(_a0) 295 | } 296 | 297 | // SetMuted provides a mock function with given fields: value 298 | func (_m *App) SetMuted(value bool) error { 299 | ret := _m.Called(value) 300 | 301 | if len(ret) == 0 { 302 | panic("no return value specified for SetMuted") 303 | } 304 | 305 | var r0 error 306 | if rf, ok := ret.Get(0).(func(bool) error); ok { 307 | r0 = rf(value) 308 | } else { 309 | r0 = ret.Error(0) 310 | } 311 | 312 | return r0 313 | } 314 | 315 | // SetServerPort provides a mock function with given fields: _a0 316 | func (_m *App) SetServerPort(_a0 int) { 317 | _m.Called(_a0) 318 | } 319 | 320 | // SetVolume provides a mock function with given fields: value 321 | func (_m *App) SetVolume(value float32) error { 322 | ret := _m.Called(value) 323 | 324 | if len(ret) == 0 { 325 | panic("no return value specified for SetVolume") 326 | } 327 | 328 | var r0 error 329 | if rf, ok := ret.Get(0).(func(float32) error); ok { 330 | r0 = rf(value) 331 | } else { 332 | r0 = ret.Error(0) 333 | } 334 | 335 | return r0 336 | } 337 | 338 | // Skipad provides a mock function with given fields: 339 | func (_m *App) Skipad() error { 340 | ret := _m.Called() 341 | 342 | if len(ret) == 0 { 343 | panic("no return value specified for Skipad") 344 | } 345 | 346 | var r0 error 347 | if rf, ok := ret.Get(0).(func() error); ok { 348 | r0 = rf() 349 | } else { 350 | r0 = ret.Error(0) 351 | } 352 | 353 | return r0 354 | } 355 | 356 | // Slideshow provides a mock function with given fields: filenames, duration, repeat 357 | func (_m *App) Slideshow(filenames []string, duration int, repeat bool) error { 358 | ret := _m.Called(filenames, duration, repeat) 359 | 360 | if len(ret) == 0 { 361 | panic("no return value specified for Slideshow") 362 | } 363 | 364 | var r0 error 365 | if rf, ok := ret.Get(0).(func([]string, int, bool) error); ok { 366 | r0 = rf(filenames, duration, repeat) 367 | } else { 368 | r0 = ret.Error(0) 369 | } 370 | 371 | return r0 372 | } 373 | 374 | // Start provides a mock function with given fields: addr, port 375 | func (_m *App) Start(addr string, port int) error { 376 | ret := _m.Called(addr, port) 377 | 378 | if len(ret) == 0 { 379 | panic("no return value specified for Start") 380 | } 381 | 382 | var r0 error 383 | if rf, ok := ret.Get(0).(func(string, int) error); ok { 384 | r0 = rf(addr, port) 385 | } else { 386 | r0 = ret.Error(0) 387 | } 388 | 389 | return r0 390 | } 391 | 392 | // Status provides a mock function with given fields: 393 | func (_m *App) Status() (*cast.Application, *cast.Media, *cast.Volume) { 394 | ret := _m.Called() 395 | 396 | if len(ret) == 0 { 397 | panic("no return value specified for Status") 398 | } 399 | 400 | var r0 *cast.Application 401 | var r1 *cast.Media 402 | var r2 *cast.Volume 403 | if rf, ok := ret.Get(0).(func() (*cast.Application, *cast.Media, *cast.Volume)); ok { 404 | return rf() 405 | } 406 | if rf, ok := ret.Get(0).(func() *cast.Application); ok { 407 | r0 = rf() 408 | } else { 409 | if ret.Get(0) != nil { 410 | r0 = ret.Get(0).(*cast.Application) 411 | } 412 | } 413 | 414 | if rf, ok := ret.Get(1).(func() *cast.Media); ok { 415 | r1 = rf() 416 | } else { 417 | if ret.Get(1) != nil { 418 | r1 = ret.Get(1).(*cast.Media) 419 | } 420 | } 421 | 422 | if rf, ok := ret.Get(2).(func() *cast.Volume); ok { 423 | r2 = rf() 424 | } else { 425 | if ret.Get(2) != nil { 426 | r2 = ret.Get(2).(*cast.Volume) 427 | } 428 | } 429 | 430 | return r0, r1, r2 431 | } 432 | 433 | // Stop provides a mock function with given fields: 434 | func (_m *App) Stop() error { 435 | ret := _m.Called() 436 | 437 | if len(ret) == 0 { 438 | panic("no return value specified for Stop") 439 | } 440 | 441 | var r0 error 442 | if rf, ok := ret.Get(0).(func() error); ok { 443 | r0 = rf() 444 | } else { 445 | r0 = ret.Error(0) 446 | } 447 | 448 | return r0 449 | } 450 | 451 | // StopMedia provides a mock function with given fields: 452 | func (_m *App) StopMedia() error { 453 | ret := _m.Called() 454 | 455 | if len(ret) == 0 { 456 | panic("no return value specified for StopMedia") 457 | } 458 | 459 | var r0 error 460 | if rf, ok := ret.Get(0).(func() error); ok { 461 | r0 = rf() 462 | } else { 463 | r0 = ret.Error(0) 464 | } 465 | 466 | return r0 467 | } 468 | 469 | // TogglePause provides a mock function with given fields: 470 | func (_m *App) TogglePause() error { 471 | ret := _m.Called() 472 | 473 | if len(ret) == 0 { 474 | panic("no return value specified for TogglePause") 475 | } 476 | 477 | var r0 error 478 | if rf, ok := ret.Get(0).(func() error); ok { 479 | r0 = rf() 480 | } else { 481 | r0 = ret.Error(0) 482 | } 483 | 484 | return r0 485 | } 486 | 487 | // Transcode provides a mock function with given fields: contentType, command, args 488 | func (_m *App) Transcode(contentType string, command string, args ...string) error { 489 | _va := make([]interface{}, len(args)) 490 | for _i := range args { 491 | _va[_i] = args[_i] 492 | } 493 | var _ca []interface{} 494 | _ca = append(_ca, contentType, command) 495 | _ca = append(_ca, _va...) 496 | ret := _m.Called(_ca...) 497 | 498 | if len(ret) == 0 { 499 | panic("no return value specified for Transcode") 500 | } 501 | 502 | var r0 error 503 | if rf, ok := ret.Get(0).(func(string, string, ...string) error); ok { 504 | r0 = rf(contentType, command, args...) 505 | } else { 506 | r0 = ret.Error(0) 507 | } 508 | 509 | return r0 510 | } 511 | 512 | // Unpause provides a mock function with given fields: 513 | func (_m *App) Unpause() error { 514 | ret := _m.Called() 515 | 516 | if len(ret) == 0 { 517 | panic("no return value specified for Unpause") 518 | } 519 | 520 | var r0 error 521 | if rf, ok := ret.Get(0).(func() error); ok { 522 | r0 = rf() 523 | } else { 524 | r0 = ret.Error(0) 525 | } 526 | 527 | return r0 528 | } 529 | 530 | // Update provides a mock function with given fields: 531 | func (_m *App) Update() error { 532 | ret := _m.Called() 533 | 534 | if len(ret) == 0 { 535 | panic("no return value specified for Update") 536 | } 537 | 538 | var r0 error 539 | if rf, ok := ret.Get(0).(func() error); ok { 540 | r0 = rf() 541 | } else { 542 | r0 = ret.Error(0) 543 | } 544 | 545 | return r0 546 | } 547 | 548 | // NewApp creates a new instance of App. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 549 | // The first argument is typically a *testing.T value. 550 | func NewApp(t interface { 551 | mock.TestingT 552 | Cleanup(func()) 553 | }) *App { 554 | mock := &App{} 555 | mock.Mock.Test(t) 556 | 557 | t.Cleanup(func() { mock.AssertExpectations(t) }) 558 | 559 | return mock 560 | } 561 | -------------------------------------------------------------------------------- /application/mocks/ApplicationOption.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.49.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | mock "github.com/stretchr/testify/mock" 7 | application "github.com/vishen/go-chromecast/application" 8 | ) 9 | 10 | // ApplicationOption is an autogenerated mock type for the ApplicationOption type 11 | type ApplicationOption struct { 12 | mock.Mock 13 | } 14 | 15 | // Execute provides a mock function with given fields: _a0 16 | func (_m *ApplicationOption) Execute(_a0 *application.Application) { 17 | _m.Called(_a0) 18 | } 19 | 20 | // NewApplicationOption creates a new instance of ApplicationOption. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 21 | // The first argument is typically a *testing.T value. 22 | func NewApplicationOption(t interface { 23 | mock.TestingT 24 | Cleanup(func()) 25 | }) *ApplicationOption { 26 | mock := &ApplicationOption{} 27 | mock.Mock.Test(t) 28 | 29 | t.Cleanup(func() { mock.AssertExpectations(t) }) 30 | 31 | return mock 32 | } 33 | -------------------------------------------------------------------------------- /application/mocks/CastMessageFunc.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.49.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | api "github.com/vishen/go-chromecast/cast/proto" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // CastMessageFunc is an autogenerated mock type for the CastMessageFunc type 12 | type CastMessageFunc struct { 13 | mock.Mock 14 | } 15 | 16 | // Execute provides a mock function with given fields: _a0 17 | func (_m *CastMessageFunc) Execute(_a0 *api.CastMessage) { 18 | _m.Called(_a0) 19 | } 20 | 21 | // NewCastMessageFunc creates a new instance of CastMessageFunc. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 22 | // The first argument is typically a *testing.T value. 23 | func NewCastMessageFunc(t interface { 24 | mock.TestingT 25 | Cleanup(func()) 26 | }) *CastMessageFunc { 27 | mock := &CastMessageFunc{} 28 | mock.Mock.Test(t) 29 | 30 | t.Cleanup(func() { mock.AssertExpectations(t) }) 31 | 32 | return mock 33 | } 34 | -------------------------------------------------------------------------------- /cast/connection.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/binary" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net" 11 | "sync" 12 | "time" 13 | 14 | log "github.com/sirupsen/logrus" 15 | 16 | "github.com/buger/jsonparser" 17 | "github.com/gogo/protobuf/proto" 18 | "github.com/pkg/errors" 19 | 20 | pb "github.com/vishen/go-chromecast/cast/proto" 21 | ) 22 | 23 | const ( 24 | dialerTimeout = time.Second * 3 25 | dialerKeepAlive = time.Second * 30 26 | ) 27 | 28 | type Conn interface { 29 | Start(addr string, port int) error 30 | MsgChan() chan *pb.CastMessage 31 | Close() error 32 | SetDebug(debug bool) 33 | LocalAddr() (addr string, err error) 34 | RemoteAddr() (addr string, err error) 35 | RemotePort() (addr string, err error) 36 | Send(requestID int, payload Payload, sourceID, destinationID, namespace string) error 37 | } 38 | 39 | type Connection struct { 40 | conn *tls.Conn 41 | 42 | recvMsgChan chan *pb.CastMessage 43 | closeChanOnce sync.Once 44 | 45 | debug bool 46 | connected bool 47 | 48 | cancel context.CancelFunc 49 | } 50 | 51 | func NewConnection() *Connection { 52 | c := &Connection{ 53 | recvMsgChan: make(chan *pb.CastMessage, 5), 54 | connected: false, 55 | } 56 | return c 57 | } 58 | 59 | func (c *Connection) MsgChan() chan *pb.CastMessage { return c.recvMsgChan } 60 | 61 | func (c *Connection) Start(addr string, port int) error { 62 | if !c.connected { 63 | err := c.connect(addr, port) 64 | if err != nil { 65 | return err 66 | } 67 | var ctx context.Context 68 | // TODO: Receive context through function params? 69 | ctx, c.cancel = context.WithCancel(context.Background()) 70 | go c.receiveLoop(ctx) 71 | } 72 | return nil 73 | } 74 | 75 | func (c *Connection) Close() error { 76 | // TODO: nothing here is concurrent safe, fix? 77 | c.connected = false 78 | if c.cancel != nil { 79 | c.cancel() 80 | } 81 | defer c.closeChanOnce.Do(func() { 82 | close(c.recvMsgChan) 83 | }) 84 | return c.conn.Close() 85 | } 86 | 87 | func (c *Connection) SetDebug(debug bool) { c.debug = debug } 88 | 89 | func (c *Connection) LocalAddr() (addr string, err error) { 90 | host, _, err := net.SplitHostPort(c.conn.LocalAddr().String()) 91 | return host, err 92 | } 93 | 94 | func (c *Connection) RemoteAddr() (addr string, err error) { 95 | addr, _, err = net.SplitHostPort(c.conn.RemoteAddr().String()) 96 | return addr, err 97 | } 98 | 99 | func (c *Connection) RemotePort() (port string, err error) { 100 | _, port, err = net.SplitHostPort(c.conn.RemoteAddr().String()) 101 | return port, err 102 | } 103 | 104 | func (c *Connection) log(message string, args ...interface{}) { 105 | if c.debug { 106 | log.WithField("package", "cast").Debugf(message, args...) 107 | } 108 | } 109 | 110 | func (c *Connection) connect(addr string, port int) error { 111 | var err error 112 | dialer := &net.Dialer{ 113 | Timeout: dialerTimeout, 114 | KeepAlive: dialerKeepAlive, 115 | } 116 | c.conn, err = tls.DialWithDialer(dialer, "tcp", fmt.Sprintf("%s:%d", addr, port), &tls.Config{ 117 | InsecureSkipVerify: true, 118 | }) 119 | if err != nil { 120 | return errors.Wrapf(err, "unable to connect to chromecast at '%s:%d'", addr, port) 121 | } 122 | c.connected = true 123 | return nil 124 | } 125 | 126 | func (c *Connection) Send(requestID int, payload Payload, sourceID, destinationID, namespace string) error { 127 | 128 | payloadJson, err := json.Marshal(payload) 129 | if err != nil { 130 | return errors.Wrap(err, "unable to marshal json payload") 131 | } 132 | payloadUtf8 := string(payloadJson) 133 | message := &pb.CastMessage{ 134 | ProtocolVersion: pb.CastMessage_CASTV2_1_0.Enum(), 135 | SourceId: &sourceID, 136 | DestinationId: &destinationID, 137 | Namespace: &namespace, 138 | PayloadType: pb.CastMessage_STRING.Enum(), 139 | PayloadUtf8: &payloadUtf8, 140 | } 141 | proto.SetDefaults(message) 142 | data, err := proto.Marshal(message) 143 | if err != nil { 144 | return errors.Wrap(err, "unable to marshal proto payload") 145 | } 146 | 147 | c.log("(%d)%s -> %s [%s]: %s", requestID, sourceID, destinationID, namespace, payloadJson) 148 | 149 | if err := binary.Write(c.conn, binary.BigEndian, uint32(len(data))); err != nil { 150 | return errors.Wrap(err, "unable to write binary format") 151 | } 152 | if _, err := c.conn.Write(data); err != nil { 153 | return errors.Wrap(err, "unable to send data") 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *Connection) receiveLoop(ctx context.Context) { 160 | for { 161 | select { 162 | case <-ctx.Done(): 163 | return 164 | default: 165 | // Fallthrough if not done 166 | } 167 | var length uint32 168 | if c.conn == nil { 169 | continue 170 | } 171 | if err := binary.Read(c.conn, binary.BigEndian, &length); err != nil { 172 | c.log("failed to binary read payload: %v", err) 173 | break 174 | } 175 | if length == 0 { 176 | c.log("empty payload received") 177 | continue 178 | } 179 | 180 | payload := make([]byte, length) 181 | i, err := io.ReadFull(c.conn, payload) 182 | if err != nil { 183 | c.log("failed to read payload: %v", err) 184 | continue 185 | } 186 | 187 | if i != int(length) { 188 | c.log("invalid payload, wanted: %d but read: %d", length, i) 189 | continue 190 | } 191 | 192 | message := &pb.CastMessage{} 193 | if err := proto.Unmarshal(payload, message); err != nil { 194 | c.log("failed to unmarshal proto cast message '%s': %v", payload, err) 195 | continue 196 | } 197 | // Get the requestID from the message to use in the log. We don't really 198 | // care if this fails. 199 | requestID, _ := jsonparser.GetInt([]byte(*message.PayloadUtf8), "requestId") 200 | if requestID == 0 { 201 | requestID = -1 202 | } 203 | // Cast to int, losing information, but unlilely we will 204 | // ever send that many messages in a single run. 205 | requestIDi := int(requestID) 206 | 207 | c.log("(%d)%s <- %s [%s]: %s", requestIDi, *message.DestinationId, *message.SourceId, *message.Namespace, *message.PayloadUtf8) 208 | 209 | var headers PayloadHeader 210 | if err := json.Unmarshal([]byte(*message.PayloadUtf8), &headers); err != nil { 211 | c.log("failed to unmarshal proto message header: %v", err) 212 | continue 213 | } 214 | 215 | c.handleMessage(requestIDi, message, &headers) 216 | } 217 | } 218 | 219 | func (c *Connection) handleMessage(requestID int, message *pb.CastMessage, headers *PayloadHeader) { 220 | 221 | messageType, err := jsonparser.GetString([]byte(*message.PayloadUtf8), "type") 222 | if err != nil { 223 | c.log("could not find 'type' key in response message request_id=%d %q: %s", requestID, *message.PayloadUtf8, err) 224 | return 225 | } 226 | 227 | switch messageType { 228 | case "PING": 229 | if err := c.Send(-1, &PongHeader, *message.SourceId, *message.DestinationId, *message.Namespace); err != nil { 230 | c.log("unable to respond to 'PING': %v", err) 231 | } 232 | default: 233 | c.recvMsgChan <- message 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /cast/mocks/Conn.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.49.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | cast "github.com/vishen/go-chromecast/cast" 7 | api "github.com/vishen/go-chromecast/cast/proto" 8 | 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // Conn is an autogenerated mock type for the Conn type 13 | type Conn struct { 14 | mock.Mock 15 | } 16 | 17 | // Close provides a mock function with given fields: 18 | func (_m *Conn) Close() error { 19 | ret := _m.Called() 20 | 21 | if len(ret) == 0 { 22 | panic("no return value specified for Close") 23 | } 24 | 25 | var r0 error 26 | if rf, ok := ret.Get(0).(func() error); ok { 27 | r0 = rf() 28 | } else { 29 | r0 = ret.Error(0) 30 | } 31 | 32 | return r0 33 | } 34 | 35 | // LocalAddr provides a mock function with given fields: 36 | func (_m *Conn) LocalAddr() (string, error) { 37 | ret := _m.Called() 38 | 39 | if len(ret) == 0 { 40 | panic("no return value specified for LocalAddr") 41 | } 42 | 43 | var r0 string 44 | var r1 error 45 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 46 | return rf() 47 | } 48 | if rf, ok := ret.Get(0).(func() string); ok { 49 | r0 = rf() 50 | } else { 51 | r0 = ret.Get(0).(string) 52 | } 53 | 54 | if rf, ok := ret.Get(1).(func() error); ok { 55 | r1 = rf() 56 | } else { 57 | r1 = ret.Error(1) 58 | } 59 | 60 | return r0, r1 61 | } 62 | 63 | // MsgChan provides a mock function with given fields: 64 | func (_m *Conn) MsgChan() chan *api.CastMessage { 65 | ret := _m.Called() 66 | 67 | if len(ret) == 0 { 68 | panic("no return value specified for MsgChan") 69 | } 70 | 71 | var r0 chan *api.CastMessage 72 | if rf, ok := ret.Get(0).(func() chan *api.CastMessage); ok { 73 | r0 = rf() 74 | } else { 75 | if ret.Get(0) != nil { 76 | r0 = ret.Get(0).(chan *api.CastMessage) 77 | } 78 | } 79 | 80 | return r0 81 | } 82 | 83 | // RemoteAddr provides a mock function with given fields: 84 | func (_m *Conn) RemoteAddr() (string, error) { 85 | ret := _m.Called() 86 | 87 | if len(ret) == 0 { 88 | panic("no return value specified for RemoteAddr") 89 | } 90 | 91 | var r0 string 92 | var r1 error 93 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 94 | return rf() 95 | } 96 | if rf, ok := ret.Get(0).(func() string); ok { 97 | r0 = rf() 98 | } else { 99 | r0 = ret.Get(0).(string) 100 | } 101 | 102 | if rf, ok := ret.Get(1).(func() error); ok { 103 | r1 = rf() 104 | } else { 105 | r1 = ret.Error(1) 106 | } 107 | 108 | return r0, r1 109 | } 110 | 111 | // RemotePort provides a mock function with given fields: 112 | func (_m *Conn) RemotePort() (string, error) { 113 | ret := _m.Called() 114 | 115 | if len(ret) == 0 { 116 | panic("no return value specified for RemotePort") 117 | } 118 | 119 | var r0 string 120 | var r1 error 121 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 122 | return rf() 123 | } 124 | if rf, ok := ret.Get(0).(func() string); ok { 125 | r0 = rf() 126 | } else { 127 | r0 = ret.Get(0).(string) 128 | } 129 | 130 | if rf, ok := ret.Get(1).(func() error); ok { 131 | r1 = rf() 132 | } else { 133 | r1 = ret.Error(1) 134 | } 135 | 136 | return r0, r1 137 | } 138 | 139 | // Send provides a mock function with given fields: requestID, payload, sourceID, destinationID, namespace 140 | func (_m *Conn) Send(requestID int, payload cast.Payload, sourceID string, destinationID string, namespace string) error { 141 | ret := _m.Called(requestID, payload, sourceID, destinationID, namespace) 142 | 143 | if len(ret) == 0 { 144 | panic("no return value specified for Send") 145 | } 146 | 147 | var r0 error 148 | if rf, ok := ret.Get(0).(func(int, cast.Payload, string, string, string) error); ok { 149 | r0 = rf(requestID, payload, sourceID, destinationID, namespace) 150 | } else { 151 | r0 = ret.Error(0) 152 | } 153 | 154 | return r0 155 | } 156 | 157 | // SetDebug provides a mock function with given fields: debug 158 | func (_m *Conn) SetDebug(debug bool) { 159 | _m.Called(debug) 160 | } 161 | 162 | // Start provides a mock function with given fields: addr, port 163 | func (_m *Conn) Start(addr string, port int) error { 164 | ret := _m.Called(addr, port) 165 | 166 | if len(ret) == 0 { 167 | panic("no return value specified for Start") 168 | } 169 | 170 | var r0 error 171 | if rf, ok := ret.Get(0).(func(string, int) error); ok { 172 | r0 = rf(addr, port) 173 | } else { 174 | r0 = ret.Error(0) 175 | } 176 | 177 | return r0 178 | } 179 | 180 | // NewConn creates a new instance of Conn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 181 | // The first argument is typically a *testing.T value. 182 | func NewConn(t interface { 183 | mock.TestingT 184 | Cleanup(func()) 185 | }) *Conn { 186 | mock := &Conn{} 187 | mock.Mock.Test(t) 188 | 189 | t.Cleanup(func() { mock.AssertExpectations(t) }) 190 | 191 | return mock 192 | } 193 | -------------------------------------------------------------------------------- /cast/mocks/Payload.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.49.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Payload is an autogenerated mock type for the Payload type 8 | type Payload struct { 9 | mock.Mock 10 | } 11 | 12 | // SetRequestId provides a mock function with given fields: id 13 | func (_m *Payload) SetRequestId(id int) { 14 | _m.Called(id) 15 | } 16 | 17 | // NewPayload creates a new instance of Payload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 18 | // The first argument is typically a *testing.T value. 19 | func NewPayload(t interface { 20 | mock.TestingT 21 | Cleanup(func()) 22 | }) *Payload { 23 | mock := &Payload{} 24 | mock.Mock.Test(t) 25 | 26 | t.Cleanup(func() { mock.AssertExpectations(t) }) 27 | 28 | return mock 29 | } 30 | -------------------------------------------------------------------------------- /cast/payload.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | var ( 4 | // Known Payload headers 5 | ConnectHeader = PayloadHeader{Type: "CONNECT"} 6 | CloseHeader = PayloadHeader{Type: "CLOSE"} 7 | GetStatusHeader = PayloadHeader{Type: "GET_STATUS"} 8 | PongHeader = PayloadHeader{Type: "PONG"} // Response to PING payload 9 | LaunchHeader = PayloadHeader{Type: "LAUNCH"} // Launches a new chromecast app 10 | StopHeader = PayloadHeader{Type: "STOP"} // Stop playing current media 11 | PlayHeader = PayloadHeader{Type: "PLAY"} // Plays / unpauses the running app 12 | PauseHeader = PayloadHeader{Type: "PAUSE"} // Pauses the running app 13 | SeekHeader = PayloadHeader{Type: "SEEK"} // Seek into the running app 14 | VolumeHeader = PayloadHeader{Type: "SET_VOLUME"} // Sets the volume 15 | LoadHeader = PayloadHeader{Type: "LOAD"} // Loads an application onto the chromecast 16 | QueueLoadHeader = PayloadHeader{Type: "QUEUE_LOAD"} // Loads an application onto the chromecast 17 | QueueUpdateHeader = PayloadHeader{Type: "QUEUE_UPDATE"} // Loads an application onto the chromecast 18 | SkipHeader = PayloadHeader{Type: "SKIP_AD"} // Skip add based off https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages#.SKIP_AD 19 | ) 20 | 21 | type Payload interface { 22 | SetRequestId(id int) 23 | } 24 | 25 | type PayloadHeader struct { 26 | Type string `json:"type"` 27 | RequestId int `json:"requestId,omitempty"` 28 | } 29 | 30 | func (p *PayloadHeader) SetRequestId(id int) { 31 | p.RequestId = id 32 | } 33 | 34 | type QueueUpdate struct { 35 | PayloadHeader 36 | MediaSessionId int `json:"mediaSessionId,omitempty"` 37 | Jump int `json:"jump,omitempty"` 38 | } 39 | 40 | type QueueLoad struct { 41 | PayloadHeader 42 | MediaSessionId int `json:"mediaSessionId,omitempty"` 43 | CurrentTime float32 `json:"currentTime"` 44 | StartIndex int `json:"startIndex"` 45 | RepeatMode string `json:"repeatMode"` 46 | Items []QueueLoadItem `json:"items"` 47 | } 48 | 49 | type QueueLoadItem struct { 50 | Media MediaItem `json:"media"` 51 | Autoplay bool `json:"autoplay"` 52 | PlaybackDuration int `json:"playbackDuration"` 53 | } 54 | 55 | type MediaHeader struct { 56 | PayloadHeader 57 | MediaSessionId int `json:"mediaSessionId"` 58 | CurrentTime float32 `json:"currentTime"` 59 | RelativeTime float32 `json:"relativeTime,omitempty"` 60 | ResumeState string `json:"resumeState"` 61 | } 62 | 63 | type Volume struct { 64 | Level float32 `json:"level,omitempty"` 65 | Muted bool `json:"muted"` 66 | } 67 | 68 | type ReceiverStatusResponse struct { 69 | PayloadHeader 70 | Status struct { 71 | Applications []Application `json:"applications"` 72 | Volume Volume `json:"volume"` 73 | } `json:"status"` 74 | } 75 | 76 | type Application struct { 77 | AppId string `json:"appId"` 78 | DisplayName string `json:"displayName"` 79 | IsIdleScreen bool `json:"isIdleScreen"` 80 | SessionId string `json:"sessionId"` 81 | StatusText string `json:"statusText"` 82 | TransportId string `json:"transportId"` 83 | } 84 | 85 | type ReceiverStatusRequest struct { 86 | PayloadHeader 87 | Applications []Application `json:"applications"` 88 | 89 | Volume Volume `json:"volume"` 90 | } 91 | 92 | type LaunchRequest struct { 93 | PayloadHeader 94 | AppId string `json:"appId"` 95 | } 96 | 97 | type LoadMediaCommand struct { 98 | PayloadHeader 99 | Media MediaItem `json:"media"` 100 | CurrentTime int `json:"currentTime"` 101 | Autoplay bool `json:"autoplay"` 102 | QueueData QueueData `json:"queueData"` 103 | CustomData interface{} `json:"customData"` 104 | } 105 | 106 | type QueueData struct { 107 | StartIndex int `json:"startIndex"` 108 | } 109 | 110 | type MediaItem struct { 111 | ContentId string `json:"contentId"` 112 | ContentType string `json:"contentType"` 113 | StreamType string `json:"streamType"` 114 | Duration float32 `json:"duration"` 115 | Metadata MediaMetadata `json:"metadata"` 116 | } 117 | 118 | type MediaMetadata struct { 119 | MetadataType int `json:"metadataType"` 120 | Artist string `json:"artist"` 121 | Title string `json:"title"` 122 | Subtitle string `json:"subtitle"` 123 | Images []Image `json:"images"` 124 | ReleaseDate string `json:"releaseDate"` 125 | } 126 | 127 | type Image struct { 128 | URL string `json:"url"` 129 | Height int `json:"height"` 130 | Width int `json:"width"` 131 | } 132 | 133 | type Media struct { 134 | MediaSessionId int `json:"mediaSessionId"` 135 | PlayerState string `json:"playerState"` 136 | CurrentTime float32 `json:"currentTime"` 137 | IdleReason string `json:"idleReason"` 138 | Volume Volume `json:"volume"` 139 | CurrentItemId int `json:"currentItemId"` 140 | LoadingItemId int `json:"loadingItemId"` 141 | CustomData CustomData `json:"customData"` 142 | 143 | Media MediaItem `json:"media"` 144 | } 145 | 146 | type CustomData struct { 147 | PlayerState int `json:"playerState"` 148 | } 149 | 150 | type MediaStatusResponse struct { 151 | PayloadHeader 152 | Status []Media `json:"status"` 153 | } 154 | 155 | type SetVolume struct { 156 | PayloadHeader 157 | Volume Volume `json:"volume"` 158 | } 159 | 160 | type DeviceInfo struct { 161 | Name string `json:"name"` 162 | IpAddress string `json:"ip_address"` 163 | Locale string `json:"locale"` 164 | MacAddress string `json:"mac_address"` 165 | Ssid string `json:"ssid"` 166 | Timezone string `json:"timezone"` 167 | UptimeSec float64 `json:"uptime"` 168 | SsdpUdn string `json:"ssdp_udn"` 169 | } 170 | -------------------------------------------------------------------------------- /cast/proto/cast_channel.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-gogo. 2 | // source: api/cast_channel.proto 3 | // DO NOT EDIT! 4 | 5 | /* 6 | Package api is a generated protocol buffer package. 7 | 8 | It is generated from these files: 9 | api/cast_channel.proto 10 | 11 | It has these top-level messages: 12 | CastMessage 13 | AuthChallenge 14 | AuthResponse 15 | AuthError 16 | DeviceAuthMessage 17 | */ 18 | package api 19 | 20 | import proto "github.com/gogo/protobuf/proto" 21 | import json "encoding/json" 22 | import math "math" 23 | 24 | // Reference proto, json, and math imports to suppress error if they are not otherwise used. 25 | var _ = proto.Marshal 26 | var _ = &json.SyntaxError{} 27 | var _ = math.Inf 28 | 29 | // Always pass a version of the protocol for future compatibility 30 | // requirements. 31 | type CastMessage_ProtocolVersion int32 32 | 33 | const ( 34 | CastMessage_CASTV2_1_0 CastMessage_ProtocolVersion = 0 35 | ) 36 | 37 | var CastMessage_ProtocolVersion_name = map[int32]string{ 38 | 0: "CASTV2_1_0", 39 | } 40 | var CastMessage_ProtocolVersion_value = map[string]int32{ 41 | "CASTV2_1_0": 0, 42 | } 43 | 44 | func (x CastMessage_ProtocolVersion) Enum() *CastMessage_ProtocolVersion { 45 | p := new(CastMessage_ProtocolVersion) 46 | *p = x 47 | return p 48 | } 49 | func (x CastMessage_ProtocolVersion) String() string { 50 | return proto.EnumName(CastMessage_ProtocolVersion_name, int32(x)) 51 | } 52 | func (x *CastMessage_ProtocolVersion) UnmarshalJSON(data []byte) error { 53 | value, err := proto.UnmarshalJSONEnum(CastMessage_ProtocolVersion_value, data, "CastMessage_ProtocolVersion") 54 | if err != nil { 55 | return err 56 | } 57 | *x = CastMessage_ProtocolVersion(value) 58 | return nil 59 | } 60 | 61 | // What type of data do we have in this message. 62 | type CastMessage_PayloadType int32 63 | 64 | const ( 65 | CastMessage_STRING CastMessage_PayloadType = 0 66 | CastMessage_BINARY CastMessage_PayloadType = 1 67 | ) 68 | 69 | var CastMessage_PayloadType_name = map[int32]string{ 70 | 0: "STRING", 71 | 1: "BINARY", 72 | } 73 | var CastMessage_PayloadType_value = map[string]int32{ 74 | "STRING": 0, 75 | "BINARY": 1, 76 | } 77 | 78 | func (x CastMessage_PayloadType) Enum() *CastMessage_PayloadType { 79 | p := new(CastMessage_PayloadType) 80 | *p = x 81 | return p 82 | } 83 | func (x CastMessage_PayloadType) String() string { 84 | return proto.EnumName(CastMessage_PayloadType_name, int32(x)) 85 | } 86 | func (x *CastMessage_PayloadType) UnmarshalJSON(data []byte) error { 87 | value, err := proto.UnmarshalJSONEnum(CastMessage_PayloadType_value, data, "CastMessage_PayloadType") 88 | if err != nil { 89 | return err 90 | } 91 | *x = CastMessage_PayloadType(value) 92 | return nil 93 | } 94 | 95 | type AuthError_ErrorType int32 96 | 97 | const ( 98 | AuthError_INTERNAL_ERROR AuthError_ErrorType = 0 99 | AuthError_NO_TLS AuthError_ErrorType = 1 100 | ) 101 | 102 | var AuthError_ErrorType_name = map[int32]string{ 103 | 0: "INTERNAL_ERROR", 104 | 1: "NO_TLS", 105 | } 106 | var AuthError_ErrorType_value = map[string]int32{ 107 | "INTERNAL_ERROR": 0, 108 | "NO_TLS": 1, 109 | } 110 | 111 | func (x AuthError_ErrorType) Enum() *AuthError_ErrorType { 112 | p := new(AuthError_ErrorType) 113 | *p = x 114 | return p 115 | } 116 | func (x AuthError_ErrorType) String() string { 117 | return proto.EnumName(AuthError_ErrorType_name, int32(x)) 118 | } 119 | func (x *AuthError_ErrorType) UnmarshalJSON(data []byte) error { 120 | value, err := proto.UnmarshalJSONEnum(AuthError_ErrorType_value, data, "AuthError_ErrorType") 121 | if err != nil { 122 | return err 123 | } 124 | *x = AuthError_ErrorType(value) 125 | return nil 126 | } 127 | 128 | type CastMessage struct { 129 | ProtocolVersion *CastMessage_ProtocolVersion `protobuf:"varint,1,req,name=protocol_version,enum=api.CastMessage_ProtocolVersion" json:"protocol_version,omitempty"` 130 | // source and destination ids identify the origin and destination of the 131 | // message. They are used to route messages between endpoints that share a 132 | // device-to-device channel. 133 | // 134 | // For messages between applications: 135 | // - The sender application id is a unique identifier generated on behalf of 136 | // the sender application. 137 | // - The receiver id is always the the session id for the application. 138 | // 139 | // For messages to or from the sender or receiver platform, the special ids 140 | // 'sender-0' and 'receiver-0' can be used. 141 | // 142 | // For messages intended for all endpoints using a given channel, the 143 | // wildcard destination_id '*' can be used. 144 | SourceId *string `protobuf:"bytes,2,req,name=source_id" json:"source_id,omitempty"` 145 | DestinationId *string `protobuf:"bytes,3,req,name=destination_id" json:"destination_id,omitempty"` 146 | // This is the core multiplexing key. All messages are sent on a namespace 147 | // and endpoints sharing a channel listen on one or more namespaces. The 148 | // namespace defines the protocol and semantics of the message. 149 | Namespace *string `protobuf:"bytes,4,req,name=namespace" json:"namespace,omitempty"` 150 | PayloadType *CastMessage_PayloadType `protobuf:"varint,5,req,name=payload_type,enum=api.CastMessage_PayloadType" json:"payload_type,omitempty"` 151 | // Depending on payload_type, exactly one of the following optional fields 152 | // will always be set. 153 | PayloadUtf8 *string `protobuf:"bytes,6,opt,name=payload_utf8" json:"payload_utf8,omitempty"` 154 | PayloadBinary []byte `protobuf:"bytes,7,opt,name=payload_binary" json:"payload_binary,omitempty"` 155 | XXX_unrecognized []byte `json:"-"` 156 | } 157 | 158 | func (m *CastMessage) Reset() { *m = CastMessage{} } 159 | func (m *CastMessage) String() string { return proto.CompactTextString(m) } 160 | func (*CastMessage) ProtoMessage() {} 161 | 162 | func (m *CastMessage) GetProtocolVersion() CastMessage_ProtocolVersion { 163 | if m != nil && m.ProtocolVersion != nil { 164 | return *m.ProtocolVersion 165 | } 166 | return CastMessage_CASTV2_1_0 167 | } 168 | 169 | func (m *CastMessage) GetSourceId() string { 170 | if m != nil && m.SourceId != nil { 171 | return *m.SourceId 172 | } 173 | return "" 174 | } 175 | 176 | func (m *CastMessage) GetDestinationId() string { 177 | if m != nil && m.DestinationId != nil { 178 | return *m.DestinationId 179 | } 180 | return "" 181 | } 182 | 183 | func (m *CastMessage) GetNamespace() string { 184 | if m != nil && m.Namespace != nil { 185 | return *m.Namespace 186 | } 187 | return "" 188 | } 189 | 190 | func (m *CastMessage) GetPayloadType() CastMessage_PayloadType { 191 | if m != nil && m.PayloadType != nil { 192 | return *m.PayloadType 193 | } 194 | return CastMessage_STRING 195 | } 196 | 197 | func (m *CastMessage) GetPayloadUtf8() string { 198 | if m != nil && m.PayloadUtf8 != nil { 199 | return *m.PayloadUtf8 200 | } 201 | return "" 202 | } 203 | 204 | func (m *CastMessage) GetPayloadBinary() []byte { 205 | if m != nil { 206 | return m.PayloadBinary 207 | } 208 | return nil 209 | } 210 | 211 | // Messages for authentication protocol between a sender and a receiver. 212 | type AuthChallenge struct { 213 | XXX_unrecognized []byte `json:"-"` 214 | } 215 | 216 | func (m *AuthChallenge) Reset() { *m = AuthChallenge{} } 217 | func (m *AuthChallenge) String() string { return proto.CompactTextString(m) } 218 | func (*AuthChallenge) ProtoMessage() {} 219 | 220 | type AuthResponse struct { 221 | Signature []byte `protobuf:"bytes,1,req,name=signature" json:"signature,omitempty"` 222 | ClientAuthCertificate []byte `protobuf:"bytes,2,req,name=client_auth_certificate" json:"client_auth_certificate,omitempty"` 223 | XXX_unrecognized []byte `json:"-"` 224 | } 225 | 226 | func (m *AuthResponse) Reset() { *m = AuthResponse{} } 227 | func (m *AuthResponse) String() string { return proto.CompactTextString(m) } 228 | func (*AuthResponse) ProtoMessage() {} 229 | 230 | func (m *AuthResponse) GetSignature() []byte { 231 | if m != nil { 232 | return m.Signature 233 | } 234 | return nil 235 | } 236 | 237 | func (m *AuthResponse) GetClientAuthCertificate() []byte { 238 | if m != nil { 239 | return m.ClientAuthCertificate 240 | } 241 | return nil 242 | } 243 | 244 | type AuthError struct { 245 | ErrorType *AuthError_ErrorType `protobuf:"varint,1,req,name=error_type,enum=api.AuthError_ErrorType" json:"error_type,omitempty"` 246 | XXX_unrecognized []byte `json:"-"` 247 | } 248 | 249 | func (m *AuthError) Reset() { *m = AuthError{} } 250 | func (m *AuthError) String() string { return proto.CompactTextString(m) } 251 | func (*AuthError) ProtoMessage() {} 252 | 253 | func (m *AuthError) GetErrorType() AuthError_ErrorType { 254 | if m != nil && m.ErrorType != nil { 255 | return *m.ErrorType 256 | } 257 | return AuthError_INTERNAL_ERROR 258 | } 259 | 260 | type DeviceAuthMessage struct { 261 | // Request fields 262 | Challenge *AuthChallenge `protobuf:"bytes,1,opt,name=challenge" json:"challenge,omitempty"` 263 | // Response fields 264 | Response *AuthResponse `protobuf:"bytes,2,opt,name=response" json:"response,omitempty"` 265 | Error *AuthError `protobuf:"bytes,3,opt,name=error" json:"error,omitempty"` 266 | XXX_unrecognized []byte `json:"-"` 267 | } 268 | 269 | func (m *DeviceAuthMessage) Reset() { *m = DeviceAuthMessage{} } 270 | func (m *DeviceAuthMessage) String() string { return proto.CompactTextString(m) } 271 | func (*DeviceAuthMessage) ProtoMessage() {} 272 | 273 | func (m *DeviceAuthMessage) GetChallenge() *AuthChallenge { 274 | if m != nil { 275 | return m.Challenge 276 | } 277 | return nil 278 | } 279 | 280 | func (m *DeviceAuthMessage) GetResponse() *AuthResponse { 281 | if m != nil { 282 | return m.Response 283 | } 284 | return nil 285 | } 286 | 287 | func (m *DeviceAuthMessage) GetError() *AuthError { 288 | if m != nil { 289 | return m.Error 290 | } 291 | return nil 292 | } 293 | 294 | func init() { 295 | proto.RegisterEnum("api.CastMessage_ProtocolVersion", CastMessage_ProtocolVersion_name, CastMessage_ProtocolVersion_value) 296 | proto.RegisterEnum("api.CastMessage_PayloadType", CastMessage_PayloadType_name, CastMessage_PayloadType_value) 297 | proto.RegisterEnum("api.AuthError_ErrorType", AuthError_ErrorType_name, AuthError_ErrorType_value) 298 | } 299 | -------------------------------------------------------------------------------- /cast/proto/cast_channel.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Chromium Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | syntax = "proto2"; 6 | 7 | option optimize_for = LITE_RUNTIME; 8 | 9 | package api;// EDITED: Was extensions.api.cast_channel. ; 10 | 11 | message CastMessage { 12 | // Always pass a version of the protocol for future compatibility 13 | // requirements. 14 | enum ProtocolVersion { 15 | CASTV2_1_0 = 0; 16 | } 17 | required ProtocolVersion protocol_version = 1; 18 | 19 | // source and destination ids identify the origin and destination of the 20 | // message. They are used to route messages between endpoints that share a 21 | // device-to-device channel. 22 | // 23 | // For messages between applications: 24 | // - The sender application id is a unique identifier generated on behalf of 25 | // the sender application. 26 | // - The receiver id is always the the session id for the application. 27 | // 28 | // For messages to or from the sender or receiver platform, the special ids 29 | // 'sender-0' and 'receiver-0' can be used. 30 | // 31 | // For messages intended for all endpoints using a given channel, the 32 | // wildcard destination_id '*' can be used. 33 | required string source_id = 2; 34 | required string destination_id = 3; 35 | 36 | // This is the core multiplexing key. All messages are sent on a namespace 37 | // and endpoints sharing a channel listen on one or more namespaces. The 38 | // namespace defines the protocol and semantics of the message. 39 | required string namespace = 4; 40 | 41 | // Encoding and payload info follows. 42 | 43 | // What type of data do we have in this message. 44 | enum PayloadType { 45 | STRING = 0; 46 | BINARY = 1; 47 | } 48 | required PayloadType payload_type = 5; 49 | 50 | // Depending on payload_type, exactly one of the following optional fields 51 | // will always be set. 52 | optional string payload_utf8 = 6; 53 | optional bytes payload_binary = 7; 54 | } 55 | 56 | // Messages for authentication protocol between a sender and a receiver. 57 | message AuthChallenge { 58 | } 59 | 60 | message AuthResponse { 61 | required bytes signature = 1; 62 | required bytes client_auth_certificate = 2; 63 | } 64 | 65 | message AuthError { 66 | enum ErrorType { 67 | INTERNAL_ERROR = 0; 68 | NO_TLS = 1; // The underlying connection is not TLS 69 | } 70 | required ErrorType error_type = 1; 71 | } 72 | 73 | message DeviceAuthMessage { 74 | // Request fields 75 | optional AuthChallenge challenge = 1; 76 | // Response fields 77 | optional AuthResponse response = 2; 78 | optional AuthError error = 3; 79 | } 80 | -------------------------------------------------------------------------------- /cmd/httpserver.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | "github.com/vishen/go-chromecast/http" 20 | ) 21 | 22 | // httpserverCmd represents the httpserver command 23 | var httpserverCmd = &cobra.Command{ 24 | Use: "httpserver", 25 | Short: "Start the HTTP server", 26 | Long: `Start the HTTP server which provides an HTTP 27 | api to control chromecast devices on a network.`, 28 | Run: func(cmd *cobra.Command, args []string) { 29 | 30 | addr, _ := cmd.Flags().GetString("http-addr") 31 | port, _ := cmd.Flags().GetString("http-port") 32 | 33 | // TODO: Should only need verbose, but debug has stupidly hijacked 34 | // the -v flag... 35 | verbose, _ := cmd.Flags().GetBool("verbose") 36 | debug, _ := cmd.Flags().GetBool("debug") 37 | 38 | if err := http.NewHandler(verbose || debug).Serve(addr + ":" + port); err != nil { 39 | exit("unable to run http server: %v", err) 40 | } 41 | }, 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(httpserverCmd) 46 | httpserverCmd.Flags().String("http-port", "8011", "port for the http server to listen on") 47 | httpserverCmd.Flags().String("http-addr", "0.0.0.0", "addr for the http server to listen on") 48 | } 49 | -------------------------------------------------------------------------------- /cmd/load-app.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2021 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/vishen/go-chromecast/ui" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // loadAppCmd represents the load command 24 | var loadAppCmd = &cobra.Command{ 25 | Use: "load-app ", 26 | Short: "Load and play content on a chromecast app", 27 | Long: `Load and play content on a chromecast app. This requires 28 | the chromecast receiver app to be specified. An older list can be found 29 | here https://gist.github.com/jloutsenhizer/8855258. 30 | `, 31 | Run: func(cmd *cobra.Command, args []string) { 32 | if len(args) != 2 { 33 | exit("requires exactly two arguments") 34 | } 35 | app, err := castApplication(cmd, args) 36 | if err != nil { 37 | exit("unable to get cast application: %v", err) 38 | } 39 | 40 | // Optionally run a UI when playing this media: 41 | runWithUI, _ := cmd.Flags().GetBool("with-ui") 42 | if runWithUI { 43 | go func() { 44 | if err := app.LoadApp(args[0], args[1]); err != nil { 45 | exit("unable to load media: %v", err) 46 | } 47 | }() 48 | 49 | ccui, err := ui.NewUserInterface(app) 50 | if err != nil { 51 | exit("unable to prepare a new user-interface: %v", err) 52 | } 53 | if err := ccui.Run(); err != nil { 54 | exit("unable to run ui: %v", err) 55 | } 56 | } 57 | 58 | // Otherwise just run in CLI mode: 59 | if err := app.LoadApp(args[0], args[1]); err != nil { 60 | exit("unable to load media: %v", err) 61 | } 62 | }, 63 | } 64 | 65 | func init() { 66 | rootCmd.AddCommand(loadAppCmd) 67 | } 68 | -------------------------------------------------------------------------------- /cmd/load.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/vishen/go-chromecast/ui" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // loadCmd represents the load command 24 | var loadCmd = &cobra.Command{ 25 | Use: "load ", 26 | Short: "Load and play media on the chromecast", 27 | Long: `Load and play media files on the chromecast, this will 28 | start a HTTP server locally and will stream the media file to the 29 | chromecast if it is a local file, otherwise it will load the url. 30 | 31 | If the media file is an unplayable media type by the chromecast, this 32 | will attempt to transcode the media file to mp4 using ffmpeg. This requires 33 | that ffmpeg is installed.`, 34 | Run: func(cmd *cobra.Command, args []string) { 35 | if len(args) != 1 { 36 | exit("requires exactly one argument, should be the media file to load") 37 | } 38 | app, err := castApplication(cmd, args) 39 | if err != nil { 40 | exit("unable to get cast application: %v", err) 41 | } 42 | 43 | contentType, _ := cmd.Flags().GetString("content-type") 44 | transcode, _ := cmd.Flags().GetBool("transcode") 45 | detach, _ := cmd.Flags().GetBool("detach") 46 | startTime, _ := cmd.Flags().GetInt("start-time") 47 | 48 | // Optionally run a UI when playing this media: 49 | runWithUI, _ := cmd.Flags().GetBool("with-ui") 50 | if runWithUI { 51 | go func() { 52 | if err := app.Load(args[0], startTime, contentType, transcode, detach, false); err != nil { 53 | exit("unable to load media: %v", err) 54 | } 55 | }() 56 | 57 | ccui, err := ui.NewUserInterface(app) 58 | if err != nil { 59 | exit("unable to prepare a new user-interface: %v", err) 60 | } 61 | if err := ccui.Run(); err != nil { 62 | exit("unable to run ui: %v", err) 63 | } 64 | return 65 | } 66 | 67 | // Otherwise just run in CLI mode: 68 | if err := app.Load(args[0], startTime, contentType, transcode, detach, false); err != nil { 69 | exit("unable to load media: %v", err) 70 | } 71 | }, 72 | } 73 | 74 | func init() { 75 | rootCmd.AddCommand(loadCmd) 76 | loadCmd.Flags().Bool("transcode", true, "transcode the media to mp4 if media type is unrecognised") 77 | loadCmd.Flags().Bool("detach", false, "detach from waiting until media finished. Only works with url loaded external media") 78 | loadCmd.Flags().StringP("content-type", "c", "", "content-type to serve the media file as") 79 | loadCmd.Flags().Int("start-time", 0, "start time to play media, in seconds") 80 | } 81 | -------------------------------------------------------------------------------- /cmd/ls.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "context" 19 | "net" 20 | "time" 21 | 22 | "github.com/spf13/cobra" 23 | castdns "github.com/vishen/go-chromecast/dns" 24 | ) 25 | 26 | // lsCmd represents the ls command 27 | var lsCmd = &cobra.Command{ 28 | Use: "ls", 29 | Short: "List devices", 30 | Run: func(cmd *cobra.Command, args []string) { 31 | ifaceName, _ := cmd.Flags().GetString("iface") 32 | dnsTimeoutSeconds, _ := cmd.Flags().GetInt("dns-timeout") 33 | var iface *net.Interface 34 | var err error 35 | if ifaceName != "" { 36 | if iface, err = net.InterfaceByName(ifaceName); err != nil { 37 | exit("unable to find interface %q: %v", ifaceName, err) 38 | } 39 | } 40 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(dnsTimeoutSeconds)) 41 | defer cancel() 42 | castEntryChan, err := castdns.DiscoverCastDNSEntries(ctx, iface) 43 | if err != nil { 44 | exit("unable to discover chromecast devices: %v", err) 45 | } 46 | i := 1 47 | for d := range castEntryChan { 48 | outputInfo("%d) device=%q device_name=%q address=\"%s:%d\" uuid=%q", i, d.Device, d.DeviceName, d.AddrV4, d.Port, d.UUID) 49 | i++ 50 | } 51 | if i == 1 { 52 | outputError("no cast devices found on network") 53 | } 54 | }, 55 | } 56 | 57 | func init() { 58 | rootCmd.AddCommand(lsCmd) 59 | } 60 | -------------------------------------------------------------------------------- /cmd/mute.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // muteCmd represents the mute command 22 | var muteCmd = &cobra.Command{ 23 | Use: "mute", 24 | Short: "Mute the chromecast", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.SetMuted(true); err != nil { 31 | exit("unable to mute cast application: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(muteCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/next.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // nextCmd represents the next command 22 | var nextCmd = &cobra.Command{ 23 | Use: "next", 24 | Short: "Play the next available media", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.Next(); err != nil { 31 | exit("unable to play next media: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(nextCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/pause.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // pauseCmd represents the pause command 22 | var pauseCmd = &cobra.Command{ 23 | Use: "pause", 24 | Short: "Pause the currently playing media on the chromecast", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.Pause(); err != nil { 31 | exit("unable to pause cast application: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(pauseCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/playlist.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "bufio" 19 | "io/ioutil" 20 | "os" 21 | "path/filepath" 22 | "sort" 23 | "strconv" 24 | "strings" 25 | "time" 26 | 27 | "github.com/spf13/cobra" 28 | "github.com/vishen/go-chromecast/ui" 29 | ) 30 | 31 | type mediaFile struct { 32 | filename string 33 | possibleNumbers []int 34 | } 35 | 36 | // playlistCmd represents the playlist command 37 | var playlistCmd = &cobra.Command{ 38 | Use: "playlist ", 39 | Short: "Load and play media on the chromecast", 40 | Long: `Load and play media files on the chromecast, this will 41 | start a streaming server locally and serve the media file to the 42 | chromecast. 43 | 44 | If the media file is an unplayable media type by the chromecast, this 45 | will attempt to transcode the media file to mp4 using ffmpeg. This requires 46 | that ffmpeg is installed.`, 47 | Run: func(cmd *cobra.Command, args []string) { 48 | if len(args) != 1 { 49 | exit("requires exactly one argument, should be the folder to play media from") 50 | } 51 | if fileInfo, err := os.Stat(args[0]); err != nil { 52 | exit("unable to find %q: %v", args[0], err) 53 | } else if !fileInfo.Mode().IsDir() { 54 | exit("%q is not a directory", args[0]) 55 | } 56 | app, err := castApplication(cmd, args) 57 | if err != nil { 58 | exit("unable to get cast application: %v", err) 59 | } 60 | 61 | contentType, _ := cmd.Flags().GetString("content-type") 62 | transcode, _ := cmd.Flags().GetBool("transcode") 63 | forcePlay, _ := cmd.Flags().GetBool("force-play") 64 | continuePlaying, _ := cmd.Flags().GetBool("continue") 65 | selection, _ := cmd.Flags().GetBool("select") 66 | files, err := ioutil.ReadDir(args[0]) 67 | if err != nil { 68 | exit("unable to list files from %q: %v", args[0], err) 69 | } 70 | filesToPlay := make([]mediaFile, 0, len(files)) 71 | for _, f := range files { 72 | if !forcePlay && !app.PlayableMediaType(filepath.Join(args[0], f.Name())) { 73 | continue 74 | } 75 | 76 | foundNum := false 77 | numPos := 0 78 | foundNumbers := []int{} 79 | for i, c := range f.Name() { 80 | if c < '0' || c > '9' { 81 | if foundNum { 82 | val, _ := strconv.Atoi(f.Name()[numPos:i]) 83 | foundNumbers = append(foundNumbers, val) 84 | } 85 | foundNum = false 86 | continue 87 | } 88 | 89 | if !foundNum { 90 | numPos = i 91 | foundNum = true 92 | } 93 | } 94 | 95 | filesToPlay = append(filesToPlay, mediaFile{ 96 | filename: f.Name(), 97 | possibleNumbers: foundNumbers, 98 | }) 99 | 100 | } 101 | 102 | sort.Slice(filesToPlay, func(i, j int) bool { 103 | iNum := filesToPlay[i].possibleNumbers 104 | jNum := filesToPlay[j].possibleNumbers 105 | if len(iNum) == 0 { 106 | return false 107 | } 108 | if len(jNum) == 0 { 109 | return true 110 | } 111 | max := len(iNum) 112 | if len(iNum) < len(jNum) { 113 | max = len(jNum) 114 | } 115 | for vi := 0; vi < max; vi++ { 116 | if len(iNum) <= vi { 117 | return false 118 | } 119 | if len(jNum) <= vi { 120 | return true 121 | } 122 | if iNum[vi] == jNum[vi] { 123 | continue 124 | } 125 | if iNum[vi] > jNum[vi] { 126 | return false 127 | } 128 | return true 129 | } 130 | return true 131 | }) 132 | 133 | filenames := make([]string, len(filesToPlay)) 134 | for i, f := range filesToPlay { 135 | filename := filepath.Join(args[0], f.filename) 136 | filenames[i] = filename 137 | } 138 | 139 | indexToPlayFrom := 0 140 | if selection { 141 | outputInfo("Will play the following items, select where to start from:") 142 | for i, f := range filenames { 143 | lastPlayed := "never" 144 | if lp, ok := app.PlayedItems()[f]; ok { 145 | t := time.Unix(lp.Started, 0) 146 | lastPlayed = t.String() 147 | } 148 | outputInfo("%d) %s: last played %q", i+1, f, lastPlayed) 149 | } 150 | reader := bufio.NewReader(os.Stdin) 151 | for { 152 | outputInfo("Enter selection: ") 153 | text, err := reader.ReadString('\n') 154 | if err != nil { 155 | outputError("reading console: %v", err) 156 | continue 157 | } 158 | i, err := strconv.Atoi(strings.TrimSpace(text)) 159 | if err != nil { 160 | continue 161 | } else if i < 1 || i > len(filenames) { 162 | continue 163 | } 164 | indexToPlayFrom = i - 1 165 | break 166 | } 167 | } else if continuePlaying { 168 | var lastPlayedStartUnix int64 = 0 169 | var lastPlayedEndUnix int64 = 0 170 | lastPlayedIndex := 0 171 | for i, f := range filenames { 172 | p, ok := app.PlayedItems()[f] 173 | if ok && p.Started > lastPlayedStartUnix { 174 | lastPlayedStartUnix = p.Started 175 | lastPlayedEndUnix = p.Finished 176 | lastPlayedIndex = i 177 | } 178 | } 179 | 180 | if lastPlayedIndex > 0 { 181 | if lastPlayedStartUnix < lastPlayedEndUnix { 182 | if len(filenames) > lastPlayedIndex { 183 | // lastPlayedIndex += 1 184 | } else { 185 | lastPlayedIndex = 0 186 | } 187 | } 188 | } 189 | indexToPlayFrom = lastPlayedIndex 190 | } 191 | 192 | s := "Attemping to play the following media:" 193 | for _, f := range filenames[indexToPlayFrom:] { 194 | s += "- " + f + " " 195 | } 196 | outputInfo(s) 197 | 198 | // Optionally run a UI when playing this media: 199 | runWithUI, _ := cmd.Flags().GetBool("with-ui") 200 | if runWithUI { 201 | go func() { 202 | if err := app.QueueLoad(filenames[indexToPlayFrom:], contentType, transcode); err != nil { 203 | exit("unable to play playlist on cast application: %v", err) 204 | } 205 | }() 206 | 207 | ccui, err := ui.NewUserInterface(app) 208 | if err != nil { 209 | exit("unable to prepare a new user-interface: %v", err) 210 | } 211 | if err := ccui.Run(); err != nil { 212 | exit("unable to run ui: %v", err) 213 | } 214 | } 215 | 216 | if err := app.QueueLoad(filenames[indexToPlayFrom:], contentType, transcode); err != nil { 217 | exit("unable to play playlist on cast application: %v", err) 218 | } 219 | }, 220 | } 221 | 222 | func init() { 223 | rootCmd.AddCommand(playlistCmd) 224 | playlistCmd.Flags().Bool("continue", true, "continue playing from the last known media") 225 | playlistCmd.Flags().Bool("select", false, "choose which media to start the playlist from") 226 | playlistCmd.Flags().Bool("transcode", true, "transcode the media to mp4 if media type is unrecognised") 227 | playlistCmd.Flags().Bool("force-play", false, "attempt to play a media type even if it is unrecognised") 228 | playlistCmd.Flags().StringP("content-type", "c", "", "content-type to serve the media file as") 229 | } 230 | -------------------------------------------------------------------------------- /cmd/previous.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // previousCmd represents the previous command 22 | var previousCmd = &cobra.Command{ 23 | Use: "previous", 24 | Short: "Play the previous available media", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.Previous(); err != nil { 31 | exit("unable to play previous media: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(previousCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/restart.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // restartCmd represents the restart command 22 | var restartCmd = &cobra.Command{ 23 | Use: "restart", 24 | Short: "Restart the currently playing media", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.SeekFromStart(0); err != nil { 31 | exit("unable to restart media: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(restartCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/rewind.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "strconv" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // rewindCmd represents the rewind command 24 | var rewindCmd = &cobra.Command{ 25 | Use: "rewind ", 26 | Short: "Rewind by seconds the currently playing media", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | if len(args) != 1 { 29 | exit("one argument required") 30 | } 31 | value, err := strconv.Atoi(args[0]) 32 | if err != nil { 33 | exit("unable to parse %q to an integer", args[0]) 34 | } 35 | app, err := castApplication(cmd, args) 36 | if err != nil { 37 | exit("unable to get cast application: %v", err) 38 | } 39 | if err := app.Seek(-value); err != nil { 40 | exit("unable to rewind current media: %v", err) 41 | } 42 | }, 43 | } 44 | 45 | func init() { 46 | rootCmd.AddCommand(rewindCmd) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "time" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | var ( 24 | Version = "" 25 | Commit = "" 26 | Date = "" 27 | ) 28 | 29 | // rootCmd represents the base command when called without any subcommands 30 | var rootCmd = &cobra.Command{ 31 | Use: "go-chromecast", 32 | Short: "CLI for interacting with the Google Chromecast", 33 | Long: `Control your Google Chromecast or Google Home Mini from the 34 | command line.`, 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | printVersion, _ := cmd.Flags().GetBool("version") 37 | if printVersion { 38 | if len(Version) > 0 && Version[0] != 'v' && Version != "dev" { 39 | Version = "v" + Version 40 | } 41 | outputInfo("go-chromecast %s (%s) %s", Version, Commit, Date) 42 | return nil 43 | } 44 | return cmd.Help() 45 | }, 46 | } 47 | 48 | // Execute adds all child commands to the root command and sets flags appropriately. 49 | // This is called by main.main(). It only needs to happen once to the rootCmd. 50 | func Execute(version, commit, date string) int { 51 | Version = version 52 | Commit = commit 53 | if date != "" { 54 | Date = date 55 | } else { 56 | Date = time.Now().UTC().Format(time.RFC3339) 57 | } 58 | if err := rootCmd.Execute(); err != nil { 59 | return 1 60 | } 61 | return 0 62 | } 63 | 64 | func init() { 65 | rootCmd.PersistentFlags().Bool("version", false, "display command version") 66 | // TODO: clean up shortened "v" for debug and move to verbose, and ensure 67 | // verbose is used appropriately as debug. 68 | rootCmd.PersistentFlags().BoolP("debug", "v", false, "debug logging") 69 | rootCmd.PersistentFlags().Bool("verbose", false, "verbose logging") 70 | rootCmd.PersistentFlags().Bool("disable-cache", false, "disable the cache") 71 | rootCmd.PersistentFlags().Bool("with-ui", false, "run with a UI") 72 | rootCmd.PersistentFlags().StringP("device", "d", "", "chromecast device, ie: 'Chromecast' or 'Google Home Mini'") 73 | rootCmd.PersistentFlags().StringP("device-name", "n", "", "chromecast device name") 74 | rootCmd.PersistentFlags().StringP("uuid", "u", "", "chromecast device uuid") 75 | rootCmd.PersistentFlags().StringP("addr", "a", "", "Address of the chromecast device") 76 | rootCmd.PersistentFlags().StringP("port", "p", "8009", "Port of the chromecast device if 'addr' is specified") 77 | rootCmd.PersistentFlags().StringP("iface", "i", "", "Network interface to use when looking for a local address to use for the http server or for use with multicast dns discovery") 78 | rootCmd.PersistentFlags().IntP("server-port", "s", 0, "Listening port for the http server") 79 | rootCmd.PersistentFlags().Int("dns-timeout", 3, "Multicast DNS timeout in seconds when searching for chromecast DNS entries") 80 | rootCmd.PersistentFlags().Bool("first", false, "Use first cast device found") 81 | } 82 | -------------------------------------------------------------------------------- /cmd/scan.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2024 Martin Holst Swende 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "fmt" 19 | "net" 20 | "sync" 21 | "time" 22 | 23 | "github.com/seancfoley/ipaddress-go/ipaddr" 24 | "github.com/spf13/cobra" 25 | "github.com/vishen/go-chromecast/application" 26 | ) 27 | 28 | // scanCmd triggers a scan 29 | var scanCmd = &cobra.Command{ 30 | Use: "scan", 31 | Short: "Scan for chromecast devices", 32 | Run: func(cmd *cobra.Command, args []string) { 33 | var ( 34 | cidrAddr, _ = cmd.Flags().GetString("cidr") 35 | port, _ = cmd.Flags().GetInt("port") 36 | wg sync.WaitGroup 37 | ipCh = make(chan *ipaddr.IPAddress) 38 | logged = time.Unix(0, 0) 39 | start = time.Now() 40 | count int 41 | ipRange, err = ipaddr.NewIPAddressString(cidrAddr).ToSequentialRange() 42 | ) 43 | if err != nil { 44 | exit("could not parse cidr address expression: %v", err) 45 | } 46 | // Use one goroutine to send URIs over a channel 47 | go func() { 48 | it := ipRange.Iterator() 49 | for it.HasNext() { 50 | ip := it.Next() 51 | if time.Since(logged) > 8*time.Second { 52 | outputInfo("Scanning... scanned %d, current %v\n", count, ip.String()) 53 | logged = time.Now() 54 | } 55 | ipCh <- ip 56 | count++ 57 | } 58 | close(ipCh) 59 | }() 60 | // Use a bunch of goroutines to do connect-attempts. 61 | for i := 0; i < 64; i++ { 62 | wg.Add(1) 63 | go func() { 64 | defer wg.Done() 65 | dialer := &net.Dialer{ 66 | Timeout: 400 * time.Millisecond, 67 | } 68 | for ip := range ipCh { 69 | conn, err := dialer.Dial("tcp", fmt.Sprintf("%v:%d", ip, port)) 70 | if err != nil { 71 | continue 72 | } 73 | conn.Close() 74 | if info, err := application.GetInfo(ip.String()); err != nil { 75 | outputInfo(" - Device at %v:%d errored during discovery: %v", ip, port, err) 76 | } else { 77 | outputInfo(" - '%v' at %v:%d\n", info.Name, ip, port) 78 | } 79 | } 80 | }() 81 | } 82 | wg.Wait() 83 | outputInfo("Scanned %d uris in %v\n", count, time.Since(start)) 84 | }, 85 | } 86 | 87 | func init() { 88 | scanCmd.Flags().String("cidr", "192.168.50.0/24", "cidr expression of subnet to scan") 89 | scanCmd.Flags().Int("port", 8009, "port to scan for") 90 | rootCmd.AddCommand(scanCmd) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/seek-to.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "strconv" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // seekToCmd represents the seekTo command 24 | var seekToCmd = &cobra.Command{ 25 | Use: "seek-to ", 26 | Short: "Seek to the in the currently playing media", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | if len(args) != 1 { 29 | exit("one argument required") 30 | } 31 | value, err := strconv.ParseFloat(args[0], 32) 32 | if err != nil { 33 | exit("unable to parse %q to an integer", args[0]) 34 | } 35 | app, err := castApplication(cmd, args) 36 | if err != nil { 37 | exit("unable to get cast application: %v", err) 38 | } 39 | if err := app.SeekToTime(float32(value)); err != nil { 40 | exit("unable to seek to current media: %v", err) 41 | } 42 | }, 43 | } 44 | 45 | func init() { 46 | rootCmd.AddCommand(seekToCmd) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/seek.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "strconv" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // seekCmd represents the seek command 24 | var seekCmd = &cobra.Command{ 25 | Use: "seek ", 26 | Short: "Seek by seconds into the currently playing media", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | if len(args) != 1 { 29 | exit("one argument required") 30 | } 31 | value, err := strconv.Atoi(args[0]) 32 | if err != nil { 33 | exit("unable to parse %q to an integer", args[0]) 34 | } 35 | app, err := castApplication(cmd, args) 36 | if err != nil { 37 | exit("unable to get cast application: %v", err) 38 | } 39 | if err := app.Seek(value); err != nil { 40 | exit("unable to seek current media: %v", err) 41 | } 42 | }, 43 | } 44 | 45 | func init() { 46 | rootCmd.AddCommand(seekCmd) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/skipad.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // skipadCmd represents the unpause command 8 | var skipadCmd = &cobra.Command{ 9 | Use: "skipad", 10 | Short: "Skip the currently playing ad on the chromecast", 11 | Run: func(cmd *cobra.Command, args []string) { 12 | app, err := castApplication(cmd, args) 13 | if err != nil { 14 | exit("unable to get cast application: %v\n", err) 15 | return 16 | } 17 | if err := app.Skipad(); err != nil { 18 | exit("unable to skip current ad: %v\n", err) 19 | } 20 | 21 | }, 22 | } 23 | 24 | func init() { 25 | rootCmd.AddCommand(skipadCmd) 26 | } 27 | -------------------------------------------------------------------------------- /cmd/slideshow.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "os" 19 | 20 | "io/fs" 21 | 22 | "github.com/rs/zerolog/log" 23 | "github.com/spf13/cobra" 24 | 25 | "path/filepath" 26 | "strings" 27 | ) 28 | 29 | type share struct { 30 | files []string 31 | } 32 | 33 | // slideshowCmd represents the slideshow command 34 | var slideshowCmd = &cobra.Command{ 35 | Use: "slideshow file1 file2 ...", 36 | Short: "Play a slideshow of photos", 37 | Run: func(cmd *cobra.Command, args []string) { 38 | if len(args) == 0 { 39 | exit("requires files (or directories) to play in slideshow") 40 | } 41 | 42 | s := &share{} 43 | 44 | for _, arg := range args { 45 | if fileInfo, err := os.Stat(arg); err != nil { 46 | log.Warn().Msgf("unable to find %q: %v", arg, err) 47 | } else if fileInfo.Mode().IsDir() { 48 | log.Debug().Msgf("%q is a directory", arg) 49 | 50 | // recursively find files in directory 51 | // TODO: this will consume large amounts of memory as it will hold references to each file (media item) to be served 52 | filepath.WalkDir(arg, s.getFilesRecursively) 53 | } else { 54 | s.files = append(s.files, arg) 55 | } 56 | } 57 | 58 | app, err := castApplication(cmd, s.files) 59 | if err != nil { 60 | exit("unable to get cast application: %v", err) 61 | } 62 | 63 | duration, _ := cmd.Flags().GetInt("duration") 64 | repeat, _ := cmd.Flags().GetBool("repeat") 65 | if err := app.Slideshow(s.files, duration, repeat); err != nil { 66 | exit("unable to play slideshow on cast application: %v", err) 67 | } 68 | }, 69 | } 70 | 71 | func (s *share) getFilesRecursively(path string, d fs.DirEntry, err error) error { 72 | if err != nil { 73 | return err 74 | } 75 | 76 | fileInfo, err := os.Stat(path) 77 | if err != nil { 78 | log.Warn().Msgf("Error checking file: %v | %v", path, err) 79 | return nil 80 | } 81 | 82 | if !fileInfo.Mode().IsDir() { 83 | if isSupportedImageType(path) { 84 | s.files = append(s.files, path) 85 | } else { 86 | log.Warn().Msgf("excluding %s as it is not a supported image type", path) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func isSupportedImageType(path string) bool { 94 | switch ext := strings.ToLower(filepath.Ext(path)); ext { 95 | case ".jpg", ".jpeg", ".gif", ".bmp", ".png", ".webp": 96 | return true 97 | default: 98 | return false 99 | } 100 | } 101 | 102 | func init() { 103 | rootCmd.AddCommand(slideshowCmd) 104 | slideshowCmd.Flags().Int("duration", 10, "duration of each image on screen") 105 | slideshowCmd.Flags().Bool("repeat", true, "should the slideshow repeat") 106 | } 107 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // statusCmd represents the status command 24 | var statusCmd = &cobra.Command{ 25 | Use: "status", 26 | Short: "Current chromecast status", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | app, err := castApplication(cmd, args) 29 | if err != nil { 30 | exit("unable to get cast application: %v", err) 31 | } 32 | castApplication, castMedia, castVolume := app.Status() 33 | volumeLevel := castVolume.Level 34 | volumeMuted := castVolume.Muted 35 | 36 | contentId, _ := cmd.Flags().GetBool("content-id") 37 | 38 | scriptMode := contentId 39 | 40 | if scriptMode { 41 | if contentId { 42 | if castMedia != nil { 43 | outputInfo(castMedia.Media.ContentId) 44 | } else { 45 | outputInfo("not available") 46 | } 47 | } 48 | } else if castApplication == nil { 49 | outputInfo("Idle, volume=%0.2f muted=%t", volumeLevel, volumeMuted) 50 | } else { 51 | displayName := castApplication.DisplayName 52 | if castApplication.IsIdleScreen { 53 | outputInfo("Idle (%s), volume=%0.2f muted=%t", displayName, volumeLevel, volumeMuted) 54 | } else if castMedia == nil { 55 | outputInfo("Idle (%s), volume=%0.2f muted=%t", displayName, volumeLevel, volumeMuted) 56 | } else { 57 | var metadata string 58 | var usefulID string 59 | switch castMedia.Media.ContentType { 60 | case "x-youtube/video": 61 | usefulID = fmt.Sprintf("[%s] ", castMedia.Media.ContentId) 62 | } 63 | if castMedia.Media.Metadata.Title != "" { 64 | md := castMedia.Media.Metadata 65 | metadata = fmt.Sprintf("title=%q, artist=%q", md.Title, md.Artist) 66 | } 67 | if castMedia.Media.ContentId != "" { 68 | if metadata != "" { 69 | metadata += ", " 70 | } 71 | metadata += fmt.Sprintf("[%s]", castMedia.Media.ContentId) 72 | } 73 | if metadata == "" { 74 | metadata = "unknown" 75 | 76 | } 77 | outputInfo("%s%s (%s), %s, time remaining=%.0fs/%.0fs, volume=%0.2f, muted=%t", usefulID, displayName, castMedia.PlayerState, metadata, castMedia.CurrentTime, castMedia.Media.Duration, volumeLevel, volumeMuted) 78 | } 79 | } 80 | }, 81 | } 82 | 83 | func init() { 84 | rootCmd.AddCommand(statusCmd) 85 | statusCmd.Flags().Bool("content-id", false, "print the content id if available") 86 | } 87 | -------------------------------------------------------------------------------- /cmd/stop.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // stopCmd represents the stop command 22 | var stopCmd = &cobra.Command{ 23 | Use: "stop", 24 | Short: "Stop casting", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.Stop(); err != nil { 31 | exit("unable to stop casting: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(stopCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/togglepause.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // togglepauseCmd represents the togglepause command 22 | var togglepauseCmd = &cobra.Command{ 23 | Use: "togglepause", 24 | Aliases: []string{"tpause", "playpause"}, 25 | Short: "Toggle paused/unpaused state. Aliases: tpause, playpause", 26 | Run: func(cmd *cobra.Command, args []string) { 27 | app, err := castApplication(cmd, args) 28 | if err != nil { 29 | exit("unable to get cast application: %v", err) 30 | } 31 | if err := app.TogglePause(); err != nil { 32 | exit("unable to (un)pause cast application: %v", err) 33 | } 34 | }, 35 | } 36 | 37 | func init() { 38 | rootCmd.AddCommand(togglepauseCmd) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/transcode.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "strings" 19 | 20 | "github.com/spf13/cobra" 21 | "github.com/vishen/go-chromecast/ui" 22 | ) 23 | 24 | // transcodeCmd represents the transcode command 25 | var transcodeCmd = &cobra.Command{ 26 | Use: "transcode", 27 | Short: "Transcode and play media on the chromecast", 28 | Long: `Transcode and play media on the chromecast. This will start a streaming server 29 | locally and serve the output of the transcoding operation to the chromecast. 30 | This command requires the program or script to write the media content to stdout. 31 | The transcoded media content-type is required as well`, 32 | Run: func(cmd *cobra.Command, args []string) { 33 | app, err := castApplication(cmd, args) 34 | if err != nil { 35 | exit("unable to get cast application: %v", err) 36 | } 37 | 38 | contentType, _ := cmd.Flags().GetString("content-type") 39 | command, _ := cmd.Flags().GetString("command") 40 | 41 | var commandArgs []string 42 | if command == "" { 43 | command = args[0] 44 | commandArgs = args[1:] 45 | } else { 46 | s := strings.Split(command, " ") 47 | command = s[0] 48 | commandArgs = s[1:] 49 | } 50 | 51 | runWithUI, _ := cmd.Flags().GetBool("with-ui") 52 | if runWithUI { 53 | go func() { 54 | if err := app.Transcode(contentType, command, commandArgs...); err != nil { 55 | exit("unable to load media: %v", err) 56 | } 57 | }() 58 | 59 | ccui, err := ui.NewUserInterface(app) 60 | if err != nil { 61 | exit("unable to prepare a new user-interface: %v", err) 62 | } 63 | if err := ccui.Run(); err != nil { 64 | exit("unable to run ui: %v", err) 65 | } 66 | } 67 | 68 | if err := app.Transcode(contentType, command, commandArgs...); err != nil { 69 | exit("unable to transcode media: %v", err) 70 | } 71 | }, 72 | } 73 | 74 | func init() { 75 | rootCmd.AddCommand(transcodeCmd) 76 | transcodeCmd.Flags().String("command", "", "command to use when transcoding") 77 | transcodeCmd.Flags().StringP("content-type", "c", "", "content-type to serve the media file as") 78 | } 79 | -------------------------------------------------------------------------------- /cmd/tts.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2019 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "io/ioutil" 19 | "os" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/vishen/go-chromecast/tts" 23 | ) 24 | 25 | // ttsCmd represents the tts command 26 | var ttsCmd = &cobra.Command{ 27 | Use: "tts ", 28 | Short: "text-to-speech", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | 31 | if len(args) != 1 || args[0] == "" { 32 | exit("expected exactly one argument to convert to speech") 33 | return 34 | } 35 | 36 | googleServiceAccount, _ := cmd.Flags().GetString("google-service-account") 37 | if googleServiceAccount == "" { 38 | exit("--google-service-account is required") 39 | return 40 | } 41 | 42 | languageCode, _ := cmd.Flags().GetString("language-code") 43 | voiceName, _ := cmd.Flags().GetString("voice-name") 44 | speakingRate, _ := cmd.Flags().GetFloat32("speaking-rate") 45 | pitch, _ := cmd.Flags().GetFloat32("pitch") 46 | ssml, _ := cmd.Flags().GetBool("ssml") 47 | 48 | b, err := ioutil.ReadFile(googleServiceAccount) 49 | if err != nil { 50 | exit("unable to open google service account file: %v", err) 51 | } 52 | 53 | app, err := castApplication(cmd, args) 54 | if err != nil { 55 | exit("unable to get cast application: %v", err) 56 | } 57 | 58 | data, err := tts.Create(args[0], b, languageCode, voiceName, speakingRate, pitch, ssml) 59 | if err != nil { 60 | exit("unable to create tts: %v", err) 61 | } 62 | 63 | f, err := ioutil.TempFile("", "go-chromecast-tts") 64 | if err != nil { 65 | exit("unable to create temp file: %v", err) 66 | } 67 | defer os.Remove(f.Name()) 68 | 69 | if _, err := f.Write(data); err != nil { 70 | exit("unable to write to temp file: %v", err) 71 | } 72 | if err := f.Close(); err != nil { 73 | exit("unable to close temp file: %v", err) 74 | } 75 | 76 | if err := app.Load(f.Name(), 0, "audio/mp3", false, false, false); err != nil { 77 | exit("unable to load media to device: %v", err) 78 | } 79 | }, 80 | } 81 | 82 | func init() { 83 | rootCmd.AddCommand(ttsCmd) 84 | ttsCmd.Flags().String("google-service-account", "", "google service account JSON file") 85 | ttsCmd.Flags().String("language-code", "en-US", "text-to-speech Language Code (de-DE, ja-JP,...)") 86 | ttsCmd.Flags().String("voice-name", "en-US-Wavenet-G", "text-to-speech Voice (en-US-Wavenet-G, pl-PL-Wavenet-A, pl-PL-Wavenet-B, de-DE-Wavenet-A)") 87 | ttsCmd.Flags().Float32("speaking-rate", 1.0, "speaking rate") 88 | ttsCmd.Flags().Float32("pitch", 1.0, "pitch") 89 | ttsCmd.Flags().Bool("ssml", false, "use SSML") 90 | } 91 | -------------------------------------------------------------------------------- /cmd/ui.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/vishen/go-chromecast/ui" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // uiCmd represents the ui command (runs a UI): 24 | var uiCmd = &cobra.Command{ 25 | Use: "ui", 26 | Short: "Run the UI", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | app, err := castApplication(cmd, args) 29 | if err != nil { 30 | exit("unable to get cast application: %v", err) 31 | return 32 | } 33 | 34 | ccui, err := ui.NewUserInterface(app) 35 | if err != nil { 36 | exit("unable to prepare a new user-interface: %v", err) 37 | } 38 | 39 | if err := ccui.Run(); err != nil { 40 | exit("unable to start the user-interface: %v", err) 41 | } 42 | }, 43 | } 44 | 45 | func init() { 46 | rootCmd.AddCommand(uiCmd) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/unmute.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // unmuteCmd represents the unmute command 22 | var unmuteCmd = &cobra.Command{ 23 | Use: "unmute", 24 | Short: "Unmute the chromecast", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.SetMuted(false); err != nil { 31 | exit("unable to unmute cast application: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(unmuteCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/unpause.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // unpauseCmd represents the unpause command 22 | var unpauseCmd = &cobra.Command{ 23 | Use: "unpause", 24 | Short: "Unpause the currently playing media on the chromecast", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | if err := app.Unpause(); err != nil { 31 | exit("unable to pause cast application: %v", err) 32 | } 33 | }, 34 | } 35 | 36 | func init() { 37 | rootCmd.AddCommand(unpauseCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net" 9 | "os" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/pkg/errors" 16 | log "github.com/sirupsen/logrus" 17 | "github.com/spf13/cobra" 18 | "github.com/vishen/go-chromecast/application" 19 | castdns "github.com/vishen/go-chromecast/dns" 20 | "github.com/vishen/go-chromecast/storage" 21 | ) 22 | 23 | var ( 24 | cache = storage.NewStorage() 25 | 26 | // Set up a global dns entry so we can attempt reconnects 27 | entry castdns.CastDNSEntry 28 | ) 29 | 30 | type CachedDNSEntry struct { 31 | UUID string `json:"uuid"` 32 | Name string `json:"name"` 33 | Addr string `json:"addr"` 34 | Port int `json:"port"` 35 | } 36 | 37 | func (e CachedDNSEntry) GetUUID() string { 38 | return e.UUID 39 | } 40 | 41 | func (e CachedDNSEntry) GetName() string { 42 | return e.Name 43 | } 44 | 45 | func (e CachedDNSEntry) GetAddr() string { 46 | return e.Addr 47 | } 48 | 49 | func (e CachedDNSEntry) GetPort() int { 50 | return e.Port 51 | } 52 | 53 | func castApplication(cmd *cobra.Command, args []string) (application.App, error) { 54 | deviceName, _ := cmd.Flags().GetString("device-name") 55 | deviceUuid, _ := cmd.Flags().GetString("uuid") 56 | device, _ := cmd.Flags().GetString("device") 57 | debug, _ := cmd.Flags().GetBool("debug") 58 | disableCache, _ := cmd.Flags().GetBool("disable-cache") 59 | addr, _ := cmd.Flags().GetString("addr") 60 | port, _ := cmd.Flags().GetString("port") 61 | ifaceName, _ := cmd.Flags().GetString("iface") 62 | serverPort, _ := cmd.Flags().GetInt("server-port") 63 | dnsTimeoutSeconds, _ := cmd.Flags().GetInt("dns-timeout") 64 | useFirstDevice, _ := cmd.Flags().GetBool("first") 65 | 66 | // Used to try and reconnect 67 | if deviceUuid == "" && entry != nil { 68 | deviceUuid = entry.GetUUID() 69 | entry = nil 70 | } 71 | 72 | if debug { 73 | log.SetLevel(log.DebugLevel) 74 | } 75 | 76 | applicationOptions := []application.ApplicationOption{ 77 | application.WithServerPort(serverPort), 78 | application.WithDebug(debug), 79 | application.WithCacheDisabled(disableCache), 80 | } 81 | 82 | // If we need to look on a specific network interface for mdns or 83 | // for finding a network ip to host from, ensure that the network 84 | // interface exists. 85 | var iface *net.Interface 86 | if ifaceName != "" { 87 | var err error 88 | if iface, err = net.InterfaceByName(ifaceName); err != nil { 89 | return nil, errors.Wrap(err, fmt.Sprintf("unable to find interface %q", ifaceName)) 90 | } 91 | applicationOptions = append(applicationOptions, application.WithIface(iface)) 92 | } 93 | 94 | // If no address was specified, attempt to determine the address of any 95 | // local chromecast devices. 96 | if addr == "" { 97 | // If a device name or uuid was specified, check the cache for the ip+port 98 | found := false 99 | if !disableCache && (deviceName != "" || deviceUuid != "") { 100 | entry = findCachedCastDNS(deviceName, deviceUuid) 101 | found = entry.GetAddr() != "" 102 | } 103 | if !found { 104 | var err error 105 | if entry, err = findCastDNS(iface, dnsTimeoutSeconds, device, deviceName, deviceUuid, useFirstDevice); err != nil { 106 | return nil, errors.Wrap(err, "unable to find cast dns entry") 107 | } 108 | } 109 | if !disableCache { 110 | cachedEntry := CachedDNSEntry{ 111 | UUID: entry.GetUUID(), 112 | Name: entry.GetName(), 113 | Addr: entry.GetAddr(), 114 | Port: entry.GetPort(), 115 | } 116 | cachedEntryJson, _ := json.Marshal(cachedEntry) 117 | if err := cache.Save(getCacheKey(cachedEntry.UUID), cachedEntryJson); err != nil { 118 | outputError("Failed to save UUID cache entry\n") 119 | } 120 | if err := cache.Save(getCacheKey(cachedEntry.Name), cachedEntryJson); err != nil { 121 | outputError("Failed to save name cache entry\n") 122 | } 123 | } 124 | if debug { 125 | outputInfo("using device name=%s addr=%s port=%d uuid=%s", entry.GetName(), entry.GetAddr(), entry.GetPort(), entry.GetUUID()) 126 | } 127 | } else { 128 | p, err := strconv.Atoi(port) 129 | if err != nil { 130 | return nil, errors.Wrap(err, "port needs to be a number") 131 | } 132 | entry = CachedDNSEntry{ 133 | Addr: addr, 134 | Port: p, 135 | } 136 | } 137 | app := application.NewApplication(applicationOptions...) 138 | if err := app.Start(entry.GetAddr(), entry.GetPort()); err != nil { 139 | // NOTE: currently we delete the dns cache every time we get 140 | // an error, this is to make sure that if the device gets a new 141 | // ipaddress we will invalidate the cache. 142 | if err := cache.Save(getCacheKey(entry.GetUUID()), []byte{}); err != nil { 143 | fmt.Printf("Failed to save UUID cache entry: %v\n", err) 144 | } 145 | if err := cache.Save(getCacheKey(entry.GetName()), []byte{}); err != nil { 146 | fmt.Printf("Failed to save name cache entry: %v\n", err) 147 | } 148 | return nil, err 149 | } 150 | return app, nil 151 | } 152 | 153 | // reconnect will attempt to reconnect to the cast device 154 | // TODO: This is all very hacky, currently a global dns entry is set which 155 | // contains the device UUID, and this is then used to reconnect. This should 156 | // be handled much nicer and we shouldn't need to pass around the cmd and args everywhere 157 | // just to reconnect. This might require adding something that wraps the application and 158 | // dns? 159 | func reconnect(cmd *cobra.Command, args []string) (application.App, error) { 160 | return castApplication(cmd, args) 161 | } 162 | 163 | func getCacheKey(suffix string) string { 164 | return fmt.Sprintf("cmd/utils/dns/%s", suffix) 165 | } 166 | 167 | func findCachedCastDNS(deviceName, deviceUuid string) castdns.CastDNSEntry { 168 | for _, s := range []string{deviceName, deviceUuid} { 169 | cacheKey := getCacheKey(s) 170 | if b, err := cache.Load(cacheKey); err == nil { 171 | cachedEntry := CachedDNSEntry{} 172 | if err := json.Unmarshal(b, &cachedEntry); err == nil { 173 | return cachedEntry 174 | } 175 | } 176 | } 177 | return CachedDNSEntry{} 178 | } 179 | 180 | func findCastDNS(iface *net.Interface, dnsTimeoutSeconds int, device, deviceName, deviceUuid string, first bool) (castdns.CastDNSEntry, error) { 181 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(dnsTimeoutSeconds)) 182 | defer cancel() 183 | castEntryChan, err := castdns.DiscoverCastDNSEntries(ctx, iface) 184 | if err != nil { 185 | return castdns.CastEntry{}, err 186 | } 187 | 188 | isDeviceFilter := deviceUuid != "" || deviceName != "" || device != "" 189 | 190 | foundEntries := []castdns.CastEntry{} 191 | for entry := range castEntryChan { 192 | if first && !isDeviceFilter { 193 | return entry, nil 194 | } else if (deviceUuid != "" && entry.UUID == deviceUuid) || (deviceName != "" && entry.DeviceName == deviceName) || (device != "" && entry.Device == device) { 195 | return entry, nil 196 | } 197 | foundEntries = append(foundEntries, entry) 198 | } 199 | 200 | if len(foundEntries) == 0 || isDeviceFilter { 201 | return castdns.CastEntry{}, fmt.Errorf("no cast devices found on network") 202 | } 203 | 204 | // Always return entries in deterministic order. 205 | sort.Slice(foundEntries, func(i, j int) bool { return foundEntries[i].DeviceName < foundEntries[j].DeviceName }) 206 | 207 | outputInfo("Found %d cast dns entries, select one:", len(foundEntries)) 208 | for i, d := range foundEntries { 209 | outputInfo("%d) device=%q device_name=%q address=\"%s:%d\" uuid=%q", i+1, d.Device, d.DeviceName, d.AddrV4, d.Port, d.UUID) 210 | } 211 | reader := bufio.NewReader(os.Stdin) 212 | for { 213 | fmt.Printf("Enter selection: ") 214 | text, err := reader.ReadString('\n') 215 | if err != nil { 216 | fmt.Printf("error reading console: %v\n", err) 217 | continue 218 | } 219 | i, err := strconv.Atoi(strings.TrimSpace(text)) 220 | if err != nil { 221 | continue 222 | } else if i < 1 || i > len(foundEntries) { 223 | continue 224 | } 225 | return foundEntries[i-1], nil 226 | } 227 | } 228 | 229 | func outputError(msg string, args ...interface{}) { 230 | output(output_Error, msg, args...) 231 | } 232 | 233 | func outputInfo(msg string, args ...interface{}) { 234 | output(output_Info, msg, args...) 235 | } 236 | 237 | func exit(msg string, args ...interface{}) { 238 | outputError(msg, args...) 239 | os.Exit(1) 240 | } 241 | 242 | type outputLevel int 243 | 244 | const ( 245 | output_Info outputLevel = iota 246 | output_Error 247 | ) 248 | 249 | func output(t outputLevel, msg string, args ...interface{}) { 250 | switch t { 251 | case output_Error: 252 | fmt.Printf("%serror%s: ", RED, NC) 253 | } 254 | if !strings.HasSuffix(msg, "\n") { 255 | msg = msg + "\n" 256 | } 257 | fmt.Printf(msg, args...) 258 | } 259 | 260 | const ( 261 | RED = "\033[0;31m" 262 | NC = "\033[0m" // No Color 263 | ) 264 | -------------------------------------------------------------------------------- /cmd/volume-down.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2025 leak4mk0 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "math" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // volumeDownCmd represents the volume-down command 24 | var volumeDownCmd = &cobra.Command{ 25 | Use: "volume-down", 26 | Short: "Turn down volume", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | app, err := castApplication(cmd, args) 29 | if err != nil { 30 | exit("unable to get cast application: %v", err) 31 | } 32 | 33 | volumeStep, _ := cmd.Flags().GetFloat32("step") 34 | 35 | if err = app.Update(); err != nil { 36 | exit("unable to update cast info: %v", err) 37 | } 38 | _, _, castVolume := app.Status() 39 | 40 | nextVolume := max(castVolume.Level-volumeStep, math.SmallestNonzeroFloat32) 41 | if err = app.SetVolume(float32(nextVolume)); err != nil { 42 | exit("failed to set volume: %v", err) 43 | } 44 | 45 | if err = app.Update(); err != nil { 46 | exit("unable to update cast info: %v", err) 47 | } 48 | _, _, turnedCastVolume := app.Status() 49 | 50 | outputInfo("%0.2f", turnedCastVolume.Level) 51 | }, 52 | } 53 | 54 | func init() { 55 | rootCmd.AddCommand(volumeDownCmd) 56 | volumeDownCmd.Flags().Float32("step", 0.05, "step value for turning down volume") 57 | } 58 | -------------------------------------------------------------------------------- /cmd/volume-up.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2025 leak4mk0 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // volumeUpCmd represents the volume-up command 22 | var volumeUpCmd = &cobra.Command{ 23 | Use: "volume-up", 24 | Short: "Turn up volume", 25 | Run: func(cmd *cobra.Command, args []string) { 26 | app, err := castApplication(cmd, args) 27 | if err != nil { 28 | exit("unable to get cast application: %v", err) 29 | } 30 | 31 | volumeStep, _ := cmd.Flags().GetFloat32("step") 32 | 33 | if err = app.Update(); err != nil { 34 | exit("unable to update cast info: %v", err) 35 | } 36 | _, _, castVolume := app.Status() 37 | 38 | nextVolume := min(castVolume.Level+volumeStep, 1) 39 | if err = app.SetVolume(float32(nextVolume)); err != nil { 40 | exit("failed to set volume: %v", err) 41 | } 42 | 43 | if err = app.Update(); err != nil { 44 | exit("unable to update cast info: %v", err) 45 | } 46 | _, _, turnedCastVolume := app.Status() 47 | 48 | outputInfo("%0.2f", turnedCastVolume.Level) 49 | }, 50 | } 51 | 52 | func init() { 53 | rootCmd.AddCommand(volumeUpCmd) 54 | volumeUpCmd.Flags().Float32("step", 0.05, "step value for turning up volume") 55 | } 56 | -------------------------------------------------------------------------------- /cmd/volume.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "strconv" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // volumeCmd represents the volume command 24 | var volumeCmd = &cobra.Command{ 25 | Use: "volume [<0.00 - 1.00>]", 26 | Short: "Get or set volume", 27 | Long: "Get or set volume (float in range from 0 to 1)", 28 | Run: func(cmd *cobra.Command, args []string) { 29 | app, err := castApplication(cmd, args) 30 | if err != nil { 31 | exit("unable to get cast application: %v", err) 32 | } 33 | 34 | if len(args) == 1 && args[0] != "" { 35 | newVolume, err := strconv.ParseFloat(args[0], 32) 36 | if err != nil { 37 | exit("invalid volume: %v", err) 38 | } 39 | if err = app.SetVolume(float32(newVolume)); err != nil { 40 | exit("failed to set volume: %v", err) 41 | } 42 | } 43 | 44 | if err = app.Update(); err != nil { 45 | exit("unable to update cast info: %v", err) 46 | } 47 | _, _, castVolume := app.Status() 48 | 49 | outputInfo("%0.2f", castVolume.Level) 50 | }, 51 | } 52 | 53 | func init() { 54 | rootCmd.AddCommand(volumeCmd) 55 | } 56 | -------------------------------------------------------------------------------- /cmd/watch.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "os" 21 | "strings" 22 | "time" 23 | 24 | "github.com/buger/jsonparser" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/vishen/go-chromecast/application" 28 | pb "github.com/vishen/go-chromecast/cast/proto" 29 | ) 30 | 31 | // watchCmd represents the watch command 32 | var watchCmd = &cobra.Command{ 33 | Use: "watch", 34 | Short: "Watch all events sent from a chromecast device", 35 | Run: func(cmd *cobra.Command, args []string) { 36 | interval, _ := cmd.Flags().GetInt("interval") 37 | retries, _ := cmd.Flags().GetInt("retries") 38 | output, _ := cmd.Flags().GetString("output") 39 | 40 | o := outputNormal 41 | if strings.ToLower(output) == "json" { 42 | o = outputJSON 43 | } 44 | 45 | for i := 0; i < retries; i++ { 46 | retry := false 47 | app, err := castApplication(cmd, args) 48 | if err != nil { 49 | outputError("unable to get cast application: %v", err) 50 | time.Sleep(time.Second * 10) 51 | continue 52 | } 53 | done := make(chan struct{}, 1) 54 | go func() { 55 | for { 56 | if err := app.Update(); err != nil { 57 | outputError("unable to update cast application: %v", err) 58 | retry = true 59 | close(done) 60 | return 61 | } 62 | outputStatus(app, o) 63 | time.Sleep(time.Second * time.Duration(interval)) 64 | } 65 | }() 66 | 67 | app.AddMessageFunc(func(msg *pb.CastMessage) { 68 | protocolVersion := msg.GetProtocolVersion() 69 | sourceID := msg.GetSourceId() 70 | destID := msg.GetDestinationId() 71 | namespace := msg.GetNamespace() 72 | 73 | payload := msg.GetPayloadUtf8() 74 | payloadBytes := []byte(payload) 75 | requestID, _ := jsonparser.GetInt(payloadBytes, "requestId") 76 | messageType, _ := jsonparser.GetString(payloadBytes, "type") 77 | // Only log requests that are broadcasted from the chromecast. 78 | if requestID != 0 { 79 | return 80 | } 81 | 82 | switch o { 83 | case outputJSON: 84 | json.NewEncoder(os.Stdout).Encode(map[string]interface{}{ 85 | "type": messageType, 86 | "proto_version": protocolVersion, 87 | "namespace": namespace, 88 | "source_id": sourceID, 89 | "destination_id": destID, 90 | "payload": payload, 91 | }) 92 | case outputNormal: 93 | outputInfo("CHROMECAST BROADCAST MESSAGE: type=%s proto=%s (namespace=%s) %s -> %s | %s", messageType, protocolVersion, namespace, sourceID, destID, payload) 94 | } 95 | }) 96 | <-done 97 | if retry { 98 | // Sleep a little bit in-between retries 99 | outputInfo("attempting a retry...") 100 | time.Sleep(time.Second * 10) 101 | } 102 | } 103 | }, 104 | } 105 | 106 | type outputType int 107 | 108 | const ( 109 | outputNormal outputType = iota 110 | outputJSON 111 | ) 112 | 113 | func outputStatus(app application.App, outputType outputType) { 114 | castApplication, castMedia, castVolume := app.Status() 115 | 116 | switch outputType { 117 | case outputJSON: 118 | json.NewEncoder(os.Stdout).Encode(map[string]interface{}{ 119 | "application": castApplication, 120 | "media": castMedia, 121 | "volume": castVolume, 122 | }) 123 | case outputNormal: 124 | if castApplication == nil { 125 | outputInfo("Idle, volume=%0.2f muted=%t", castVolume.Level, castVolume.Muted) 126 | } else if castApplication.IsIdleScreen { 127 | outputInfo("Idle (%s), volume=%0.2f muted=%t", castApplication.DisplayName, castVolume.Level, castVolume.Muted) 128 | } else if castMedia == nil { 129 | outputInfo("Idle (%s), volume=%0.2f muted=%t", castApplication.DisplayName, castVolume.Level, castVolume.Muted) 130 | } else { 131 | metadata := "unknown" 132 | if castMedia.Media.Metadata.Title != "" { 133 | md := castMedia.Media.Metadata 134 | metadata = fmt.Sprintf("title=%q, artist=%q", md.Title, md.Artist) 135 | } 136 | switch castMedia.Media.ContentType { 137 | case "x-youtube/video": 138 | metadata = fmt.Sprintf("id=\"%s\", %s", castMedia.Media.ContentId, metadata) 139 | } 140 | outputInfo(">> %s (%s), %s, time remaining=%.2fs/%.2fs, volume=%0.2f, muted=%t", castApplication.DisplayName, castMedia.PlayerState, metadata, castMedia.CurrentTime, castMedia.Media.Duration, castVolume.Level, castVolume.Muted) 141 | } 142 | } 143 | } 144 | 145 | func init() { 146 | watchCmd.Flags().Int("interval", 10, "interval between status poll in seconds") 147 | watchCmd.Flags().Int("retries", 10, "times to retry when losing chromecast connection") 148 | watchCmd.Flags().String("output", "normal", "output format: normal or json") 149 | rootCmd.AddCommand(watchCmd) 150 | } 151 | -------------------------------------------------------------------------------- /dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | "unicode/utf8" 10 | 11 | "github.com/grandcat/zeroconf" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // CastDNSEntry is the interface that satisfies a Cast type. 16 | type CastDNSEntry interface { 17 | GetName() string 18 | GetUUID() string 19 | GetAddr() string 20 | GetPort() int 21 | } 22 | 23 | // CastEntry is the concrete cast entry type. 24 | type CastEntry struct { 25 | AddrV4 net.IP 26 | AddrV6 net.IP 27 | Port int 28 | 29 | Name string 30 | Host string 31 | 32 | UUID string 33 | Device string 34 | Status string 35 | DeviceName string 36 | InfoFields map[string]string 37 | } 38 | 39 | // GetUUID returns a unqiue id of a cast entry. 40 | func (e CastEntry) GetUUID() string { 41 | return e.UUID 42 | } 43 | 44 | // GetName returns the identified name of a cast entry. 45 | func (e CastEntry) GetName() string { 46 | return e.DeviceName 47 | } 48 | 49 | // GetAddr returns the IPV4 of a cast entry if it is not nil otherwise the IPV6. 50 | func (e CastEntry) GetAddr() string { 51 | if e.AddrV4 != nil { 52 | return e.AddrV4.String() 53 | } else { 54 | return fmt.Sprintf("[%s]", e.AddrV6.String()) 55 | } 56 | } 57 | 58 | // GetPort returns the port of a cast entry. 59 | func (e CastEntry) GetPort() int { 60 | return e.Port 61 | } 62 | 63 | // DiscoverCastDNSEntryByName returns the first cast dns device 64 | // found that matches the name. 65 | func DiscoverCastDNSEntryByName(ctx context.Context, iface *net.Interface, name string) (CastEntry, error) { 66 | castEntryChan, err := DiscoverCastDNSEntries(ctx, iface) 67 | if err != nil { 68 | return CastEntry{}, err 69 | } 70 | 71 | for d := range castEntryChan { 72 | if d.DeviceName == name { 73 | return d, nil 74 | } 75 | } 76 | return CastEntry{}, fmt.Errorf("No cast device found with name %q", name) 77 | } 78 | 79 | // DiscoverCastDNSEntries will return a channel with any cast dns entries 80 | // found. 81 | func DiscoverCastDNSEntries(ctx context.Context, iface *net.Interface) (<-chan CastEntry, error) { 82 | var opts = []zeroconf.ClientOption{ 83 | zeroconf.SelectIPTraffic(zeroconf.IPv4), 84 | } 85 | if iface != nil { 86 | opts = append(opts, zeroconf.SelectIfaces([]net.Interface{*iface})) 87 | } 88 | resolver, err := zeroconf.NewResolver(opts...) 89 | if err != nil { 90 | return nil, fmt.Errorf("unable to create new zeroconf resolver: %w", err) 91 | } 92 | 93 | castDNSEntriesChan := make(chan CastEntry, 5) 94 | entriesChan := make(chan *zeroconf.ServiceEntry, 5) 95 | go func() { 96 | if err := resolver.Browse(ctx, "_googlecast._tcp", "local", entriesChan); err != nil { 97 | log.WithError(err).Error("unable to browser for mdns entries") 98 | return 99 | } 100 | }() 101 | 102 | go func() { 103 | for { 104 | select { 105 | case <-ctx.Done(): 106 | close(castDNSEntriesChan) 107 | return 108 | case entry := <-entriesChan: 109 | if entry == nil { 110 | continue 111 | } 112 | castEntry := CastEntry{ 113 | Port: entry.Port, 114 | Host: entry.HostName, 115 | } 116 | if len(entry.AddrIPv4) > 0 { 117 | castEntry.AddrV4 = entry.AddrIPv4[0] 118 | } 119 | if len(entry.AddrIPv6) > 0 { 120 | castEntry.AddrV6 = entry.AddrIPv6[0] 121 | } 122 | infoFields := make(map[string]string, len(entry.Text)) 123 | for _, value := range entry.Text { 124 | if kv := strings.SplitN(value, "=", 2); len(kv) == 2 { 125 | key := kv[0] 126 | val := kv[1] 127 | 128 | infoFields[key] = val 129 | 130 | switch key { 131 | case "fn": 132 | castEntry.DeviceName = decode(val) 133 | case "md": 134 | castEntry.Device = decode(val) 135 | case "id": 136 | castEntry.UUID = val 137 | } 138 | } 139 | } 140 | castEntry.InfoFields = infoFields 141 | castDNSEntriesChan <- castEntry 142 | } 143 | } 144 | }() 145 | return castDNSEntriesChan, nil 146 | } 147 | 148 | // decode attempts to decode the passed in string using escaped utf8 bytes. 149 | // some DNS entries for other languages seem to include utf8 escape sequences as 150 | // part of the name. 151 | func decode(val string) string { 152 | if strings.Index(val, "\\") == -1 { 153 | return val 154 | } 155 | 156 | var ( 157 | r []rune 158 | toDecode []byte 159 | ) 160 | 161 | decodeRunes := func() { 162 | if len(toDecode) > 0 { 163 | for len(toDecode) > 0 { 164 | rr, size := utf8.DecodeRune(toDecode) 165 | r = append(r, rr) 166 | toDecode = toDecode[size:] 167 | } 168 | toDecode = []byte{} 169 | } 170 | } 171 | 172 | for i := 0; i < len(val); { 173 | if val[i] == '\\' { 174 | if i+3 < len(val) { 175 | v, err := strconv.Atoi(val[i+1 : i+4]) 176 | if err == nil { 177 | toDecode = append(toDecode, byte(v)) 178 | i += 4 179 | continue 180 | } 181 | } 182 | } 183 | decodeRunes() 184 | r = append(r, rune(val[i])) 185 | i++ 186 | } 187 | decodeRunes() 188 | return string(r) 189 | } 190 | -------------------------------------------------------------------------------- /dns/dns_test.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDecode(t *testing.T) { 8 | testCases := []struct { 9 | val string 10 | expected string 11 | }{ 12 | { 13 | val: "\\208\\161\\209\\130\\208\\176\\209\\129: \\208\\154\\208\\190\\208\\187\\208\\190\\208\\189\\208\\186\\208\\176", 14 | expected: "Стас: Колонка", 15 | }, 16 | { 17 | val: "\\208\\161\\209\\130\\208\\176\\209\\129: ABCDEF HIJ\\208\\154\\208\\190\\208\\187\\208\\190\\208\\189\\208\\186\\208\\176", 18 | expected: "Стас: ABCDEF HIJКолонка", 19 | }, 20 | { 21 | val: "contains no escape characters", 22 | expected: "contains no escape characters", 23 | }, 24 | { 25 | val: "contains some \\escape characters", 26 | expected: "contains some \\escape characters", 27 | }, 28 | { 29 | val: "contains some \\escape numbers: \\20", 30 | expected: "contains some \\escape numbers: \\20", 31 | }, 32 | } 33 | 34 | for _, tt := range testCases { 35 | decoded := decode(tt.val) 36 | if decoded != tt.expected { 37 | t.Errorf("decoded to %s, but expected %s", decoded, tt.expected) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /go-chromecast-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishen/go-chromecast/408c42e94c13548c0a65748b150891b35b626f72/go-chromecast-ui.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vishen/go-chromecast 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | cloud.google.com/go/texttospeech v1.10.0 7 | github.com/buger/jsonparser v1.1.1 8 | github.com/gogo/protobuf v1.3.2 9 | github.com/grandcat/zeroconf v1.0.0 10 | github.com/h2non/filetype v1.1.3 11 | github.com/jroimartin/gocui v0.5.0 12 | github.com/mitchellh/go-homedir v1.1.0 13 | github.com/pkg/errors v0.9.1 14 | github.com/rogpeppe/go-internal v1.13.1 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/spf13/cobra v1.8.1 17 | github.com/stretchr/testify v1.10.0 18 | golang.org/x/net v0.38.0 // indirect 19 | golang.org/x/sys v0.31.0 // indirect 20 | google.golang.org/api v0.209.0 21 | google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 22 | ) 23 | 24 | require github.com/seancfoley/ipaddress-go v1.7.0 25 | 26 | require ( 27 | github.com/rs/zerolog v1.33.0 28 | golang.org/x/sync v0.12.0 29 | gopkg.in/ini.v1 v1.67.0 30 | ) 31 | 32 | require ( 33 | cloud.google.com/go v0.116.0 // indirect 34 | cloud.google.com/go/auth v0.11.0 // indirect 35 | cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect 36 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 37 | cloud.google.com/go/longrunning v0.6.3 // indirect 38 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 39 | github.com/davecgh/go-spew v1.1.1 // indirect 40 | github.com/felixge/httpsnoop v1.0.4 // indirect 41 | github.com/go-logr/logr v1.4.2 // indirect 42 | github.com/go-logr/stdr v1.2.2 // indirect 43 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 44 | github.com/google/s2a-go v0.1.8 // indirect 45 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 46 | github.com/googleapis/gax-go/v2 v2.14.0 // indirect 47 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 48 | github.com/kr/pretty v0.1.0 // indirect 49 | github.com/mattn/go-colorable v0.1.13 // indirect 50 | github.com/mattn/go-isatty v0.0.20 // indirect 51 | github.com/mattn/go-runewidth v0.0.16 // indirect 52 | github.com/miekg/dns v1.1.62 // indirect 53 | github.com/nsf/termbox-go v1.1.1 // indirect 54 | github.com/pmezard/go-difflib v1.0.0 // indirect 55 | github.com/rivo/uniseg v0.4.7 // indirect 56 | github.com/seancfoley/bintree v1.3.1 // indirect 57 | github.com/spf13/pflag v1.0.5 // indirect 58 | github.com/stretchr/objx v0.5.2 // indirect 59 | go.opencensus.io v0.24.0 // indirect 60 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.57.0 // indirect 61 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect 62 | go.opentelemetry.io/otel v1.32.0 // indirect 63 | go.opentelemetry.io/otel/metric v1.32.0 // indirect 64 | go.opentelemetry.io/otel/trace v1.32.0 // indirect 65 | golang.org/x/crypto v0.36.0 // indirect 66 | golang.org/x/mod v0.22.0 // indirect 67 | golang.org/x/oauth2 v0.24.0 // indirect 68 | golang.org/x/text v0.23.0 // indirect 69 | golang.org/x/time v0.8.0 // indirect 70 | golang.org/x/tools v0.27.0 // indirect 71 | google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect 72 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect 73 | google.golang.org/grpc v1.68.0 // indirect 74 | google.golang.org/protobuf v1.35.2 // indirect 75 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 76 | gopkg.in/yaml.v3 v3.0.1 // indirect 77 | ) 78 | -------------------------------------------------------------------------------- /http/handlers.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "golang.org/x/sync/errgroup" 15 | 16 | log "github.com/sirupsen/logrus" 17 | "github.com/vishen/go-chromecast/application" 18 | "github.com/vishen/go-chromecast/dns" 19 | ) 20 | 21 | type Handler struct { 22 | mu sync.Mutex 23 | apps map[string]application.App 24 | mux *http.ServeMux 25 | 26 | verbose bool 27 | 28 | autoconnectPeriod time.Duration 29 | autoconnectTicker *time.Ticker 30 | 31 | // autoupdatePeriodSec defines how frequently app.Update method is called in the background. 32 | autoupdatePeriod time.Duration 33 | autoupdateTicker *time.Ticker 34 | } 35 | 36 | func NewHandler(verbose bool) *Handler { 37 | handler := &Handler{ 38 | verbose: verbose, 39 | apps: map[string]application.App{}, 40 | mux: http.NewServeMux(), 41 | mu: sync.Mutex{}, 42 | 43 | autoconnectPeriod: time.Duration(-1), 44 | autoconnectTicker: nil, 45 | 46 | autoupdatePeriod: time.Duration(-1), 47 | autoupdateTicker: nil, 48 | } 49 | handler.registerHandlers() 50 | return handler 51 | } 52 | 53 | // AutoConnect configures the handler to perform periodic auto-discovery of all the cast devices & groups. 54 | // It's intended to be called just after `NewHandler()`, before the handler is registered in the server. 55 | func (h *Handler) AutoConnect(period time.Duration) error { 56 | // Setting the autoconnect property - to allow (in future) periodic refresh of the connections. 57 | h.autoconnectPeriod = period 58 | if err := h.connectAllInternal("", "3"); err != nil { 59 | return err 60 | } 61 | if h.autoconnectPeriod > 0 { 62 | h.autoconnectTicker = time.NewTicker(period) 63 | go func() { 64 | for { 65 | <-h.autoconnectTicker.C 66 | if err := h.connectAllInternal("", "3"); err != nil { 67 | log.Printf("AutoConnect issued connectAllInternal failed: %v", err) 68 | } 69 | } 70 | }() 71 | } 72 | return nil 73 | } 74 | 75 | // AutoUpdate configures the handler to perform auto-update of all the cast devices & groups. 76 | // It's intended to be called just after `NewHandler()`, before the handler is registered in the server. 77 | // Thanks to AutoUpdate, /status and /statuses returns relatively recent status 'instantly'. 78 | func (h *Handler) AutoUpdate(period time.Duration) error { 79 | // Setting the autoconnect property - to allow (in future) periodic refresh of the connections. 80 | h.autoupdatePeriod = period 81 | if h.autoupdatePeriod > 0 { 82 | h.autoupdateTicker = time.NewTicker(period) 83 | go func() { 84 | for { 85 | <-h.autoupdateTicker.C 86 | if err := h.UpdateAll(); err != nil { 87 | log.Printf("AutoUpdate issued UpdateAll failed: %v", err) 88 | } 89 | } 90 | }() 91 | } 92 | return nil 93 | } 94 | 95 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 96 | h.mux.ServeHTTP(w, r) 97 | } 98 | 99 | func (h *Handler) Serve(addr string) error { 100 | log.Printf("starting http server on %s", addr) 101 | return http.ListenAndServe(addr, h) 102 | } 103 | 104 | func (h *Handler) registerHandlers() { 105 | /* 106 | GET /devices?wait=...&iface=... 107 | POST /connect?uuid=&addr=&port= 108 | POST /connect-all?wait=...&iface=... 109 | POST /disconnect?uuid= 110 | POST /disconnect-all 111 | GET /status?uuid= 112 | GET /statuses (Deprecated use status-all) 113 | GET /status-all 114 | POST /pause?uuid= 115 | POST /unpause?uuid= 116 | POST /skipad?uuid= 117 | POST /mute?uuid= 118 | POST /unmute?uuid= 119 | POST /stop?uuid= 120 | GET /volume?uuid= 121 | POST /volume?uuid=&volume= 122 | POST /rewind?uuid=&seconds= 123 | POST /seek?uuid=&seconds= 124 | POST /seek-to?uuid=&seconds= 125 | POST /load?uuid=&path=&content_type=&start_time= 126 | */ 127 | 128 | h.mux.HandleFunc("/devices", h.listDevices) 129 | h.mux.HandleFunc("/connect", h.connect) 130 | h.mux.HandleFunc("/connect-all", h.connectAll) 131 | h.mux.HandleFunc("/disconnect", h.disconnect) 132 | h.mux.HandleFunc("/disconnect-all", h.disconnectAll) 133 | h.mux.HandleFunc("/status", h.status) 134 | h.mux.HandleFunc("/status-all", h.statusAll) 135 | h.mux.HandleFunc("/statuses", h.statuses) 136 | h.mux.HandleFunc("/pause", h.pause) 137 | h.mux.HandleFunc("/unpause", h.unpause) 138 | h.mux.HandleFunc("/skipad", h.skipad) 139 | h.mux.HandleFunc("/mute", h.mute) 140 | h.mux.HandleFunc("/unmute", h.unmute) 141 | h.mux.HandleFunc("/stop", h.stop) 142 | h.mux.HandleFunc("/volume", h.volume) 143 | h.mux.HandleFunc("/rewind", h.rewind) 144 | h.mux.HandleFunc("/seek", h.seek) 145 | h.mux.HandleFunc("/seek-to", h.seekTo) 146 | h.mux.HandleFunc("/load", h.load) 147 | } 148 | 149 | func (h *Handler) discoverDnsEntries(ctx context.Context, iface string, waitq string) (devices []device) { 150 | wait := 3 151 | if n, err := strconv.Atoi(waitq); err == nil { 152 | wait = n 153 | } 154 | 155 | devices = []device{} 156 | var interf *net.Interface 157 | if iface != "" { 158 | var err error 159 | interf, err = net.InterfaceByName(iface) 160 | if err != nil { 161 | h.log("error discovering entries: %v", err) 162 | return 163 | } 164 | } 165 | 166 | ctx, cancel := context.WithTimeout(ctx, time.Duration(wait)*time.Second) 167 | defer cancel() 168 | 169 | devicesChan, err := dns.DiscoverCastDNSEntries(ctx, interf) 170 | if err != nil { 171 | h.log("error discovering entries: %v", err) 172 | return 173 | } 174 | 175 | for d := range devicesChan { 176 | devices = append(devices, device{ 177 | Addr: d.AddrV4.String(), 178 | Port: d.Port, 179 | Name: d.Name, 180 | Host: d.Host, 181 | UUID: d.UUID, 182 | Device: d.Device, 183 | Status: d.Status, 184 | DeviceName: d.DeviceName, 185 | InfoFields: d.InfoFields, 186 | }) 187 | } 188 | 189 | return 190 | } 191 | 192 | func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) { 193 | h.log("listing chromecast devices") 194 | 195 | q := r.URL.Query() 196 | iface := q.Get("interface") 197 | wait := q.Get("wait") 198 | 199 | devices := h.discoverDnsEntries(context.Background(), iface, wait) 200 | h.log("found %d devices", len(devices)) 201 | 202 | w.Header().Add("Content-Type", "application/json") 203 | if err := json.NewEncoder(w).Encode(devices); err != nil { 204 | h.log("error encoding json: %v", err) 205 | httpError(w, fmt.Errorf("unable to json encode devices: %v", err)) 206 | return 207 | } 208 | 209 | } 210 | 211 | func (h *Handler) app(uuid string) (application.App, bool) { 212 | h.mu.Lock() 213 | defer h.mu.Unlock() 214 | 215 | app, ok := h.apps[uuid] 216 | return app, ok 217 | } 218 | 219 | func (h *Handler) ConnectedDeviceUUIDs() []string { 220 | h.mu.Lock() 221 | defer h.mu.Unlock() 222 | 223 | var uuids []string 224 | for k, _ := range h.apps { 225 | uuids = append(uuids, k) 226 | } 227 | return uuids 228 | } 229 | 230 | func (h *Handler) connect(w http.ResponseWriter, r *http.Request) { 231 | q := r.URL.Query() 232 | 233 | deviceUUID := q.Get("uuid") 234 | if deviceUUID == "" { 235 | httpValidationError(w, "missing 'uuid' in query paramater") 236 | return 237 | } 238 | 239 | _, ok := h.app(deviceUUID) 240 | if ok { 241 | httpValidationError(w, "device uuid is already connected") 242 | return 243 | } 244 | 245 | deviceAddr := q.Get("addr") 246 | devicePort := q.Get("port") 247 | deviceName := q.Get("name") 248 | iface := q.Get("interface") 249 | wait := q.Get("wait") 250 | 251 | if deviceAddr == "" || devicePort == "" || (deviceName == "" && devicePort != "8009") { 252 | h.log("device addr and/or port are missing, trying to lookup address for uuid %q", deviceUUID) 253 | 254 | devices := h.discoverDnsEntries(context.Background(), iface, wait) 255 | for _, device := range devices { 256 | // TODO: Should there be a lookup by name as well? 257 | if device.UUID == deviceUUID { 258 | deviceAddr = device.Addr 259 | // TODO: This is an unnessecary conversion since 260 | // we cast back to int a bit later. 261 | devicePort = strconv.Itoa(device.Port) 262 | deviceName = device.DeviceName 263 | } 264 | } 265 | } 266 | 267 | if deviceAddr == "" || devicePort == "" { 268 | httpValidationError(w, "'port' and 'addr' missing from query params and uuid device lookup returned no results") 269 | return 270 | } 271 | 272 | h.log("connecting to addr=%s port=%s...", deviceAddr, devicePort) 273 | 274 | devicePortI, err := strconv.Atoi(devicePort) 275 | if err != nil { 276 | h.log("device port %q is not a number: %v", devicePort, err) 277 | httpValidationError(w, "'port' is not a number") 278 | return 279 | } 280 | 281 | app, err := h.connectInternal(deviceAddr, devicePortI, deviceName) 282 | if err != nil { 283 | h.log("unable to start application: %v", err) 284 | httpError(w, fmt.Errorf("unable to start application: %v", err)) 285 | return 286 | } 287 | h.mu.Lock() 288 | h.apps[deviceUUID] = app 289 | h.mu.Unlock() 290 | 291 | w.Header().Add("Content-Type", "application/json") 292 | if err := json.NewEncoder(w).Encode(connectResponse{DeviceUUID: deviceUUID}); err != nil { 293 | h.log("error encoding json: %v", err) 294 | httpError(w, fmt.Errorf("unable to json encode devices: %v", err)) 295 | return 296 | } 297 | } 298 | 299 | func (h *Handler) connectInternal(deviceAddr string, devicePort int, deviceName string) (application.App, error) { 300 | applicationOptions := []application.ApplicationOption{ 301 | application.WithDebug(h.verbose), 302 | application.WithCacheDisabled(true), 303 | } 304 | if deviceName != "" { 305 | applicationOptions = append(applicationOptions, application.WithDeviceNameOverride(deviceName)) 306 | } 307 | 308 | app := application.NewApplication(applicationOptions...) 309 | if err := app.Start(deviceAddr, devicePort); err != nil { 310 | return nil, err 311 | } 312 | return app, nil 313 | } 314 | 315 | func (h *Handler) connectAll(w http.ResponseWriter, r *http.Request) { 316 | h.log("connecting all devices") 317 | q := r.URL.Query() 318 | 319 | wait := q.Get("wait") 320 | iface := q.Get("interface") 321 | 322 | err := h.connectAllInternal(iface, wait) 323 | if err != nil { 324 | h.log("error connecting: %v", err) 325 | httpError(w, fmt.Errorf("unable to connect to device: %v", err)) 326 | } 327 | 328 | var resp []connectResponse 329 | for _, deviceUUID := range h.ConnectedDeviceUUIDs() { 330 | resp = append(resp, connectResponse{DeviceUUID: deviceUUID}) 331 | } 332 | 333 | w.Header().Add("Content-Type", "application/json") 334 | if err := json.NewEncoder(w).Encode(resp); err != nil { 335 | h.log("error encoding json: %v", err) 336 | httpError(w, fmt.Errorf("unable to json encode devices: %v", err)) 337 | return 338 | } 339 | } 340 | 341 | func (h *Handler) connectAllInternal(iface string, waitSec string) error { 342 | ctx := context.Background() 343 | devices := h.discoverDnsEntries(ctx, iface, waitSec) 344 | apps := make(chan *application.App, len(devices)+1) 345 | g, ctx := errgroup.WithContext(ctx) 346 | for _, device := range devices { 347 | g.Go(func() error { 348 | log.Printf("Connecting to %s:%d (%s)", device.Addr, device.Port, device.DeviceName) 349 | app, err := h.connectInternal(device.Addr, device.Port, device.DeviceName) 350 | if err != nil { 351 | log.Printf("Connection to %s:%d (%s) failed: %v", device.Addr, device.Port, device.DeviceName, err) 352 | return err 353 | } 354 | log.Printf("Connected to %s:%d (%s)", device.Addr, device.Port, device.DeviceName) 355 | apps <- &app 356 | return nil 357 | }) 358 | } 359 | err := g.Wait() 360 | log.Printf("Post wait status: %v", err) 361 | close(apps) 362 | 363 | // Even if we cannot connect to some of the devices, we still update the map for remaining devices. 364 | uuidMap := map[string]application.App{} 365 | for app := range apps { 366 | info, err := (*app).Info() 367 | if err != nil { 368 | log.Printf("Skipping device %v", app) 369 | } else { 370 | uuidMap[strings.ReplaceAll(info.SsdpUdn, "-", "")] = *app 371 | } 372 | } 373 | 374 | h.mu.Lock() 375 | h.apps = uuidMap 376 | h.mu.Unlock() 377 | return err 378 | } 379 | 380 | func (h *Handler) disconnect(w http.ResponseWriter, r *http.Request) { 381 | q := r.URL.Query() 382 | 383 | deviceUUID := q.Get("uuid") 384 | if deviceUUID == "" { 385 | httpValidationError(w, "missing 'uuid' in query paramater") 386 | return 387 | } 388 | 389 | h.log("disconnecting device %s", deviceUUID) 390 | 391 | app, ok := h.app(deviceUUID) 392 | if !ok { 393 | httpValidationError(w, "device uuid is not connected") 394 | return 395 | } 396 | 397 | stopMedia := q.Get("stop") == "true" 398 | if err := app.Close(stopMedia); err != nil { 399 | h.log("unable to close application: %v", err) 400 | } 401 | 402 | h.mu.Lock() 403 | delete(h.apps, deviceUUID) 404 | h.mu.Unlock() 405 | } 406 | 407 | func (h *Handler) disconnectAll(w http.ResponseWriter, r *http.Request) { 408 | h.log("disconnecting all devices") 409 | h.mu.Lock() 410 | stopMedia := r.URL.Query().Get("stop") == "true" 411 | for deviceUUID, app := range h.apps { 412 | if err := app.Close(stopMedia); err != nil { 413 | h.log("unable to close application %q: %v", deviceUUID, err) 414 | } 415 | delete(h.apps, deviceUUID) 416 | } 417 | h.mu.Unlock() 418 | } 419 | 420 | func (h *Handler) status(w http.ResponseWriter, r *http.Request) { 421 | app, found := h.appForRequest(w, r) 422 | if !found { 423 | return 424 | } 425 | h.log("status for device") 426 | syncUpdate := r.URL.Query().Get("syncUpdate") == "true" 427 | if syncUpdate { 428 | if err := app.Update(); err != nil { 429 | h.log("error updating status: %v", err) 430 | httpError(w, fmt.Errorf("error updating status: %w", err)) 431 | return 432 | } 433 | } 434 | castApplication, castMedia, castVolume := app.Status() 435 | info, err := app.Info() 436 | if err != nil { 437 | werr := fmt.Errorf("error getting device info: %v", err) 438 | h.log("%v", werr) 439 | httpError(w, werr) 440 | return 441 | } 442 | statusResponse := fromApplicationStatus( 443 | info, 444 | castApplication, 445 | castMedia, 446 | castVolume, 447 | ) 448 | 449 | w.Header().Add("Content-Type", "application/json") 450 | if err := json.NewEncoder(w).Encode(statusResponse); err != nil { 451 | h.log("error encoding json: %v", err) 452 | httpError(w, fmt.Errorf("unable to json encode devices: %v", err)) 453 | return 454 | } 455 | } 456 | 457 | func (h *Handler) UpdateAll() error { 458 | uuids := h.ConnectedDeviceUUIDs() 459 | g := new(errgroup.Group) 460 | for _, deviceUUID := range uuids { 461 | app, ok := h.app(deviceUUID) 462 | if ok { 463 | g.Go(func() error { return app.Update() }) 464 | } 465 | } 466 | return g.Wait() 467 | } 468 | 469 | // Deprecated: Use statusAll instead 470 | func (h *Handler) statuses(w http.ResponseWriter, r *http.Request) { 471 | dep_message := "/statuses is deprecated, use /status-all instead" 472 | log.Warn(dep_message) 473 | 474 | w.Header().Add("Deprecated", dep_message) 475 | 476 | h.statusAll(w, r) 477 | } 478 | 479 | func (h *Handler) statusAll(w http.ResponseWriter, r *http.Request) { 480 | h.log("statuses for devices") 481 | syncUpdate := r.URL.Query().Get("syncUpdate") == "true" 482 | uuids := h.ConnectedDeviceUUIDs() 483 | mapUUID2Ch := map[string]chan statusResponse{} 484 | g := new(errgroup.Group) 485 | for _, deviceUUID := range uuids { 486 | app, ok := h.app(deviceUUID) 487 | if ok { 488 | ch := make(chan statusResponse, 1) 489 | mapUUID2Ch[deviceUUID] = ch 490 | g.Go(func() error { 491 | if syncUpdate { 492 | if err := app.Update(); err != nil { 493 | return err 494 | } 495 | } 496 | castApplication, castMedia, castVolume := app.Status() 497 | info, err := app.Info() 498 | if err != nil { 499 | return fmt.Errorf("error getting device info: %v", err) 500 | } 501 | ch <- fromApplicationStatus( 502 | info, 503 | castApplication, 504 | castMedia, 505 | castVolume, 506 | ) 507 | return nil 508 | }) 509 | } 510 | } 511 | if err := g.Wait(); err != nil { 512 | h.log("collecting statuses failed: %v", err) 513 | httpError(w, fmt.Errorf("collecting statuses failed: %w", err)) 514 | return 515 | } 516 | 517 | statusResponses := map[string]statusResponse{} 518 | for deviceUUID, ch := range mapUUID2Ch { 519 | statusResponse := <-ch 520 | statusResponses[deviceUUID] = statusResponse 521 | } 522 | 523 | w.Header().Add("Content-Type", "application/json") 524 | if err := json.NewEncoder(w).Encode(statusResponses); err != nil { 525 | h.log("error encoding json: %v", err) 526 | httpError(w, fmt.Errorf("unable to json encode statuses: %v", err)) 527 | return 528 | } 529 | } 530 | 531 | func (h *Handler) pause(w http.ResponseWriter, r *http.Request) { 532 | app, found := h.appForRequest(w, r) 533 | if !found { 534 | return 535 | } 536 | h.log("pausing device") 537 | 538 | if err := app.Pause(); err != nil { 539 | h.log("unable to pause device: %v", err) 540 | httpError(w, fmt.Errorf("unable to pause device: %w", err)) 541 | return 542 | } 543 | } 544 | 545 | func (h *Handler) unpause(w http.ResponseWriter, r *http.Request) { 546 | app, found := h.appForRequest(w, r) 547 | if !found { 548 | return 549 | } 550 | h.log("unpausing device") 551 | 552 | if err := app.Unpause(); err != nil { 553 | h.log("unable to unpause device: %v", err) 554 | httpError(w, fmt.Errorf("unable to unpause device: %w", err)) 555 | return 556 | } 557 | } 558 | 559 | func (h *Handler) skipad(w http.ResponseWriter, r *http.Request) { 560 | app, found := h.appForRequest(w, r) 561 | if !found { 562 | h.log("handlers.go.skipad: !found for skipad") 563 | return 564 | } 565 | 566 | h.log("skipping ad") 567 | 568 | if err := app.Skipad(); err != nil { 569 | h.log("unable to skip ad for: %v", err) 570 | httpError(w, fmt.Errorf("unable to skip ad for: %w", err)) 571 | return 572 | } 573 | } 574 | 575 | func (h *Handler) mute(w http.ResponseWriter, r *http.Request) { 576 | app, found := h.appForRequest(w, r) 577 | if !found { 578 | return 579 | } 580 | h.log("muting device") 581 | 582 | if err := app.SetMuted(true); err != nil { 583 | h.log("unable to mute device: %v", err) 584 | httpError(w, fmt.Errorf("unable to mute device: %w", err)) 585 | return 586 | } 587 | } 588 | 589 | func (h *Handler) unmute(w http.ResponseWriter, r *http.Request) { 590 | app, found := h.appForRequest(w, r) 591 | if !found { 592 | return 593 | } 594 | h.log("unmuting device") 595 | 596 | if err := app.SetMuted(false); err != nil { 597 | h.log("unable to unmute device: %v", err) 598 | httpError(w, fmt.Errorf("unable to unmute device: %w", err)) 599 | return 600 | } 601 | } 602 | 603 | func (h *Handler) stop(w http.ResponseWriter, r *http.Request) { 604 | app, found := h.appForRequest(w, r) 605 | if !found { 606 | return 607 | } 608 | h.log("stopping device") 609 | 610 | if err := app.Stop(); err != nil { 611 | h.log("unable to stop device: %v", err) 612 | httpError(w, fmt.Errorf("unable to stop device: %w", err)) 613 | return 614 | } 615 | } 616 | 617 | func (h *Handler) volume(w http.ResponseWriter, r *http.Request) { 618 | app, found := h.appForRequest(w, r) 619 | if !found { 620 | return 621 | } 622 | 623 | if r.Method == "GET" { 624 | h.log("getting volume for device") 625 | _, _, volume := app.Status() 626 | 627 | w.Header().Add("Content-Type", "application/json") 628 | if err := json.NewEncoder(w).Encode(volumeResponse{Level: volume.Level, Muted: volume.Muted}); err != nil { 629 | h.log("error encoding json: %v", err) 630 | httpError(w, fmt.Errorf("unable to json encode devices: %v", err)) 631 | } 632 | return 633 | } 634 | 635 | h.log("setting volume for device") 636 | 637 | q := r.URL.Query() 638 | volume := q.Get("volume") 639 | if volume == "" { 640 | httpValidationError(w, "missing 'volume' in query paramater") 641 | return 642 | } 643 | 644 | value, err := strconv.ParseFloat(volume, 32) 645 | if err != nil { 646 | h.log("volume %q is not a number: %v", volume, err) 647 | httpValidationError(w, "'volume' is not a number") 648 | return 649 | } 650 | 651 | if err := app.SetVolume(float32(value)); err != nil { 652 | h.log("unable to set device volume: %v", err) 653 | httpError(w, fmt.Errorf("unable to set device volume: %w", err)) 654 | return 655 | } 656 | } 657 | 658 | func (h *Handler) rewind(w http.ResponseWriter, r *http.Request) { 659 | app, found := h.appForRequest(w, r) 660 | if !found { 661 | return 662 | } 663 | 664 | h.log("rewinding device") 665 | 666 | q := r.URL.Query() 667 | seconds := q.Get("seconds") 668 | if seconds == "" { 669 | httpValidationError(w, "missing 'seconds' in query parameter") 670 | return 671 | } 672 | 673 | value, err := strconv.Atoi(seconds) 674 | if err != nil { 675 | h.log("seconds %q is not a number: %v", seconds, err) 676 | httpValidationError(w, "'seconds' is not a number") 677 | return 678 | } 679 | 680 | if err := app.Seek(-value); err != nil { 681 | h.log("unable to rewind device: %v", err) 682 | httpError(w, fmt.Errorf("unable to rewind device: %w", err)) 683 | return 684 | } 685 | } 686 | 687 | func (h *Handler) seek(w http.ResponseWriter, r *http.Request) { 688 | app, found := h.appForRequest(w, r) 689 | if !found { 690 | return 691 | } 692 | 693 | h.log("seeking device") 694 | 695 | q := r.URL.Query() 696 | seconds := q.Get("seconds") 697 | if seconds == "" { 698 | httpValidationError(w, "missing 'seconds' in query parameter") 699 | return 700 | } 701 | 702 | value, err := strconv.Atoi(seconds) 703 | if err != nil { 704 | h.log("seconds %q is not a number: %v", seconds, err) 705 | httpValidationError(w, "'seconds' is not a number") 706 | return 707 | } 708 | 709 | if err := app.Seek(value); err != nil { 710 | h.log("unable to seek device: %v", err) 711 | httpError(w, fmt.Errorf("unable to seek device: %w", err)) 712 | return 713 | } 714 | } 715 | 716 | func (h *Handler) seekTo(w http.ResponseWriter, r *http.Request) { 717 | app, found := h.appForRequest(w, r) 718 | if !found { 719 | return 720 | } 721 | 722 | h.log("seeking-to device") 723 | 724 | q := r.URL.Query() 725 | seconds := q.Get("seconds") 726 | if seconds == "" { 727 | httpValidationError(w, "missing 'seconds' in query paramater") 728 | return 729 | } 730 | 731 | value, err := strconv.ParseFloat(seconds, 32) 732 | if err != nil { 733 | h.log("seconds %q is not a number: %v", seconds, err) 734 | httpValidationError(w, "'seconds' is not a number") 735 | return 736 | } 737 | 738 | if err := app.SeekToTime(float32(value)); err != nil { 739 | h.log("unable to seek-to device: %v", err) 740 | httpError(w, fmt.Errorf("unable to seek-to device: %w", err)) 741 | return 742 | } 743 | } 744 | 745 | func (h *Handler) load(w http.ResponseWriter, r *http.Request) { 746 | app, found := h.appForRequest(w, r) 747 | if !found { 748 | return 749 | } 750 | 751 | h.log("loading media for device") 752 | 753 | q := r.URL.Query() 754 | path := q.Get("path") 755 | if path == "" { 756 | httpValidationError(w, "missing 'path' in query paramater") 757 | return 758 | } 759 | 760 | contentType := q.Get("content_type") 761 | 762 | startTime := q.Get("start_time") 763 | if startTime == "" { 764 | startTime = "0" 765 | } 766 | 767 | startTimeInt, err := strconv.Atoi(startTime) 768 | 769 | if err != nil { 770 | h.log("start_time %q is not a number: %v", startTimeInt, err) 771 | httpValidationError(w, "'start_time' is not a number") 772 | return 773 | } 774 | 775 | if err := app.Load(path, startTimeInt, contentType, true, true, true); err != nil { 776 | h.log("unable to load media for device: %v", err) 777 | httpError(w, fmt.Errorf("unable to load media for device: %w", err)) 778 | return 779 | } 780 | } 781 | 782 | func (h *Handler) appForRequest(w http.ResponseWriter, r *http.Request) (application.App, bool) { 783 | q := r.URL.Query() 784 | 785 | deviceUUID := q.Get("uuid") 786 | if deviceUUID == "" { 787 | httpValidationError(w, "missing 'uuid' in query params") 788 | return nil, false 789 | } 790 | 791 | app, ok := h.app(deviceUUID) 792 | if !ok { 793 | httpValidationError(w, "device uuid is not connected") 794 | return nil, false 795 | } 796 | 797 | if err := app.Update(); err != nil { 798 | return nil, false 799 | } 800 | 801 | return app, true 802 | } 803 | 804 | func (h *Handler) log(msg string, args ...interface{}) { 805 | if h.verbose { 806 | log.Printf(msg, args...) 807 | } 808 | } 809 | 810 | func httpError(w http.ResponseWriter, err error) { 811 | w.Header().Add("Content-Type", "text/plain") 812 | http.Error(w, err.Error(), http.StatusInternalServerError) 813 | } 814 | 815 | func httpValidationError(w http.ResponseWriter, msg string) { 816 | http.Error(w, msg, http.StatusBadRequest) 817 | } 818 | -------------------------------------------------------------------------------- /http/types.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "github.com/vishen/go-chromecast/cast" 4 | 5 | type connectResponse struct { 6 | DeviceUUID string `json:"device_uuid"` 7 | } 8 | 9 | type volumeResponse struct { 10 | Level float32 `json:"level"` 11 | Muted bool `json:"muted"` 12 | } 13 | 14 | type statusResponse struct { 15 | Info *cast.DeviceInfo `json:"info,omitempty"` 16 | App *cast.Application `json:"app,omitempty"` 17 | Media *cast.Media `json:"media,omitempty"` 18 | Volume *cast.Volume `json:"volume,omitempty"` 19 | } 20 | 21 | func fromApplicationStatus(info *cast.DeviceInfo, app *cast.Application, media *cast.Media, volume *cast.Volume) statusResponse { 22 | status := statusResponse{} 23 | 24 | if info != nil { 25 | status.Info = info 26 | } 27 | 28 | if app != nil { 29 | status.App = app 30 | } 31 | 32 | if media != nil { 33 | status.Media = media 34 | } 35 | 36 | if volume != nil { 37 | status.Volume = volume 38 | } 39 | 40 | return status 41 | } 42 | 43 | type device struct { 44 | Addr string `json:"addr"` 45 | Port int `json:"port"` 46 | 47 | Name string `json:"name"` 48 | Host string `json:"host"` 49 | 50 | UUID string `json:"uuid"` 51 | Device string `json:"device_type"` 52 | Status string `json:"status"` 53 | DeviceName string `json:"device_name"` 54 | InfoFields map[string]string `json:"info_fields"` 55 | } 56 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Jonathan Pentecost 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "os" 19 | 20 | "github.com/vishen/go-chromecast/cmd" 21 | ) 22 | 23 | var ( 24 | // These are build-time variables that get set by goreleaser. 25 | version = "dev" 26 | commit = "master" 27 | date = "" 28 | ) 29 | 30 | func main() { 31 | os.Exit(main1()) 32 | } 33 | 34 | func main1() int { 35 | return cmd.Execute(version, commit, date) 36 | } 37 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/rogpeppe/go-internal/testscript" 8 | ) 9 | 10 | func TestMain(m *testing.M) { 11 | os.Exit(testscript.RunMain(m, map[string]func() int{ 12 | "go-chromecast": main1, 13 | })) 14 | } 15 | 16 | func TestCommands(t *testing.T) { 17 | testscript.Run(t, testscript.Params{ 18 | Dir: "testdata", 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /playlists/playlist_test.go: -------------------------------------------------------------------------------- 1 | package playlists 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestParsePLSFormat(t *testing.T) { 10 | var wantUrls = []struct { 11 | url string 12 | title string 13 | }{ 14 | {"https://ice4.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#1): New and classic favorite indie pop tracks."}, 15 | {"https://ice2.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#2): New and classic favorite indie pop tracks."}, 16 | {"https://ice1.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#3): New and classic favorite indie pop tracks."}, 17 | {"https://ice6.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#4): New and classic favorite indie pop tracks."}, 18 | {"https://ice5.somafm.com/indiepop-64-aac", "SomaFM: Indie Pop Rocks! (#5): New and classic favorite indie pop tracks."}, 19 | } 20 | var path string 21 | if abs, err := filepath.Abs(filepath.Join("testdata", "indiepop64.pls")); err != nil { 22 | t.Fatal(err) 23 | } else { 24 | path = fmt.Sprintf("file://%v", abs) 25 | } 26 | it, err := newPLSIterator(path) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | for i, want := range wantUrls { 31 | if !it.HasNext() { 32 | t.Fatal("iterator exhausted") 33 | } 34 | url, title := it.Next() 35 | if url != want.url { 36 | t.Fatalf("test %d, have url %v want %v", i, url, want.url) 37 | } 38 | if title != want.title { 39 | t.Fatalf("test %d, have title %v want %v", i, title, want.title) 40 | } 41 | } 42 | if it.HasNext() { 43 | t.Fatal("expected exhausted iterator") 44 | } 45 | } 46 | 47 | func TestParseM3UFormat(t *testing.T) { 48 | var wantUrls = []struct { 49 | url string 50 | title string 51 | }{ 52 | {"http://ice1.somafm.com/indiepop-128-aac", ""}, 53 | {"http://ice4.somafm.com/indiepop-128-aac", ""}, 54 | {"http://ice2.somafm.com/indiepop-128-aac", ""}, 55 | {"http://ice6.somafm.com/indiepop-128-aac", ""}, 56 | {"http://ice5.somafm.com/indiepop-128-aac", ""}, 57 | } 58 | var path string 59 | if abs, err := filepath.Abs(filepath.Join("testdata", "indiepop130.m3u")); err != nil { 60 | t.Fatal(err) 61 | } else { 62 | path = fmt.Sprintf("file://%v", abs) 63 | } 64 | it, err := newM3UIterator(path) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | for i, want := range wantUrls { 69 | if !it.HasNext() { 70 | t.Fatal("iterator exhausted") 71 | } 72 | url, title := it.Next() 73 | if url != want.url { 74 | t.Fatalf("test %d, have url %v want %v", i, url, want.url) 75 | } 76 | if title != want.title { 77 | t.Fatalf("test %d, have title %v want %v", i, title, want.title) 78 | } 79 | } 80 | if it.HasNext() { 81 | t.Fatal("expected exhausted iterator") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /playlists/playlists.go: -------------------------------------------------------------------------------- 1 | package playlists 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/ini.v1" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | type Iterator interface { 11 | HasNext() bool 12 | Next() (file, title string) 13 | } 14 | 15 | func IsPlaylist(path string) bool { 16 | ext := strings.ToLower(filepath.Ext(path)) 17 | return ext == ".m3u" || ext == ".pls" 18 | } 19 | 20 | // NewPlaylistIterator creates an iterator for the given playlist. 21 | func NewIterator(uri string) (Iterator, error) { 22 | ext := strings.ToLower(filepath.Ext(uri)) 23 | switch ext { 24 | case ".pls": 25 | return newPLSIterator(uri) 26 | case ".m3u": 27 | return newM3UIterator(uri) 28 | } 29 | return nil, fmt.Errorf("'%v' is not a recognized playlist format", ext) 30 | } 31 | 32 | // plsIterator is an iterator for playlist-files. 33 | // According to https://en.wikipedia.org/wiki/PLS_(file_format), 34 | // The format is case-sensitive and essentially that of an INI file. 35 | // It has entries on the form File1, Title1 etc. 36 | type plsIterator struct { 37 | count int 38 | playlist *ini.Section 39 | } 40 | 41 | func newPLSIterator(uri string) (*plsIterator, error) { 42 | content, err := FetchResource(uri) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to read file %v: %w", uri, err) 45 | } 46 | pls, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, content) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to parse file %v: %w", uri, err) 49 | } 50 | section, err := pls.GetSection("playlist") 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to find playlist in .pls-file %v", uri) 53 | } 54 | return &plsIterator{ 55 | playlist: section, 56 | }, nil 57 | } 58 | 59 | func (it *plsIterator) HasNext() bool { 60 | return it.playlist.HasKey(fmt.Sprintf("File%d", it.count+1)) 61 | } 62 | 63 | func (it *plsIterator) Next() (file, title string) { 64 | if val := it.playlist.Key(fmt.Sprintf("File%d", it.count+1)); val != nil { 65 | file = val.Value() 66 | } 67 | if val := it.playlist.Key(fmt.Sprintf("Title%d", it.count+1)); val != nil { 68 | title = val.Value() 69 | } 70 | it.count = it.count + 1 71 | return file, title 72 | } 73 | 74 | // m3uIterator is an iterator for m3u-files. 75 | // https://docs.fileformat.com/audio/m3u/: 76 | // 77 | // There is no official specification for the M3U file format, it is a de-facto standard. 78 | // M3U is a plain text file that uses the .m3u extension if the text is encoded 79 | // in the local system’s default non-Unicode encoding or with the .m3u8 extension 80 | // if the text is UTF-8 encoded. Each entry in the M3U file can be one of the following: 81 | // 82 | // - Absolute path to the file 83 | // - File path relative to the M3U file. 84 | // - URL 85 | // 86 | // In the extended M3U, additional directives are introduced that begin 87 | // with “#” and end with a colon(:) if they have parameters 88 | type m3uIterator struct { 89 | index int 90 | lines []string 91 | } 92 | 93 | func newM3UIterator(uri string) (*m3uIterator, error) { 94 | content, err := FetchResource(uri) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to read file %v: %w", uri, err) 97 | } 98 | var lines []string 99 | // convert windows linebreaks, and split 100 | for _, l := range strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") { 101 | // This is a very simple m3u decoder, ignores all extended info 102 | l = strings.TrimSpace(l) 103 | if len(l) > 0 && !strings.HasPrefix(l, "#") { 104 | lines = append(lines, l) 105 | } 106 | } 107 | return &m3uIterator{ 108 | index: 0, 109 | lines: lines, 110 | }, nil 111 | } 112 | 113 | func (it *m3uIterator) HasNext() bool { 114 | return it.index < len(it.lines) 115 | } 116 | 117 | func (it *m3uIterator) Next() (file, title string) { 118 | file = it.lines[it.index] 119 | title = "" // Todo? 120 | it.index++ 121 | return file, title 122 | } 123 | -------------------------------------------------------------------------------- /playlists/testdata/indiepop130.m3u: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXTINF:-1,SomaFM - Indie Pop Rocks! 3 | http://ice1.somafm.com/indiepop-128-aac 4 | #EXTINF:-1,SomaFM - Indie Pop Rocks! 5 | http://ice4.somafm.com/indiepop-128-aac 6 | #EXTINF:-1,SomaFM - Indie Pop Rocks! 7 | http://ice2.somafm.com/indiepop-128-aac 8 | #EXTINF:-1,SomaFM - Indie Pop Rocks! 9 | http://ice6.somafm.com/indiepop-128-aac 10 | #EXTINF:-1,SomaFM - Indie Pop Rocks! 11 | http://ice5.somafm.com/indiepop-128-aac 12 | 13 | 14 | -------------------------------------------------------------------------------- /playlists/testdata/indiepop64.pls: -------------------------------------------------------------------------------- 1 | [playlist] 2 | numberofentries=5 3 | File1=https://ice4.somafm.com/indiepop-64-aac 4 | Title1=SomaFM: Indie Pop Rocks! (#1): New and classic favorite indie pop tracks. 5 | Length1=-1 6 | File2=https://ice2.somafm.com/indiepop-64-aac 7 | Title2=SomaFM: Indie Pop Rocks! (#2): New and classic favorite indie pop tracks. 8 | Length2=-1 9 | File3=https://ice1.somafm.com/indiepop-64-aac 10 | Title3=SomaFM: Indie Pop Rocks! (#3): New and classic favorite indie pop tracks. 11 | Length3=-1 12 | File4=https://ice6.somafm.com/indiepop-64-aac 13 | Title4=SomaFM: Indie Pop Rocks! (#4): New and classic favorite indie pop tracks. 14 | Length4=-1 15 | File5=https://ice5.somafm.com/indiepop-64-aac 16 | Title5=SomaFM: Indie Pop Rocks! (#5): New and classic favorite indie pop tracks. 17 | Length5=-1 18 | Version=2 19 | 20 | -------------------------------------------------------------------------------- /playlists/utlis.go: -------------------------------------------------------------------------------- 1 | package playlists 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | // FetchResource fetches the given url and returns the response body. The url can either 11 | // be an HTTP url or a file:// url. 12 | func FetchResource(url string) ([]byte, error) { 13 | if filep := strings.TrimPrefix(url, "file://"); filep != url { 14 | return os.ReadFile(filep) 15 | } 16 | res, err := http.Get(url) 17 | if err != nil { 18 | return nil, err 19 | } 20 | defer res.Body.Close() 21 | body, err := io.ReadAll(res.Body) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return body, nil 26 | } 27 | -------------------------------------------------------------------------------- /regenerate_mocks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | cd ./cast 5 | go run github.com/vektra/mockery/v2@v2.49.1 --all 6 | 7 | cd ../application 8 | go run github.com/vektra/mockery/v2@v2.49.1 --all -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | 8 | homedir "github.com/mitchellh/go-homedir" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | var possibleCachePaths = []string{ 13 | ".config/gochromecast", 14 | ".gochromecast", 15 | } 16 | 17 | type Storage struct { 18 | cache map[string][]byte 19 | cacheFilename string 20 | } 21 | 22 | func NewStorage() *Storage { 23 | return &Storage{cache: map[string][]byte{}} 24 | } 25 | 26 | func (s *Storage) lazyLoadCacheDir() error { 27 | if s.cacheFilename != "" { 28 | return nil 29 | } 30 | 31 | homeDir, err := homedir.Dir() 32 | if err != nil { 33 | return errors.Wrap(err, "unable to find homedir") 34 | } 35 | for _, p := range possibleCachePaths { 36 | filename := homeDir + "/" + p 37 | 38 | // Check if file exists, if so then load it 39 | if _, err := os.Stat(filename); err == nil { 40 | s.cacheFilename = filename 41 | if fileContents, err := ioutil.ReadFile(filename); err == nil { 42 | if err := json.Unmarshal(fileContents, &s.cache); err == nil { 43 | return nil 44 | } 45 | } 46 | } 47 | 48 | // Attempt to create file. 49 | if _, err := os.Create(filename); err == nil { 50 | s.cacheFilename = filename 51 | return nil 52 | } 53 | 54 | } 55 | return errors.New("unable to create cache file") 56 | } 57 | 58 | func (s *Storage) Save(key string, data []byte) error { 59 | if err := s.lazyLoadCacheDir(); err != nil { 60 | return err 61 | } 62 | 63 | s.cache[key] = data 64 | 65 | cacheJson, _ := json.Marshal(s.cache) 66 | ioutil.WriteFile(s.cacheFilename, cacheJson, 0644) 67 | 68 | return nil 69 | } 70 | 71 | func (s *Storage) Load(key string) ([]byte, error) { 72 | if err := s.lazyLoadCacheDir(); err != nil { 73 | return nil, err 74 | } 75 | return s.cache[key], nil 76 | } 77 | -------------------------------------------------------------------------------- /testdata/hello.txt: -------------------------------------------------------------------------------- 1 | # hello world 2 | exec cat hello.text 3 | stdout 'hello world\n' 4 | ! stderr . 5 | 6 | -- hello.text -- 7 | hello world 8 | -------------------------------------------------------------------------------- /testdata/indiepop64.pls: -------------------------------------------------------------------------------- 1 | [playlist] 2 | numberofentries=5 3 | File1=https://ice4.somafm.com/indiepop-64-aac 4 | Title1=SomaFM: Indie Pop Rocks! (#1): New and classic favorite indie pop tracks. 5 | Length1=-1 6 | File2=https://ice2.somafm.com/indiepop-64-aac 7 | Title2=SomaFM: Indie Pop Rocks! (#2): New and classic favorite indie pop tracks. 8 | Length2=-1 9 | File3=https://ice1.somafm.com/indiepop-64-aac 10 | Title3=SomaFM: Indie Pop Rocks! (#3): New and classic favorite indie pop tracks. 11 | Length3=-1 12 | File4=https://ice6.somafm.com/indiepop-64-aac 13 | Title4=SomaFM: Indie Pop Rocks! (#4): New and classic favorite indie pop tracks. 14 | Length4=-1 15 | File5=https://ice5.somafm.com/indiepop-64-aac 16 | Title5=SomaFM: Indie Pop Rocks! (#5): New and classic favorite indie pop tracks. 17 | Length5=-1 18 | Version=2 19 | 20 | -------------------------------------------------------------------------------- /testdata/version.txt: -------------------------------------------------------------------------------- 1 | # Version 2 | go-chromecast --version 3 | stdout 'go-chromecast dev \(master\)' 4 | ! stderr . 5 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | UI 2 | -- 3 | * Get the application metadata to update from whatever the chosen chromecast is doing (not just the first thing it was doing) 4 | * Include a playlist view 5 | * Incorporate a file-browser (which could use the playlist view) 6 | * Allow hiding / unhiding of the log view 7 | -------------------------------------------------------------------------------- /tts/tts.go: -------------------------------------------------------------------------------- 1 | package tts 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | 9 | texttospeech "cloud.google.com/go/texttospeech/apiv1" 10 | "google.golang.org/api/option" 11 | texttospeechpb "google.golang.org/genproto/googleapis/cloud/texttospeech/v1" 12 | ) 13 | 14 | const ( 15 | timeout = time.Second * 10 16 | ) 17 | 18 | func Create(sentence string, serviceAccountKey []byte, languageCode string, voiceName string, speakingRate float32, pitch float32, ssml bool) ([]byte, error) { 19 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 20 | defer cancel() 21 | 22 | client, err := texttospeech.NewClient(ctx, option.WithCredentialsJSON(serviceAccountKey)) 23 | if err != nil { 24 | return nil, errors.Wrap(err, "unable to create texttospeech client") 25 | } 26 | 27 | input := texttospeechpb.SynthesisInput{} 28 | if ssml { 29 | input.InputSource = &texttospeechpb.SynthesisInput_Ssml{Ssml: sentence} 30 | } else { 31 | input.InputSource = &texttospeechpb.SynthesisInput_Text{Text: sentence} 32 | } 33 | 34 | req := texttospeechpb.SynthesizeSpeechRequest{ 35 | Input: &input, 36 | Voice: &texttospeechpb.VoiceSelectionParams{ 37 | LanguageCode: languageCode, 38 | Name: voiceName, 39 | SsmlGender: texttospeechpb.SsmlVoiceGender_NEUTRAL, 40 | }, 41 | AudioConfig: &texttospeechpb.AudioConfig{ 42 | AudioEncoding: texttospeechpb.AudioEncoding_MP3, 43 | SpeakingRate: float64(speakingRate), 44 | Pitch: float64(pitch), 45 | }, 46 | } 47 | 48 | resp, err := client.SynthesizeSpeech(ctx, &req) 49 | if err != nil { 50 | return nil, errors.Wrap(err, "unable to synthesize speech") 51 | } 52 | return resp.AudioContent, nil 53 | } 54 | -------------------------------------------------------------------------------- /ui/key_bindings.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/vishen/go-chromecast/application" 5 | 6 | "github.com/jroimartin/gocui" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // setupKeyBindings binds keys to actions: 11 | func (ui *UserInterface) setupKeyBindings() { 12 | ui.gui.SetKeybinding("", 'q', gocui.ModNone, ui.Stop) 13 | ui.gui.SetKeybinding("", 's', gocui.ModNone, ui.stopMedia) 14 | ui.gui.SetKeybinding("", 'a', gocui.ModNone, ui.skipAd) 15 | ui.gui.SetKeybinding("", gocui.KeySpace, gocui.ModNone, ui.playPause) 16 | ui.gui.SetKeybinding("", gocui.KeyArrowLeft, gocui.ModNone, ui.seekBackwards) 17 | ui.gui.SetKeybinding("", gocui.KeyArrowRight, gocui.ModNone, ui.seekForwards) 18 | ui.gui.SetKeybinding("", '=', gocui.ModNone, ui.volumeUp) 19 | ui.gui.SetKeybinding("", '+', gocui.ModNone, ui.volumeUp) 20 | ui.gui.SetKeybinding("", '-', gocui.ModNone, ui.volumeDown) 21 | ui.gui.SetKeybinding("", 'm', gocui.ModNone, ui.volumeMute) 22 | ui.gui.SetKeybinding("", gocui.KeyPgup, gocui.ModNone, ui.previousMedia) 23 | ui.gui.SetKeybinding("", gocui.KeyPgdn, gocui.ModNone, ui.nextMedia) 24 | } 25 | 26 | // playPause tells the app to play / pause: 27 | func (ui *UserInterface) playPause(g *gocui.Gui, v *gocui.View) error { 28 | if ui.paused { 29 | log.Info("Play") 30 | ui.app.Unpause() 31 | ui.paused = false 32 | } else { 33 | log.Info("Pause") 34 | ui.app.Pause() 35 | ui.paused = true 36 | } 37 | 38 | return nil 39 | } 40 | 41 | // skipAd tells the app to skip ad: 42 | func (ui *UserInterface) skipAd(g *gocui.Gui, v *gocui.View) error { 43 | log.Info("Skip Ad") 44 | ui.app.Skipad() 45 | return nil 46 | } 47 | 48 | // seekBackwards tells the app to rewind: 49 | func (ui *UserInterface) seekBackwards(g *gocui.Gui, v *gocui.View) error { 50 | err := ui.app.Seek(ui.seekRewind) 51 | if err != nil { 52 | switch err { 53 | case application.ErrMediaNotYetInitialised: 54 | log.Warn("Rewind (nothing playing)") 55 | return nil 56 | default: 57 | log.WithError(err).Error("Rewind") 58 | return nil 59 | } 60 | } 61 | 62 | log.WithField("seconds", ui.seekRewind).Info("Rewind") 63 | return nil 64 | } 65 | 66 | // seekForwards tells the app to fastforward: 67 | func (ui *UserInterface) seekForwards(g *gocui.Gui, v *gocui.View) error { 68 | err := ui.app.Seek(ui.seekFastforward) 69 | if err != nil { 70 | switch err { 71 | case application.ErrMediaNotYetInitialised: 72 | log.Warn("Fastforward (nothing playing)") 73 | return nil 74 | default: 75 | log.WithError(err).Error("Fastforward") 76 | return nil 77 | } 78 | } 79 | 80 | log.WithField("seconds", ui.seekFastforward).Info("Fastforward") 81 | return nil 82 | } 83 | 84 | // volumeUp increases the volume: 85 | func (ui *UserInterface) volumeUp(g *gocui.Gui, v *gocui.View) error { 86 | ui.volumeMutex.Lock() 87 | defer ui.volumeMutex.Unlock() 88 | 89 | // Attempt to increment our version of the volume: 90 | if ui.volume+5 > 100 { 91 | log.Warn("Volume already at maximum") 92 | return nil 93 | } 94 | ui.volume += 5 95 | 96 | floatVolume := float32(ui.volume) / 100 97 | 98 | err := ui.app.SetVolume(floatVolume) 99 | if err != nil { 100 | switch err { 101 | case application.ErrVolumeOutOfRange: 102 | log.WithError(err).WithField("volume", floatVolume).Warn("Volume up") 103 | return nil 104 | default: 105 | log.WithError(err).WithField("volume", floatVolume).Error("Volume up") 106 | return nil 107 | } 108 | } 109 | 110 | log.WithField("volume", floatVolume).Info("Volume up") 111 | return nil 112 | } 113 | 114 | // volumeDown decreases the volume: 115 | func (ui *UserInterface) volumeDown(g *gocui.Gui, v *gocui.View) error { 116 | ui.volumeMutex.Lock() 117 | defer ui.volumeMutex.Unlock() 118 | 119 | // Attempt to decrement our version of the volume: 120 | if ui.volume-5 < 0 { 121 | log.Warn("Volume already at minimum") 122 | return nil 123 | } 124 | ui.volume -= 5 125 | 126 | floatVolume := float32(ui.volume) / 100 127 | 128 | err := ui.app.SetVolume(floatVolume) 129 | if err != nil { 130 | switch err { 131 | case application.ErrVolumeOutOfRange: 132 | log.WithError(err).WithField("volume", floatVolume).Warn("Volume down") 133 | return nil 134 | default: 135 | log.WithError(err).WithField("volume", floatVolume).Error("Volume down") 136 | return nil 137 | } 138 | } 139 | 140 | log.WithField("volume", floatVolume).Info("Volume down") 141 | return nil 142 | } 143 | 144 | // volumeMute mutes the volume: 145 | func (ui *UserInterface) volumeMute(g *gocui.Gui, v *gocui.View) error { 146 | if ui.muted { 147 | ui.muted = false 148 | } else { 149 | ui.muted = true 150 | } 151 | 152 | err := ui.app.SetMuted(ui.muted) 153 | if err != nil { 154 | log.WithError(err).WithField("muted", ui.muted).Error("Volume mute") 155 | return nil 156 | } 157 | 158 | if ui.muted { 159 | log.Info("Volume muted") 160 | } else { 161 | log.Info("Volume unmuted") 162 | } 163 | return nil 164 | } 165 | 166 | // stopMedia halts playback: 167 | func (ui *UserInterface) stopMedia(g *gocui.Gui, v *gocui.View) error { 168 | err := ui.app.StopMedia() 169 | if err != nil { 170 | switch err { 171 | case application.ErrNoMediaStop: 172 | log.Warn("Stop (nothing playing)") 173 | return nil 174 | default: 175 | log.WithError(err).Error("Stop") 176 | return nil 177 | } 178 | } 179 | 180 | log.Info("Stop") 181 | return nil 182 | } 183 | 184 | // nextMedia starts playing the next item in the playlist: 185 | func (ui *UserInterface) nextMedia(g *gocui.Gui, v *gocui.View) error { 186 | err := ui.app.Next() 187 | if err != nil { 188 | switch err { 189 | case application.ErrNoMediaNext: 190 | log.WithError(err).Warn("Next") 191 | return nil 192 | default: 193 | log.WithError(err).Error("Next") 194 | return nil 195 | } 196 | } 197 | 198 | log.Info("Next") 199 | return nil 200 | } 201 | 202 | // previousMedia starts playing the previous item in the playlist: 203 | func (ui *UserInterface) previousMedia(g *gocui.Gui, v *gocui.View) error { 204 | err := ui.app.Previous() 205 | if err != nil { 206 | switch err { 207 | case application.ErrNoMediaPrevious: 208 | log.WithError(err).Warn("Previous") 209 | return nil 210 | default: 211 | log.WithError(err).Error("Previous") 212 | return nil 213 | } 214 | } 215 | 216 | log.Info("Previous") 217 | return nil 218 | } 219 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/vishen/go-chromecast/application" 8 | 9 | "github.com/jroimartin/gocui" 10 | 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // UserInterface is an alternaive way of running go-chromecast (based around a gocui GUI): 15 | type UserInterface struct { 16 | app application.App 17 | displayName string 18 | gui *gocui.Gui 19 | media string 20 | muted bool 21 | paused bool 22 | positionCurrent float32 23 | positionTotal float32 24 | seekFastforward int 25 | seekRewind int 26 | volume int 27 | volumeMutex sync.Mutex 28 | wg sync.WaitGroup 29 | } 30 | 31 | // NewUserInterface returns a new user-interface loaded with everything we need: 32 | func NewUserInterface(app application.App) (*UserInterface, error) { 33 | 34 | // Use a GUI from gocui to handle the user-interface: 35 | g, err := gocui.NewGui(gocui.OutputNormal) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | // Make a new UserInterface with this GUI: 41 | newUserInterface := &UserInterface{ 42 | app: app, 43 | displayName: "connecting", 44 | gui: g, 45 | seekFastforward: 15, 46 | seekRewind: -15, 47 | volume: 0, 48 | } 49 | 50 | // Tell the GUI about its "Manager" function (defines the gocui "views"): 51 | g.SetManagerFunc(newUserInterface.views) 52 | 53 | // Setup key-bindings: 54 | newUserInterface.setupKeyBindings() 55 | 56 | return newUserInterface, nil 57 | } 58 | 59 | // Run tells the UserInterface to start: 60 | func (ui *UserInterface) Run() error { 61 | defer ui.gui.Close() 62 | 63 | // Run the main gocui loop in the background (because it blocks): 64 | ui.wg.Add(1) 65 | go func() { 66 | if err := ui.gui.MainLoop(); err != nil && err != gocui.ErrQuit { 67 | log.WithError(err).Error("Error from gocui") 68 | } 69 | ui.wg.Done() 70 | }() 71 | 72 | // Update the status from the application: 73 | go ui.updateStatus(time.Second) 74 | 75 | // Wait for the main gocui loop to end: 76 | ui.wg.Wait() 77 | return nil 78 | } 79 | 80 | // Stop tells the UserInterface to stop: 81 | func (ui *UserInterface) Stop(g *gocui.Gui, v *gocui.View) error { 82 | return gocui.ErrQuit 83 | } 84 | -------------------------------------------------------------------------------- /ui/update_status.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/jroimartin/gocui" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // updateStatus polls the chromecast application for status info, and updates the UI: 12 | func (ui *UserInterface) updateStatus(sleepTime time.Duration) { 13 | 14 | for { 15 | // Sleep before updating: 16 | time.Sleep(sleepTime) 17 | 18 | // Get the "status" view: 19 | viewStatus, err := ui.gui.View(viewNameStatus) 20 | if err != nil { 21 | log.WithError(err).Errorf("Unable to get gocui view (%s)", viewNameStatus) 22 | continue 23 | } 24 | 25 | // Get the "progress" view: 26 | viewProgress, err := ui.gui.View(viewNameProgress) 27 | if err != nil { 28 | log.WithError(err).Errorf("Unable to get gocui view (%s)", viewNameProgress) 29 | continue 30 | } 31 | 32 | // Get the "volume" view: 33 | viewVolume, err := ui.gui.View(viewNameVolume) 34 | if err != nil { 35 | log.WithError(err).Errorf("Unable to get gocui view (%s)", viewNameVolume) 36 | continue 37 | } 38 | 39 | // Update the "status" view: 40 | ui.gui.Update(func(*gocui.Gui) error { return nil }) 41 | if err := ui.app.Update(); err != nil { 42 | log.WithError(err).Debug("Unable to update the app") 43 | } 44 | viewStatus.Clear() 45 | castApplication, castMedia, castVolume := ui.app.Status() 46 | 47 | // Update the displayName: 48 | if castApplication == nil { 49 | ui.displayName = "Idle" 50 | } else { 51 | ui.displayName = castApplication.DisplayName 52 | } 53 | 54 | // Update the media info: 55 | if castMedia != nil { 56 | var media string 57 | if castMedia.Media.Metadata.Artist != "" { 58 | media = fmt.Sprintf("%s - ", castMedia.Media.Metadata.Artist) 59 | } 60 | if castMedia.Media.Metadata.Title != "" { 61 | media += fmt.Sprintf("%s ", castMedia.Media.Metadata.Title) 62 | } 63 | if castMedia.Media.ContentId != "" { 64 | media += fmt.Sprintf("[%s] ", castMedia.Media.ContentId) 65 | } 66 | ui.media = media 67 | } else { 68 | ui.media = "unknown" 69 | } 70 | fmt.Fprintf(viewStatus, "%sMedia: %s%s%s\n", normalTextColour, boldTextColour, ui.media, resetTextColour) 71 | 72 | if castApplication != nil { 73 | fmt.Fprintf(viewStatus, "%sDetail: %s%s%s\n", normalTextColour, boldTextColour, castApplication.StatusText, resetTextColour) 74 | } 75 | 76 | // Update the player status: 77 | if castMedia != nil { 78 | ui.paused = castMedia.PlayerState == "PAUSED" 79 | } 80 | 81 | // Update the playback position: 82 | if castMedia != nil { 83 | ui.positionCurrent = castMedia.CurrentTime 84 | ui.positionTotal = castMedia.Media.Duration 85 | } else { 86 | ui.positionCurrent = 0 87 | ui.positionTotal = 0 88 | } 89 | 90 | // Update the "progress" view: 91 | if castMedia != nil { 92 | viewProgress.Clear() 93 | viewWidth, _ := viewProgress.Size() 94 | progress := (castMedia.CurrentTime / castMedia.Media.Duration) * float32(viewWidth) 95 | 96 | // Draw a bar of "#" to represent progress: 97 | for i := 0; i < int(progress); i++ { 98 | fmt.Fprintf(viewProgress, "%s#", progressColour) 99 | } 100 | } 101 | 102 | // Update the "volume" view: 103 | if castVolume != nil { 104 | ui.volume = int(castVolume.Level * 100) 105 | ui.muted = castVolume.Muted 106 | 107 | viewVolume.Clear() 108 | if ui.muted { 109 | fmt.Fprintf(viewVolume, "%s(muted)", volumeMutedColour) 110 | } else { 111 | for i := 0; i < ui.volume/5; i++ { 112 | fmt.Fprintf(viewVolume, "%s#", volumeColour) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /ui/view_keys.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jroimartin/gocui" 7 | ) 8 | 9 | const viewNameKeys = "Keys" 10 | 11 | // viewKeys renders the helper message for key-shortcuts: 12 | func (ui *UserInterface) viewKeys(g *gocui.Gui) error { 13 | maxX, maxY := g.Size() 14 | 15 | if v, err := g.SetView(viewNameKeys, 0, maxY-3, maxX-1, maxY-1); err != nil { 16 | if err != gocui.ErrUnknownView { 17 | return err 18 | } 19 | 20 | v.Title = viewNameKeys 21 | 22 | fmt.Fprintf(v, "%sQuit: %sq", normalTextColour, boldTextColour) 23 | fmt.Fprintf(v, "%s, Play/Pause: %sSPACE", normalTextColour, boldTextColour) 24 | fmt.Fprintf(v, "%s, Volume: %s-%s / %s+", normalTextColour, boldTextColour, normalTextColour, boldTextColour) 25 | fmt.Fprintf(v, "%s, Mute: %sm", normalTextColour, boldTextColour) 26 | fmt.Fprintf(v, "%s, Seek: %s←%s / %s→", normalTextColour, boldTextColour, normalTextColour, boldTextColour) 27 | fmt.Fprintf(v, "%s, Previous/Next: %sPgUp%s / %sPgDn", normalTextColour, boldTextColour, normalTextColour, boldTextColour) 28 | fmt.Fprintf(v, "%s, Stop: %ss", normalTextColour, boldTextColour) 29 | fmt.Fprintf(v, "%s, Skip Ad: %sa", normalTextColour, boldTextColour) 30 | fmt.Fprint(v, resetTextColour) 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /ui/view_log.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/jroimartin/gocui" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | const viewNameLog = "Log" 9 | 10 | // viewLog renders the log view: 11 | func (ui *UserInterface) viewLog(g *gocui.Gui) error { 12 | maxX, maxY := g.Size() 13 | 14 | v, err := g.SetView(viewNameLog, 0, 8, maxX-1, maxY-5) 15 | if err != nil && err != gocui.ErrUnknownView { 16 | return err 17 | } 18 | 19 | v.Title = viewNameLog 20 | v.Autoscroll = true 21 | 22 | // Tell the logger to use this view: 23 | log.SetOutput(v) 24 | log.SetLevel(log.DebugLevel) 25 | log.SetFormatter(&log.TextFormatter{ 26 | ForceColors: true, 27 | FullTimestamp: true, 28 | TimestampFormat: "15:04:05", 29 | }) 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /ui/view_progress.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jroimartin/gocui" 7 | ) 8 | 9 | const viewNameProgress = "Progress" 10 | 11 | // viewProgress renders the progress view: 12 | func (ui *UserInterface) viewProgress(g *gocui.Gui) error { 13 | maxX, _ := g.Size() 14 | 15 | v, err := g.SetView(viewNameProgress, 22, 4, maxX-1, 6) 16 | if err != nil && err != gocui.ErrUnknownView { 17 | return err 18 | } 19 | 20 | v.Title = fmt.Sprintf("%s (%0.2fs / %0.2fs)", viewNameProgress, ui.positionCurrent, ui.positionTotal) 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /ui/view_status.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jroimartin/gocui" 7 | ) 8 | 9 | const viewNameStatus = "Status" 10 | 11 | // viewStatus renders the status view (what the Chromecast is doing): 12 | func (ui *UserInterface) viewStatus(g *gocui.Gui) error { 13 | maxX, _ := g.Size() 14 | 15 | v, err := g.SetView(viewNameStatus, 0, 0, maxX-1, 3) 16 | if err != nil && err != gocui.ErrUnknownView { 17 | return err 18 | } 19 | 20 | v.Title = fmt.Sprintf("%s (%s)", viewNameStatus, ui.displayName) 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /ui/view_volume.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jroimartin/gocui" 7 | ) 8 | 9 | const viewNameVolume = "Volume" 10 | 11 | // viewVolume renders the volume view: 12 | func (ui *UserInterface) viewVolume(g *gocui.Gui) error { 13 | v, err := g.SetView(viewNameVolume, 0, 4, 21, 6) 14 | if err != nil && err != gocui.ErrUnknownView { 15 | return err 16 | } 17 | 18 | v.Title = fmt.Sprintf("%s (%d%%)", viewNameVolume, ui.volume) 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /ui/views.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/jroimartin/gocui" 5 | ) 6 | 7 | const ( 8 | boldTextColour = "\033[34;1m" 9 | normalTextColour = "\033[34;2m" 10 | resetTextColour = "\033[0m" 11 | volumeColour = "\033[31;2m" 12 | volumeMutedColour = "\033[31;1m" 13 | progressColour = "\033[33;2m" 14 | ) 15 | 16 | // views sets up all of the views: 17 | func (ui *UserInterface) views(g *gocui.Gui) error { 18 | ui.viewStatus(g) 19 | ui.viewVolume(g) 20 | ui.viewProgress(g) 21 | ui.viewLog(g) 22 | ui.viewKeys(g) 23 | return nil 24 | } 25 | --------------------------------------------------------------------------------