├── .gitignore ├── spec ├── spec_helper.cr └── castblock_spec.cr ├── contrib ├── castblock.service ├── docker-compose.yml └── PKGBUILD ├── .editorconfig ├── CHANGELOG.md ├── src ├── chromecast │ ├── errors.cr │ ├── device.cr │ └── watch_message.cr ├── sponsorblock.cr ├── castblock.cr ├── chromecast.cr └── blocker.cr ├── shard.lock ├── shard.yml ├── .github └── workflows │ └── ci.yml ├── Dockerfile ├── Makefile ├── Dockerfile.aarch64 ├── LICENSE ├── Dockerfile.arm └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/castblock" 3 | -------------------------------------------------------------------------------- /spec/castblock_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Castblock do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /contrib/castblock.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CastBlock Service 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/castblock 7 | 8 | [Install] 9 | WantedBy=multi-user.target 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0 2 | 3 | ## v0.1.1 4 | 5 | * do not crash on issue with DNS [#20](https://github.com/erdnaxeli/castblock/issues/20) 6 | 7 | ## v0.1.0 8 | 9 | Initial version, the changelog was not maintained yet :) -------------------------------------------------------------------------------- /src/chromecast/errors.cr: -------------------------------------------------------------------------------- 1 | class Castblock::Chromecast 2 | class Error < Exception 3 | end 4 | 5 | class HttpServerNotRunningError < Error 6 | end 7 | 8 | class CommandError < Error 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 1.6.3 6 | 7 | clip: 8 | git: https://github.com/erdnaxeli/clip.git 9 | version: 0.2.4 10 | 11 | -------------------------------------------------------------------------------- /contrib/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | castblock: 4 | network_mode: host 5 | image: erdnaxeli/castblock 6 | environment: 7 | - CATEGORIES=sponsor,selfpromo 8 | # Full list of categories https://wiki.sponsor.ajay.app/w/Types#Category 9 | -------------------------------------------------------------------------------- /src/chromecast/device.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | class Castblock::Chromecast 4 | struct Device 5 | include JSON::Serializable 6 | 7 | @[JSON::Field(key: "device_name")] 8 | getter name : String 9 | getter uuid : String 10 | 11 | def_equals_and_hash @uuid 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: castblock 2 | version: 0.1.1 3 | 4 | authors: 5 | - Alexandre Morignot 6 | 7 | targets: 8 | castblock: 9 | main: src/castblock.cr 10 | 11 | dependencies: 12 | clip: 13 | github: erdnaxeli/clip 14 | version: ~>0.2 15 | 16 | development_dependencies: 17 | ameba: 18 | github: crystal-ameba/ameba 19 | 20 | crystal: ">= 1.0.0, < 2" 21 | 22 | license: MIT 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | pull_request: 4 | branches: [master] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - name: Download source 10 | uses: actions/checkout@v2 11 | - name: Install Crystal 12 | uses: crystal-lang/install-crystal@v1 13 | - name: Install dependencies 14 | run: shards install 15 | - name: Run lint 16 | run: ./bin/ameba 17 | - name: Run format 18 | run: crystal tool format --check 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:1.5-alpine 2 | WORKDIR /src 3 | COPY shard.yml shard.lock /src/ 4 | RUN shards install --production 5 | COPY src /src/src 6 | RUN shards build --production --release --static 7 | 8 | FROM golang:alpine 9 | RUN go install github.com/vishen/go-chromecast@d2b4deefaef4c9f1a9859cb1334dbfaa9a1fbbb6 10 | 11 | FROM alpine 12 | RUN apk add tini 13 | COPY --from=0 /src/bin/castblock /usr/bin/castblock 14 | COPY --from=1 /go/bin/go-chromecast /usr/bin/go-chromecast 15 | ENTRYPOINT ["/sbin/tini", "--", "/usr/bin/castblock"] 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | docker-all: docker docker-arm docker-aarch64 2 | 3 | docker-arm: 4 | docker build --pull -t erdnaxeli/castblock:arm -f Dockerfile.arm . 5 | 6 | docker-aarch64: 7 | docker build --pull -t erdnaxeli/castblock:aarch64 -f Dockerfile.aarch64 . 8 | 9 | docker: 10 | docker build --pull -t erdnaxeli/castblock:amd64 . 11 | 12 | docker-push-all: 13 | docker push erdnaxeli/castblock:arm 14 | docker push erdnaxeli/castblock:aarch64 15 | docker push erdnaxeli/castblock:amd64 16 | 17 | docker-manifest: 18 | (test -d ${HOME}/.docker/manifests/docker.io_erdnaxeli_castblock-latest/ && rm -rv ${HOME}/.docker/manifests/docker.io_erdnaxeli_castblock-latest/) || true 19 | docker manifest create erdnaxeli/castblock:latest -a erdnaxeli/castblock:amd64 -a erdnaxeli/castblock:arm -a erdnaxeli/castblock:aarch64 20 | docker manifest push erdnaxeli/castblock:latest 21 | -------------------------------------------------------------------------------- /contrib/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Stephen Smith 2 | 3 | pkgname=castblock-git 4 | _pkgname=castblock 5 | _branch=main 6 | pkgver=55.91cfcd3 7 | pkgrel=1 8 | pkgdesc="CastBlock skips integrated youtube sponsors on all chromecasts on the network, crystal rewrite" 9 | arch=('any') 10 | url="https://github.com/erdnaxeli/castblock" 11 | license=('MIT') 12 | depends=('go-chromecast-git') 13 | makedepends=('git' 'crystal' 'shards') 14 | provides=('castblock') 15 | conflicts=('castblock') 16 | source=("$_pkgname::git+${url}.git#branch=$_branch") 17 | sha512sums=('SKIP') 18 | 19 | pkgver() { 20 | cd "$srcdir/$_pkgname" 21 | echo $(git rev-list --count "$_branch").$(git rev-parse --short "$_branch") 22 | } 23 | 24 | build() { 25 | cd "$srcdir/$_pkgname" 26 | shards build --release 27 | } 28 | 29 | package() { 30 | cd "$srcdir/$_pkgname" 31 | 32 | install -Dm755 ./bin/castblock "${pkgdir}/usr/bin/castblock" 33 | install -Dm644 contrib/castblock.service "$pkgdir"/usr/lib/systemd/system/castblock.service 34 | } 35 | -------------------------------------------------------------------------------- /Dockerfile.aarch64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/arm64/v8 alpine:3.16 AS crystal 2 | WORKDIR /src 3 | RUN echo '@edge http://dl-cdn.alpinelinux.org/alpine/edge/community' >>/etc/apk/repositories 4 | RUN apk add --update --no-cache --force-overwrite \ 5 | crystal \ 6 | make \ 7 | openssl-dev \ 8 | openssl-libs-static \ 9 | shards \ 10 | yaml-dev \ 11 | yaml-static \ 12 | zlib-dev \ 13 | zlib-static 14 | COPY shard.yml shard.lock /src/ 15 | RUN shards install --production 16 | COPY src /src/src 17 | RUN shards build --production --static --release 18 | 19 | FROM golang:alpine AS golang 20 | RUN apk add git 21 | RUN git clone https://github.com/vishen/go-chromecast.git && cd go-chromecast && git checkout d2b4deef 22 | ARG GOOS=linux 23 | ARG GOARCH=arm64 24 | RUN cd go-chromecast && go build -o /go/bin/go-chromecast 25 | 26 | FROM --platform=linux/arm64/v8 alpine:latest 27 | RUN apk add tini 28 | COPY --from=crystal /src/bin/castblock /usr/bin/castblock 29 | COPY --from=golang /go/bin/go-chromecast /usr/bin/go-chromecast 30 | ENTRYPOINT ["/sbin/tini", "--", "/usr/bin/castblock"] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Alexandre Morignot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/chromecast/watch_message.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | class Castblock::Chromecast 4 | struct WatchMessage 5 | include JSON::Serializable 6 | 7 | getter application : Application? 8 | getter media : Media? 9 | getter payload : String? 10 | property payload_data : WatchMessagePayload? 11 | end 12 | 13 | struct Application 14 | include JSON::Serializable 15 | 16 | @[JSON::Field(key: "displayName")] 17 | getter display_name : String 18 | end 19 | 20 | struct Media 21 | include JSON::Serializable 22 | 23 | struct Media 24 | include JSON::Serializable 25 | 26 | @[JSON::Field(key: "contentId")] 27 | getter content_id : String 28 | getter duration : Float64 29 | end 30 | 31 | @[JSON::Field(key: "playerState")] 32 | getter player_state : String 33 | @[JSON::Field(key: "currentTime")] 34 | getter current_time : Float64 35 | getter media : Media 36 | end 37 | 38 | struct WatchMessagePayload 39 | include JSON::Serializable 40 | 41 | struct PayloadStatus 42 | include JSON::Serializable 43 | 44 | struct PayloadVolume 45 | include JSON::Serializable 46 | 47 | getter muted : Bool 48 | end 49 | 50 | struct PayloadCustomData 51 | include JSON::Serializable 52 | 53 | @[JSON::Field(key: "playerState")] 54 | getter player_state : Int32 55 | end 56 | 57 | getter volume : PayloadVolume 58 | @[JSON::Field(key: "customData")] 59 | getter custom_data : PayloadCustomData 60 | end 61 | 62 | getter status : Array(PayloadStatus) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /Dockerfile.arm: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:1.5-alpine 2 | WORKDIR /src 3 | COPY shard.yml shard.lock /src/ 4 | RUN shards install --production 5 | COPY src /src/src 6 | RUN crystal build --release --cross-compile --target armv6k-unknown-linux-gnueabihf src/castblock.cr 7 | 8 | FROM --platform=linux/arm/v7 debian:bullseye-slim AS crystal 9 | WORKDIR /src 10 | RUN apt-get update 11 | RUN apt-get install -y \ 12 | g++ \ 13 | libevent-dev \ 14 | libgc-dev \ 15 | libpcre3-dev \ 16 | libssl-dev \ 17 | libxml2-dev \ 18 | llvm-dev \ 19 | zlib1g-dev 20 | COPY --from=0 /src/castblock.o . 21 | RUN cc castblock.o -o castblock -rdynamic -L/usr/bin/../lib/crystal -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lpcre -lm -lgc -lpthread -levent -lrt -ldl 22 | 23 | FROM golang:buster AS golang 24 | RUN git clone https://github.com/vishen/go-chromecast.git && cd go-chromecast && git checkout d2b4deef 25 | ARG GOOS=linux 26 | ARG GOARCH=arm 27 | RUN cd go-chromecast && go build -o /go/bin/go-chromecast 28 | 29 | FROM --platform=linux/arm/v7 debian:bullseye-slim 30 | RUN apt-get update && apt-get install -y \ 31 | ca-certificates \ 32 | libgc1 \ 33 | libevent-2.1-7 \ 34 | libssl1.1 \ 35 | tini \ 36 | && c_rehash # see https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=923479 37 | COPY --from=crystal /src/castblock /usr/bin/castblock 38 | COPY --from=golang /go/bin/go-chromecast /usr/bin/go-chromecast 39 | ENTRYPOINT ["/usr/bin/tini", "--", "/usr/bin/castblock"] 40 | -------------------------------------------------------------------------------- /src/sponsorblock.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "uri" 3 | 4 | class Castblock::Sponsorblock 5 | class Error < Exception 6 | end 7 | 8 | class CategoryError < Error 9 | end 10 | 11 | Log = Castblock::Log.for(self) 12 | 13 | struct Segment 14 | include JSON::Serializable 15 | 16 | getter category : String 17 | getter segment : Tuple(Float64, Float64) 18 | end 19 | 20 | @cache = Hash(String, Array(Segment)?).new 21 | 22 | def initialize(hostname : String, tls : Bool, @categories : Set(String)) 23 | @client = HTTP::Client.new(hostname, tls: tls) 24 | 25 | if !@categories.subset_of?(Set{"sponsor", "intro", "outro", "selfpromo", "interaction", "music_offtopic", "preview"}) 26 | Log.fatal { "Invalid categories #{@categories.join(", ")}. Available categories are sponsor, intro, outro, selfpromo, interaction, music_offtopic or preview." } 27 | raise CategoryError.new 28 | end 29 | end 30 | 31 | def get_segments(content_id : String) : Array(Segment)? 32 | if @cache.has_key?(content_id) 33 | @cache[content_id] 34 | else 35 | segments = get_segments_internal(content_id) 36 | 37 | if !segments.nil? 38 | save(content_id, segments) 39 | end 40 | 41 | segments 42 | end 43 | end 44 | 45 | private def get_segments_internal(content_id : String) : Array(Segment)? 46 | params = URI::Params.encode({ 47 | "categories" => @categories.to_json, 48 | "videoID" => content_id, 49 | }) 50 | response = get("/api/skipSegments?" + params) 51 | 52 | if response.status.success? 53 | begin 54 | Array(Segment).from_json(response.body) 55 | rescue e : JSON::ParseException 56 | Log.error &.emit("Error when reading JSON from Sponsorblock", error: e.to_s, json: response.body) 57 | nil 58 | end 59 | elsif response.status_code == 404 60 | Array(Segment).new 61 | else 62 | Log.error &.emit("Error from Sponsorblock", status_code: response.status_code, video_id: content_id) 63 | nil 64 | end 65 | end 66 | 67 | private def save(content_id : String, segments : Array(Segment)) : Nil 68 | @cache[content_id] = segments 69 | 70 | if @cache.size > 20 71 | @cache.shift 72 | end 73 | end 74 | 75 | private def get(path : String, retries = 3) : HTTP::Client::Response 76 | begin 77 | response = @client.get(path) 78 | rescue ex : Socket::Addrinfo::Error 79 | Log.warn &.emit("DNS error", error: ex.to_s) 80 | raise Error.new(cause: ex) 81 | end 82 | 83 | if response.status.server_error? 84 | 3.times do 85 | Log.warn &.emit("Received an error from Sponsorblock, retrying in 1s", status_code: response.status_code) 86 | sleep 1.second 87 | 88 | response = @client.get(path) 89 | break if !response.status.server_error? 90 | end 91 | end 92 | 93 | response 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CastBlock 2 | 3 | Skip sponsor segments and ads in YouTube videos playing on a Chromecast. 4 | 5 | This project is inspired by [CastBlock by stephen304](https://github.com/stephen304/castblock-legacy). 6 | It was rewritten in Crystal and uses the HTTP API exposed by [go-chromecast](https://github.com/vishen/go-chromecast) to be less CPU intensive. 7 | 8 | The impact of CastBlock on the CPU should be almost zero, and only a few dozen of Mo on the memory. 9 | 10 | ## Installation 11 | 12 | ### Docker 13 | 14 | ``` 15 | docker pull erdnaxeli/castblock:latest 16 | # Run CastBlock in foreground. Add -d after "run" to run it in background. 17 | docker run --rm --network host erdnaxeli/castblock 18 | ``` 19 | 20 | The docker image supports amd64, arm and arm64 architectures. 21 | In particular it should run on all raspberry pi. 22 | If not, please open an issue :) 23 | 24 | The amd64 and arm64 images are based on Alpine and weigh only 20Mo, but due to [a missing cross compilation target](https://github.com/crystal-lang/crystal/issues/5467) the arm images use Debian and weights 47Mo. 25 | 26 | ### Docker Compose 27 | 28 | ``` 29 | version: '3.3' 30 | services: 31 | castblock: 32 | network_mode: host 33 | image: erdnaxeli/castblock 34 | environment: 35 | # Full list of categories https://wiki.sponsor.ajay.app/w/Types#Category 36 | - CATEGORIES=sponsor,selfpromo 37 | # - DEBUG=false # Optional 38 | # - OFFSET=2 # Optional 39 | # - MUTE_ADS=true # Optional 40 | # - SKIP_ADS=true # Optional 41 | # - MERGE_THRESHOLD=2 # Optional 42 | ``` 43 | 44 | Most environment variables are optional as can be seen, more info on what they do can be found in the [available options section](#available-options). 45 | 46 | ### From source 47 | 48 | You need to install [go-chromecast](https://github.com/vishen/go-chromecast) first, and to make it available in your PATH. 49 | Then you need a working Crystal environment and run `shards build --release`. 50 | The binary is in `./bin/castblock`. 51 | 52 | ## Usage 53 | 54 | Run CastBlock in the same network as the Chromecast. 55 | 56 | It will detect all Chromecast, watch their activity and skip any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API. 57 | 58 | New devices are detected every 30s. 59 | Segments shorter that 5s cannot be skipped. The last 20 videos' segments are cached to limit the number on queries on SponsorBlock. 60 | 61 | If you have any issue, please run CastBlock with the `--debug` flag, try to reproduce your problem and past the output in the issue. 62 | You can use the flag with docker too like this: `docker run --rm --network host erdnaxeli/castblock --debug`. 63 | 64 | ### Available options 65 | 66 | * `--debug`: run the app with the debug logs. 67 | * `--sponsorblock-server`: specify the SponsorBlock server to use. Useful if you host one yourself. 68 | * `--offset`: set an offset to use before the end of the segment, in seconds. 69 | An offset of 2 means that it will seek 2s before the end of the segmend. 70 | * `--category`: specify the [category of segments](https://github.com/ajayyy/SponsorBlock/wiki/Types#category) to skip. 71 | It can be repeated to specify many categories. 72 | Default to "sponsor". 73 | * `--merge-threshold`: The maximum number of seconds between segments to be merged. 74 | Adjust this value to skip multiple adjacent segments that don't overlap. 75 | * `--mute-ads`: enable auto mute during native YouTube ads. These are different 76 | from in-video sponsors, and are typically blocked by browser extension ad blockers. 77 | * `--skip-ads`: enable auto skip during native YouTube ads. These are different 78 | from in-video sponsors, and are typically blocked by browser extension ad blockers. 79 | 80 | All options can also be read from the environment variables: 81 | 82 | * `DEBUG=true` 83 | * `SPONSORBLOCK_SERVER=https://sponsor.ajay.app` 84 | * `OFFSET=1` 85 | * `CATEGORIES=sponsor,interaction` 86 | * `MUTE_ADS=true` 87 | * `SKIP_ADS=true` 88 | * `MERGE_THRESHOLD=1` 89 | 90 | ## Contributing 91 | 92 | 1. Fork it () 93 | 2. Create your feature branch (`git checkout -b my-new-feature`) 94 | 3. Commit your changes (`git commit -am 'Add some feature'`) 95 | 4. Push to the branch (`git push origin my-new-feature`) 96 | 5. Create a new Pull Request 97 | 98 | ## Contributors 99 | 100 | - [erdnaxeli](https://github.com/erdnaxeli) - creator and maintainer 101 | - [stephen304](https://github.com/stephen304) - contributor and ad blocking enthusiast 102 | -------------------------------------------------------------------------------- /src/castblock.cr: -------------------------------------------------------------------------------- 1 | require "clip" 2 | 3 | require "./blocker" 4 | require "./chromecast" 5 | require "./sponsorblock" 6 | 7 | module Castblock 8 | VERSION = "0.1.0" 9 | 10 | Log = ::Log.for(self) 11 | 12 | struct Command 13 | include Clip::Mapper 14 | 15 | @debug : Bool? = nil 16 | 17 | @[Clip::Option("--sponsorblock-server")] 18 | @[Clip::Doc("The SponsorBlock server address.")] 19 | @sponsorblock_server = "https://sponsor.ajay.app" 20 | 21 | @[Clip::Option("--offset")] 22 | @[Clip::Doc("When skipping a sponsor segment, jump to this number of seconds before " \ 23 | "the end of the segment.")] 24 | @seek_to_offset = 0 25 | 26 | @[Clip::Option("--category")] 27 | @[Clip::Doc("The category of segments to block. It can be repeated to block multiple categories.")] 28 | @categories = ["sponsor"] 29 | 30 | @[Clip::Option("--mute-ads")] 31 | @[Clip::Doc("Enable auto muting adsense ads on youtube.")] 32 | @mute_ads : Bool = false 33 | 34 | @[Clip::Option("--skip-ads")] 35 | @[Clip::Doc("Enable auto skipping adsense ads on youtube.")] 36 | @skip_ads : Bool = false 37 | 38 | @[Clip::Option("--merge-threshold")] 39 | @[Clip::Doc("The maximum number of seconds between segments to be merged. " \ 40 | "Adjust this value to skip multiple adjacent segments that don't overlap.")] 41 | @merge_threshold = 0.0 42 | 43 | def read_env 44 | # If a config option equals its default value, we try to read it from the env. 45 | # This is a temporary hack while waiting for Clip to handle it in a better way. 46 | @debug = read_env_bool(@debug, nil, "DEBUG") 47 | @sponsorblock_server = read_env_str(@sponsorblock_server, "https://sponsor.ajay.app", "SPONSORBLOCK_SERVER") 48 | @seek_to_offset = read_env_int(@seek_to_offset, 0, "OFFSET") 49 | @categories = read_env_str_array(@categories, ["sponsor"], "CATEGORIES") 50 | @mute_ads = read_env_bool(@mute_ads, false, "MUTE_ADS") 51 | @skip_ads = read_env_bool(@skip_ads, false, "SKIP_ADS") 52 | @merge_threshold = read_env_float(@merge_threshold, 0.0, "MERGE_THRESHOLD") 53 | end 54 | 55 | def read_env_bool(value : Bool?, default : Bool?, name : String) : Bool? 56 | if value == default && (var = ENV[name]?) 57 | var.downcase == "true" 58 | else 59 | value 60 | end 61 | end 62 | 63 | def read_env_str(value : String, default : String, name : String) : String 64 | if value == default && (var = ENV[name]?) 65 | var 66 | else 67 | value 68 | end 69 | end 70 | 71 | def read_env_str_array(value : Array(String), default : Array(String), name : String) : Array(String) 72 | if value == default && (var = ENV[name]?) 73 | var.split(',') 74 | else 75 | value 76 | end 77 | end 78 | 79 | def read_env_int(value : Int, default : Int, name : String) : Int 80 | if value == default && (var = ENV[name]?) 81 | var.to_i 82 | else 83 | value 84 | end 85 | end 86 | 87 | def read_env_float(value : Float, default : Float, name : String) : Float 88 | if value == default && (var = ENV[name]?) 89 | var.to_f 90 | else 91 | value 92 | end 93 | end 94 | 95 | def run : Nil 96 | read_env 97 | 98 | if @debug 99 | ::Log.setup(:debug) 100 | end 101 | 102 | if @sponsorblock_server.starts_with?("https://") 103 | hostname = @sponsorblock_server[8..] 104 | tls = true 105 | elsif @sponsorblock_server.starts_with?("http://") 106 | hostname = @sponsorblock_server[7..] 107 | tls = false 108 | else 109 | puts "Invalid SponsorBlock server. You should include either https:// or http://." 110 | return 111 | end 112 | 113 | begin 114 | sponsorblock = Sponsorblock.new(hostname, tls, @categories.to_set) 115 | rescue Sponsorblock::CategoryError 116 | puts "Invalid categories" 117 | return 118 | end 119 | 120 | chromecast = Chromecast.new 121 | blocker = Blocker.new(chromecast, sponsorblock, @seek_to_offset, @mute_ads, @skip_ads, @merge_threshold) 122 | 123 | blocker.run 124 | end 125 | end 126 | 127 | def self.run 128 | begin 129 | command = Command.parse 130 | rescue ex : Clip::Error 131 | puts ex 132 | return 133 | end 134 | 135 | case command 136 | when Clip::Mapper::Help 137 | puts command.help 138 | else 139 | command.run 140 | end 141 | end 142 | end 143 | 144 | Castblock.run 145 | -------------------------------------------------------------------------------- /src/chromecast.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "http" 3 | require "process" 4 | require "uri" 5 | 6 | require "./chromecast/*" 7 | 8 | class Castblock::Chromecast 9 | Log = Castblock::Log.for(self) 10 | 11 | @bin : String 12 | @client : HTTP::Client? = nil 13 | @server_running = false 14 | @video_ids = Channel(String).new 15 | 16 | def initialize 17 | @bin = find_executable 18 | spawn start_server 19 | while !@server_running 20 | sleep 500.milliseconds 21 | end 22 | end 23 | 24 | def list_devices : Array(Device) 25 | response = client.get("/devices") 26 | if !response.status.success? 27 | Log.debug &.emit("Error while getting devices", status_code: response.status_code, error: response.body) 28 | raise CommandError.new 29 | end 30 | 31 | Array(Device).from_json(response.body) 32 | end 33 | 34 | def seek_to(device : Device, timestamp : Float64) : Nil 35 | params = HTTP::Params.encode({ 36 | "uuid" => device.uuid, 37 | "seconds" => timestamp.to_s, 38 | }) 39 | response = client.post("/seek-to?" + params) 40 | 41 | if !response.status.success? 42 | Log.error &.emit("Error with seek_to", status_code: response.status_code, error: response.body) 43 | raise CommandError.new 44 | end 45 | end 46 | 47 | def set_mute(device : Device, value : Bool) : Nil 48 | params = HTTP::Params.encode({ 49 | "uuid" => device.uuid, 50 | }) 51 | response = client.post("/#{value ? "" : "un"}mute?" + params) 52 | 53 | if !response.status.success? 54 | Log.error &.emit("Error with mute", status_code: response.status_code, error: response.body) 55 | raise CommandError.new 56 | end 57 | end 58 | 59 | def skip_ad(device : Device) : Nil 60 | params = HTTP::Params.encode({ 61 | "uuid" => device.uuid, 62 | }) 63 | response = client.post("/skipad?" + params) 64 | 65 | if !response.status.success? 66 | Log.debug &.emit("Error with skipad", status_code: response.status_code, error: response.body) 67 | # We do not want to raise an error based off the response from /skipad as the ad may already be skipped. 68 | # The /skipad logic is run 30 times over 60 seconds until the ad is skipped, so subsequent requests as the watcher loops will fail 69 | end 70 | end 71 | 72 | def start_watcher(device : Device, continue : Channel(Nil), &block : WatchMessage ->) : Nil 73 | loop do 74 | Log.info &.emit("Starting go-chromecast watcher", name: device.name, uuid: device.uuid) 75 | Process.run( 76 | @bin, 77 | args: ["watch", "--output", "json", "--interval", "2", "-u", device.uuid, "--retries", "1"], 78 | ) do |process| 79 | while output = process.output.gets 80 | if continue.closed? 81 | process.terminate 82 | return 83 | end 84 | 85 | begin 86 | message = WatchMessage.from_json(output) 87 | message.payload.as?(String).try do |payload| 88 | begin 89 | message.payload_data = WatchMessagePayload.from_json(payload) 90 | rescue ex 91 | Log.debug { "Unhandled payload: #{ex}" } 92 | end 93 | end 94 | rescue ex 95 | Log.debug { "Invalid message: #{ex}" } 96 | else 97 | yield message 98 | end 99 | end 100 | end 101 | 102 | return if continue.closed? 103 | 104 | Log.warn &.emit("go-chromecast has quit.", name: device.name, uuid: device.uuid) 105 | Log.warn &.emit("Restarting go-chromecast watcher in 5s.", name: device.name, uuid: device.uuid) 106 | sleep 5.seconds 107 | end 108 | end 109 | 110 | def connect(device : Device) : Nil 111 | params = HTTP::Params.encode({ 112 | "uuid" => device.uuid, 113 | }) 114 | response = client.post("/connect?" + params) 115 | 116 | if !response.status.success? && response.body.chomp != "device uuid is already connected" 117 | Log.debug &.emit( 118 | "Error while connecting to device", 119 | name: device.name, 120 | uuid: device.uuid, 121 | status_code: response.status_code, 122 | error: response.body.chomp, 123 | ) 124 | raise CommandError.new 125 | end 126 | end 127 | 128 | def disconnect(device : Device) : Nil 129 | params = HTTP::Params.encode({ 130 | "uuid" => device.uuid, 131 | }) 132 | response = client.post("/disconnect?" + params) 133 | 134 | if !response.status.success? 135 | Log.warn &.emit( 136 | "Error while disconnecting from device", 137 | name: device.name, 138 | uuid: device.uuid, 139 | status_code: response.status_code, 140 | error: response.body.chomp, 141 | ) 142 | end 143 | end 144 | 145 | private def client : HTTP::Client 146 | if !@server_running 147 | raise HttpServerNotRunningError.new 148 | end 149 | 150 | if client = @client 151 | client 152 | else 153 | @client = HTTP::Client.new("127.0.0.1", port: 8011) 154 | end 155 | end 156 | 157 | private def find_executable : String 158 | bin = Process.find_executable("go-chromecast") 159 | if bin.nil? 160 | Log.fatal { "No go-chromecast executable found in the PATH." } 161 | raise "missing go-chromecast executable" 162 | end 163 | 164 | Log.info { "Found go-chromecast at #{bin}." } 165 | bin 166 | end 167 | 168 | private def start_server : Nil 169 | loop do 170 | Log.info { "Starting the go-chromecast server." } 171 | Process.run(@bin, args: ["httpserver", "-p", "8011"]) do |process| 172 | loop do 173 | begin 174 | HTTP::Client.get("http://127.0.0.1:8011/") 175 | rescue e : Socket::ConnectError 176 | sleep 500.milliseconds 177 | else 178 | break 179 | end 180 | end 181 | 182 | Log.info { "The go-chromecast server is up" } 183 | 184 | @server_running = true 185 | error = process.error.gets_to_end 186 | @server_running = false 187 | 188 | Log.warn { "The go-chromecast server has quit." } 189 | Log.warn { error } 190 | end 191 | 192 | Log.warn { "Restart go-chromecast server in 5s." } 193 | sleep 5.seconds 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /src/blocker.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "math" 3 | 4 | class Castblock::Blocker 5 | Log = Castblock::Log.for(self) 6 | 7 | @devices = Hash(Chromecast::Device, Channel(Nil)).new 8 | 9 | def initialize(@chromecast : Chromecast, @sponsorblock : Sponsorblock, @seek_to_offset : Int32, @mute_ads : Bool, @skip_ads : Bool, @merge_threshold : Float64) 10 | end 11 | 12 | def run : Nil 13 | watch_new_devices 14 | end 15 | 16 | private def watch_new_devices : Nil 17 | loop do 18 | Log.debug { "Checking for new devices" } 19 | @devices.each { |device, continue| remove_device(device) if continue.closed? } 20 | 21 | begin 22 | devices = @chromecast.list_devices.to_set 23 | rescue Chromecast::CommandError 24 | Log.warn { "Error while listing devices" } 25 | else 26 | new_devices = devices - @devices.keys 27 | new_devices.each do |device| 28 | Log.info &.emit("New device found", name: device.name, uuid: device.uuid) 29 | 30 | Log.info &.emit("Connect to device", name: device.name, uuid: device.uuid) 31 | begin 32 | @chromecast.connect(device) 33 | rescue Chromecast::CommandError 34 | Log.error &.emit("Error while connecting", name: device.name, uuid: device.uuid) 35 | else 36 | @devices[device] = Channel(Nil).new 37 | spawn watch_device(device, @devices[device]) 38 | end 39 | end 40 | 41 | @devices.each_key do |device| 42 | if !devices.includes?(device) 43 | Log.info &.emit("A device is gone, stopping watcher", name: device.name, uuid: device.uuid) 44 | remove_device(device) 45 | end 46 | end 47 | end 48 | 49 | sleep 30.seconds 50 | end 51 | end 52 | 53 | private def watch_device(device : Chromecast::Device, continue : Channel(Nil)) : Nil 54 | @chromecast.start_watcher(device, continue) do |message| 55 | if application = message.application 56 | Log.debug &.emit("Received message", application: application.display_name) 57 | if application.display_name.downcase == "youtube" && (media = message.media) && media.player_state == "PLAYING" 58 | handle_media(device, media) 59 | end 60 | end 61 | 62 | if (payload = message.payload_data) && payload.status.size > 0 63 | payload_status = payload.status[0] 64 | if @mute_ads 65 | handle_mute_ad(device, payload_status) 66 | end 67 | if @skip_ads 68 | handle_skip_ad(device, payload_status) 69 | end 70 | end 71 | end 72 | end 73 | 74 | private def handle_mute_ad(device : Chromecast::Device, payload_status : Chromecast::WatchMessagePayload::PayloadStatus) : Nil 75 | if payload_status.custom_data.player_state == 1081 && payload_status.volume.muted == false 76 | Log.info &.emit("Found ad, muting audio", device: device.name) 77 | @chromecast.set_mute(device, true) 78 | elsif payload_status.custom_data.player_state != 1081 && payload_status.volume.muted 79 | Log.info &.emit("Ad ended, unmuting audio", device: device.name) 80 | @chromecast.set_mute(device, false) 81 | end 82 | end 83 | 84 | private def handle_skip_ad(device : Chromecast::Device, payload_status : Chromecast::WatchMessagePayload::PayloadStatus) : Nil 85 | if payload_status.custom_data.player_state == 1081 86 | Log.info &.emit("Found ad, skipping", device: device.name) 87 | @chromecast.skip_ad(device) 88 | end 89 | end 90 | 91 | private def handle_media(device : Chromecast::Device, media : Chromecast::Media) : Nil 92 | Log.debug &.emit("Youtube video playing", id: media.media.content_id, current_time: media.current_time) 93 | 94 | begin 95 | segments = @sponsorblock.get_segments(media.media.content_id) 96 | rescue Sponsorblock::Error 97 | return 98 | end 99 | 100 | return if segments.nil? 101 | 102 | if segments.size == 0 103 | Log.debug &.emit("Unknown video", id: media.media.content_id) 104 | return 105 | end 106 | 107 | segments.each do |segment| 108 | segment_start = segment.segment[0] 109 | segment_end = Math.min(segment.segment[1], media.media.duration).to_f 110 | 111 | # Check if we are in the segment 112 | if segment_start <= media.current_time < segment_end 113 | Log.info &.emit( 114 | "Found a #{segment.category} segment.", 115 | id: media.media.content_id, 116 | start: segment_start, 117 | end: segment_end, 118 | ) 119 | 120 | # Extend segment_end using merge_threshold 121 | sorted_segments = segments.sort { |x, y| x.segment[0] <=> y.segment[0] } 122 | sorted_segments.each do |segment_next| 123 | if segment_next.segment[0] - @merge_threshold <= segment_end < segment_next.segment[1] 124 | Log.info &.emit("Segment extended from #{segment_end} to #{segment_next.segment[1]}.") 125 | segment_end = Math.min(segment_next.segment[1], media.media.duration).to_f 126 | end 127 | end 128 | 129 | # Check if the segment meets minimum length 130 | if media.current_time < segment_end - Math.max(5, @seek_to_offset) 131 | Log.info &.emit( 132 | "Segment meets criteria, skipping it.", 133 | id: media.media.content_id, 134 | start: segment_start, 135 | end: segment_end, 136 | ) 137 | begin 138 | @chromecast.seek_to(device, segment_end - @seek_to_offset) 139 | rescue Chromecast::CommandError 140 | Log.error &.emit("Trying to reconnect to the device", name: device.name, uuid: device.uuid) 141 | @chromecast.disconnect(device) 142 | begin 143 | @chromecast.connect(device) 144 | rescue Chromecast::CommandError 145 | Log.error &.emit("Error while reconnecting to the device", name: device.name, uuid: device.uuid) 146 | remove_device(device, disconnect: false) 147 | end 148 | end 149 | # Only break if segment met the criteria and was skipped 150 | break 151 | end 152 | end 153 | end 154 | end 155 | 156 | private def remove_device(device : Chromecast::Device, disconnect = true) : Nil 157 | if continue = @devices.delete(device) 158 | continue.close 159 | 160 | if disconnect 161 | Log.info &.emit("Disconnecting from device", name: device.name, uuid: device.uuid) 162 | @chromecast.disconnect(device) 163 | end 164 | end 165 | end 166 | end 167 | --------------------------------------------------------------------------------