AndroidHDMI for Channels (ah4c): A virtual channel tuner using HDMI Encoder(s) + streaming stick(s)

2026.06.29.0212

I would think if cdvr was doing it, it would show up in the support logs.

I think they only allow 2 streams via browser
edit; I just tested, had a stream running in firefox, chrome, and edge. I was thinking of this

so with ch4c it's possible the multiple sandboxed chromes are causing a hickup

1 Like

Thank you @boukmandutty and @bnhf. Nine months later and I'm finally getting around to setting up AH4C PyATV and a Magewell card "made into" an encoder using GitHub - jpoet/Magewell2TS: Reads audio and video from a Magewell PRO capture card and muxes them into a Transport Stream. · GitHub and GitHub - mackandall/BroadCastHub: Broadcast Hub is a self-hosted web application for managing video capture hardware · GitHub.
Nice explanation here: BroadcastHub For Magewell and Decklink.

The more I think of it, I should be able to run AH4C Apple TV version in a docker container and run everything else to use the Magewell card as an encoder as native. Also, I don't think I need to pass my Intel Quick Sync driver through to the container either then. Am I thinking about this correctly? As long as I'm not using ffmpeg commands within the .env files of AH4C, and instead letting Magewell2TS and BroadCastHub work together to do the encoding, this should work to have just AH4C PyATV in the container?

Sounds about right. The Broadcast Hub essentially operates the way a link pi box would. So for example one of my inputs from my magewell card broadcasts over the network as

http://192.168.1.188:6502/stream/0-1

This means that any program can see what is being streamed. The channels changing control goes to either adbtuner or ah4c. This means that the docker would work as easily as running it in non-docker mode.

1 Like

Great, thank you! Time to roll up sleeves and try it out. I'm going to start with a new box and load all from scratch as my original CDVR Ubuntu is pretty out of date. I know @boukmandutty tested Broadcast Hub with Ubuntu, but I'm thinking of trying it with latest Debian instead. Since I'm setting this up for Spectrum and using Apple TVs as my streamers, I'll be following some good input I found in the following thread: Advice Needed - ADBTuner or AH4C?. Just thought I would link that here for anyone trying to do the same. So, just for clarity, here is what I'll be setting up. I'm not completely literate with all these steps but will be reading and trying it out. I think I have a general direction now:

  • will leave existing CDVR on existing box (eventually may also move it over to repurpose existing box)
  • new Intel box (I think I have a 9th or 10th gen Xeon) to be used for AH4C:AppleTV / Magewell cards using Magewell2TS and Broadcast Hub
  • install Debian 13.5 or Ubuntu (may experiment with Debian first)
  • ensure driver is loaded for Intel Quick Sync (no need to pass through to docker) to handle video encoding
  • install docker compose
  • use one-click from @bnhf for portainer / olive tin
  • install AppleTV version of AH4C (running as docker)
  • install 2 Magewell Pro Capture dual HDMI cards (fanless) to have a total of 4 streams at once (will probably never use that many at once)
  • install Magewell2TS (native, no docker)
  • Install Broadcast Hub (native, no docker)
  • configure everything and cross fingers
    Thank you all!

You can already do that (more-or-less) using "CMDx" and ffmpeg. There have been posts about it in the past, and I've used it myself. You're basically just letting ffmpeg deliver a black screen for x seconds to absorb whatever part of the typical tuning process you'd prefer to not see.

If a fixed delay isn't to your liking, you can also use ffmpeg to test for active video. I've written functions to do that in the past for active audio, using ffmpeg -- and I'm fairly sure you can do that with active video as well.

The parameters might be a bit trickier, but I believe you could differentiate between menu navigation and fullscreen video using ffmpeg. If you can do it this way, using a "CMDx" tee, anyone using any set of scripts could take advantage of it.

Fire up your bots and set them to the task! :slight_smile:

1 Like

where did you land with the audio check? I like the idea of audio check since that will catch a froze tune or other lockup. i've found that most of the time when I have a failed tune, a new attempt from ah4c (2nd tune) usually works.

1 Like

Once you have the ffmpeg command figured out, a bot can update your Docker Compose accordingly with the appropriate CMDx variants -- which should make it pretty simple.

so, wouldn't it make sense to use the audio to trigger that the channel has tuned so you don't see the channel changing splash screen

The function looks like this:

#Check for active audio stream with maxDuration, preTuneAudioCheck, sleepBeforeAudioCheck and sleepAfterAudioCheck as arguments
activeAudioCheck() {
  local startTime=$(date +%s)
  local maxDuration=$1
  local minimumLoudness=-50
  local sleepBeforeAudioCheck=$3
  local sleepAfterAudioCheck=$4
  local preTuneAudioCheck=$2
  
  while true; do
    sleep $sleepBeforeAudioCheck
    checkLoudness=$(ffmpeg -t 1 -i $encoderURL -filter:a ebur128 -map 0:a -f null -hide_banner - 2>&1 | awk '/I:        /{print $2}')

    if (( $(date +%s) - $startTime > $maxDuration )); then
      echo "Active audio stream not detected in $maxDuration seconds."
      if [ $preTuneAudioCheck = "false" ]; then
        echo "Active audio stream not detected after tuning completed"
        case "$specialID" in
          "212")
            echo "Possible sports event blackout on NFL Network, so bumping channel up"
            $adbTarget shell input keyevent KEYCODE_DPAD_LEFT
            echo 0 > "$streamerNoPort/last_channel"
            exit 1
            ;; 
          "213")
            echo "Possible sports event blackout on MLB Network, so bumping channel down"
            $adbTarget shell input keyevent KEYCODE_DPAD_RIGHT
            echo 0 > "$streamerNoPort/last_channel"
            exit 1
            ;; 
          *)
            echo "Possible sports event blackout, so bumping channel down"
            $adbTarget shell input keyevent KEYCODE_DPAD_RIGHT
            echo 0 > "$streamerNoPort/last_channel"
            exit 1
            ;;
        esac
      else
        exit 1
      fi
    fi

    if (( $(echo "$checkLoudness > $minimumLoudness" | bc -l) )); then
      echo "Active audio stream detected with $checkLoudness LUF."
      break
    fi

    echo "Active audio stream not yet detected -- loudness is $checkLoudness LUF. Continuing..."
    sleep $sleepAfterAudioCheck
  done
}

You can look at bmitune.sh in scripts/firetv/directv to see it in-full.

EDIT: That was pretty crucial back a few years ago, when the only option for DTV app tuning was remote control emulation.

can't you use it also for the aforementioned gate

on my boxes changing the channel brings up a splash screen for the new channel, and cuts out the audio. usually about 2 seconds. might be enough window to re-detect audio.

ie, wake, wait for audio, tune, wait 1 sec, wait for audio, send stream.

My 2 cents:

When I tune to an Osprey channel using deeplinks, I will see the channel splash screen just prior to the channel going live. Prior to deeplinks, I would see the channel numbers. I really like that behavior because it gives me visual proof that everything is working as expected. I have had instances where the box has missed the adb authorization and it just goes back to last viewed channel. The adb authorization screen never shows on the tv, only on the link pi for 1-2 seconds. Retuning to another channel has always fixed it. When it occurs on 1, the others will follow suit so I know to fix them.

ah4c (appletv tag): tuner held permanently when encoder input goes silent — io.Copy blocked in Read with no deadline

In a nutshell, I'd periodically find both of my ah4c tuners showing in use when they are not being used.

I've been working through this one with Claude for a little bit, and this is the summary of what it's helped me find:

On bnhf/ah4c:appletv using the Spectrum scripts, a tuner can be marked Active forever with no error logged. It is only recoverable by restarting the container.

The trigger is reproducible on demand: put the streaming Apple TV to sleep while a stream is in progress. The HDMI encoder stops emitting bytes but holds the TCP connection open. ah4c blocks in net.(*conn).Read with no deadline and never returns, so Close() and the stop script never run and the tuner is never released.

I've got a much longer doc outlining what was done and the logs captured showing everything, which I threw into a google doc to avoid a long post here.

Can you post the Spectrum scripts you're using please, along with the M3U file?

This is what I've got:

{
echo "### `prebmitune.sh`"
echo 'bash' cat scripts/atv/spectrum/prebmitune.sh echo ''
echo
echo "### `bmitune.sh`"
echo 'bash' cat scripts/atv/spectrum/bmitune.sh echo ''
echo
echo "### `stopbmitune.sh`"
echo 'bash' cat scripts/atv/spectrum/stopbmitune.sh echo ''
echo
echo "### `spectrum.m3u`"
echo '' cat m3u/spectrum.m3u echo ''
} > /home/peter/ah4c_watchdog/scripts-and-m3u.md
cat /home/peter/ah4c_watchdog/scripts-and-m3u.md

prebmitune.sh

#!/bin/bash
streamerIP="$1"

# 1. Wake the Apple TV
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP home
sleep 2

# 2. Gracefully open the Spectrum app to its default startup screen
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP launch_app=spectrumTV://

# 3. Give the app 7 seconds to load your profile and system preferences
sleep 7

bmitune.sh

#!/bin/bash
channelID="$1"
streamerIP="$2"

/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP launch_app=spectrumTV://watch.spectrum.net/livetv/$channelID

stopbmitune.sh

#!/bin/bash
streamerIP="$1"

# 1. Exit the stream and drop to the home screen
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP home
sleep 2

# 2. Open the App Switcher
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP home home
sleep 3

# 3. Navigate left to focus on the Spectrum app
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP left
sleep 2

# 4. Perform the "Double-Click UP" force-quit maneuver
# We send two 'up' commands in immediate succession
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP up up
sleep 2

# 5. Return to Home Screen
/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP home

spectrum.m3u

#EXTM3U

#EXTINF:-1 channel-id="31507" channel-number="" tvc-guide-stationid="31507" tvg-group="" tvg-logo="",WGRZ
http://{{ .IPADDRESS }}/play/tuner/31507

#EXTINF:-1 channel-id="24052" channel-number="" tvc-guide-stationid="24052" tvg-group="" tvg-logo="",WKBW
http://{{ .IPADDRESS }}/play/tuner/24052

#EXTINF:-1 channel-id="24051" channel-number="" tvc-guide-stationid="24051" tvg-group="" tvg-logo="",WIVB
http://{{ .IPADDRESS }}/play/tuner/24051

#EXTINF:-1 channel-id="10091" channel-number="" tvc-guide-stationid="10091" tvg-group="" tvg-logo="",CBLT
http://{{ .IPADDRESS }}/play/tuner/10091

#EXTINF:-1 channel-id="65437" channel-number="" tvc-guide-stationid="65437" tvg-group="" tvg-logo="",MSG Sports Buffalo
http://{{ .IPADDRESS }}/play/tuner/65437

#EXTINF:-1 channel-id="43432" channel-number="" tvc-guide-stationid="43432" tvg-group="" tvg-logo="",Rewind TV
http://{{ .IPADDRESS }}/play/tuner/43432

#EXTINF:-1 channel-id="123810" channel-number="" tvc-guide-stationid="123810" tvg-group="" tvg-logo="",Bay News 9 Tampa
http://{{ .IPADDRESS }}/play/tuner/123810

#EXTINF:-1 channel-id="68904" channel-number="" tvc-guide-stationid="68904" tvg-group="" tvg-logo="",Spectrum News 1 Buffalo
http://{{ .IPADDRESS }}/play/tuner/68904

#EXTINF:-1 channel-id="63992" channel-number="" tvc-guide-stationid="63992" tvg-group="" tvg-logo="",WBBZ MeTv Buffalo
http://{{ .IPADDRESS }}/play/tuner/63992

#EXTINF:-1 channel-id="123830" channel-number="" tvc-guide-stationid="123830" tvg-group="" tvg-logo="",Spectrum News 13 Central Florida
http://{{ .IPADDRESS }}/play/tuner/123830

#EXTINF:-1 channel-id="34915" channel-number="" tvc-guide-stationid="34915" tvg-group="" tvg-logo="",WNED 
http://{{ .IPADDRESS }}/play/tuner/34915

#EXTINF:-1 channel-id="43430" channel-number="" tvc-guide-stationid="43430" tvg-group="" tvg-logo="",WNLO
http://{{ .IPADDRESS }}/play/tuner/43430

#EXTINF:-1 channel-id="44668" channel-number="" tvc-guide-stationid="44668" tvg-group="" tvg-logo="",WNYO
http://{{ .IPADDRESS }}/play/tuner/44668

#EXTINF:-1 channel-id="42945" channel-number="" tvc-guide-stationid="42945" tvg-group="" tvg-logo="",WUTV
http://{{ .IPADDRESS }}/play/tuner/42945

#EXTINF:-1 channel-id="47796" channel-number="" tvc-guide-stationid="47796" tvg-group="" tvg-logo="",Antenna TV
http://{{ .IPADDRESS }}/play/tuner/47796

These channels are coming in over a VPN so I adjusted the stop script to force quit the spectrum app to minimize vpn traffic when we aren't streaming these channels. I also added the bit where it opens the Spectrum app and waits a few seconds for it to load before tuning to a channel, without this the app sometimes would launch with subtitles turned on, even though all settings had them disabled.

I'm concerned the way you've implemented the force-quit may be part of the issue here, so I'm posting a new set of scripts I'd like you to try below.

On the subject of your AI-generated report, we may already have a fix in the code tied to some work done a couple of months ago by @anon38303228. The fix is gated behind an env var, so I'm also posting a new Docker Compose and sample set of env vars below.

prebmitune.sh:

#!/bin/bash
# prebmitune.sh for atv/spectrum
# 2026.08.05

streamerIP="$1"

atvTarget="timeout 15 /usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP"

# Wake the Apple TV -- also wakes a device suspended by stopbmitune.sh's turn_off
$atvTarget home
sleep 2

# Gracefully open the Spectrum app to its default startup screen
$atvTarget launch_app=spectrumTV://
sleep 7

bmitune.sh:

#!/bin/bash
# bmitune.sh for atv/spectrum
# 2026.08.05

channelID="$1"
streamerIP="$2"

/usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP launch_app=spectrumTV://watch.spectrum.net/livetv/$channelID

stopbmitune.sh:

#!/bin/bash
# stopbmitune.sh for atv/spectrum
# 2026.08.05

streamerIP="$1"
channelID="$2"

atvTarget="timeout 15 /usr/local/bin/atvremote --storage-filename /root/.android/.pyatv.conf -s $streamerIP"

# Best-effort: back out of the stream to the home screen
$atvTarget home

# Deterministic backstop: guarantees playback stops and the tuner is
# released even if the app didn't background cleanly above. This is the
# same role KEYCODE_SLEEP plays in the adb-based script sets.
$atvTarget turn_off

ah4c.yaml:

services:
  # 2026.08.05
  # GitHub home for this project with setup instructions: https://github.com/sullrich/ah4c
  # Docker Hub home for this project: https://hub.docker.com/repository/docker/bnhf/ah4c
  ah4c:
    image: bnhf/ah4c:${TAG}
    container_name: ah4c
    hostname: ah4c
    dns_search: ${DOMAIN} # Specify the name of your LAN's domain, usually local or localdomain
    ports:
      - ${ADBS_PORT}:5037 # Port used by adb-server
      - ${HOST_PORT}:7654 # Port used by this ah4c proxy
      - ${WSCR_PORT}:8000 # Port used by ws-scrcpy
    environment:
      - IPADDRESS=${IPADDRESS} # Hostname or IP address of this ah4c extension to be used in M3U file (also add port number if not in M3U)
      - NUMBER_TUNERS=${NUMBER_TUNERS} # Number of tuners you'd like defined - add a matching TUNERn_IP and ENCODERn_URL line below for each beyond 9
      - TUNER1_IP=${TUNER1_IP} # Streaming device #1 with adb port in the form hostname:port or ip:port
      - TUNER2_IP=${TUNER2_IP} # Streaming device #2 with adb port in the form hostname:port or ip:port
      - TUNER3_IP=${TUNER3_IP} # Streaming device #3 with adb port in the form hostname:port or ip:port
      - TUNER4_IP=${TUNER4_IP} # Streaming device #4 with adb port in the form hostname:port or ip:port
      - TUNER5_IP=${TUNER5_IP} # Streaming device #5 with adb port in the form hostname:port or ip:port
      - TUNER6_IP=${TUNER6_IP} # Streaming device #6 with adb port in the form hostname:port or ip:port
      - TUNER7_IP=${TUNER7_IP} # Streaming device #7 with adb port in the form hostname:port or ip:port
      - TUNER8_IP=${TUNER8_IP} # Streaming device #8 with adb port in the form hostname:port or ip:port
      - TUNER9_IP=${TUNER9_IP} # Streaming device #9 with adb port in the form hostname:port or ip:port
      - ENCODER1_URL=${ENCODER1_URL} # Full URL for tuner #1 in the form http://hostname/stream or http://ip/stream
      - ENCODER2_URL=${ENCODER2_URL} # Full URL for tuner #2 in the form http://hostname/stream or http://ip/stream
      - ENCODER3_URL=${ENCODER3_URL} # Full URL for tuner #3 in the form http://hostname/stream or http://ip/stream
      - ENCODER4_URL=${ENCODER4_URL} # Full URL for tuner #4 in the form http://hostname/stream or http://ip/stream
      - ENCODER5_URL=${ENCODER5_URL} # Full URL for tuner #5 in the form http://hostname/stream or http://ip/stream
      - ENCODER6_URL=${ENCODER6_URL} # Full URL for tuner #6 in the form http://hostname/stream or http://ip/stream
      - ENCODER7_URL=${ENCODER7_URL} # Full URL for tuner #7 in the form http://hostname/stream or http://ip/stream
      - ENCODER8_URL=${ENCODER8_URL} # Full URL for tuner #8 in the form http://hostname/stream or http://ip/stream
      - ENCODER9_URL=${ENCODER9_URL} # Full URL for tuner #9 in the form http://hostname/stream or http://ip/stream
      - STREAMER_APP=${STREAMER_APP} # Streaming device name and streaming app you're using in the form scripts/streamer/app (use lowercase with slashes between as shown)
      - CHANNELSIP=${CHANNELSIP} # Hostname or IP address of the Channels DVR server itself
      - ALERT_SMTP_SERVER=${ALERT_SMTP_SERVER} # The domainname:port of the SMTP server you'll be using like smtp.gmail.com:587. This is for sending ah4c alerts if tuning fails.
      - ALERT_AUTH_SERVER=${ALERT_AUTH_SERVER} # The auth server for the e-mail you'll be using like smtp.gmail.com
      - ALERT_EMAIL_FROM=${ALERT_EMAIL_FROM} # The e-mail address you'd like your ah4c failure alert e-mails to show as being from.
      - ALERT_EMAIL_PASS=${ALERT_EMAIL_PASS} # Gmail and Yahoo both support the creation of app-specific e-mail passwords, and this is the way to go! It's NOT recommended to use your everyday e-mail password.
      - ALERT_EMAIL_TO=${ALERT_EMAIL_TO} # The e-mail address you'd like your alert e-mails sent to.
      #- ALERT_WEBHOOK_URL=""
      - LIVETV_ATTEMPTS=${LIVETV_ATTEMPTS} # For FireTV Live Guide tuning only, set maximum number of attempts at finding the desired channel
      - CREATE_M3US=${CREATE_M3US} # Set to true to create device-specific M3Us for use with Amazon Prime Premium channels -- requires a FireTV device
      - UPDATE_SCRIPTS=${UPDATE_SCRIPTS} # Set to true if you'd like the sample scripts and STREAMER_APP scripts updated whether they exist or not
      - UPDATE_M3US=${UPDATE_M3US} # Set to true if you'd like the sample m3us updated whether they exist or not
      - TZ=${TZ} # Your local timezone in Linux "tz" format
      - SPEED_MODE=${SPEED_MODE} # Set to false if you'd like the target streaming app to be closed after each tuning cycle (limited script support).
      - KEEP_WATCHING=${KEEP_WATCHING} # In supported scripts, set the delay before resending a tuning deeplink to prevent "Are you still watching?" type messages. Examples: Use 4h for 4 hours or 240m for 240 minutes.
      - NULL_FRAME_INSERTION=${NULL_FRAME_INSERTION} # Set to TRUE to fill encoder stalls with MPEG-TS NULL packets (PID 0x1FFF) so the DVR never sees a zero-byte gap mid-recording. Must be the literal string TRUE; anything else (including true/1/yes) leaves the feature off.
      - HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL} # In supported scripts (currently osprey), seconds between keepalive keyevents sent during playback to stop the app's UI inactivity timer from resetting the stream. Set to 0 to disable.
    volumes:
      - ${HOST_DIR}/ah4c/scripts:/opt/scripts # pre/stop/bmitune.sh scripts will be stored in this bound host directory under streamer/app
      - ${HOST_DIR}/ah4c/m3u:/opt/m3u # m3u files will be stored here and hosted at http://<hostname or ip>:7654/m3u for use in Channels DVR - Custom Channels settings
      - ${HOST_DIR}/ah4c/adb:/root/.android # Persistent data directory for adb keys
    restart: unless-stopped

ah4c.env:

TAG=appletv
DOMAIN=localdomain tailxxxxx.ts.net
ADBS_PORT=5037
HOST_PORT=7654
SCRC_PORT=7655
IPADDRESS=htpc6:7654
NUMBER_TUNERS=5
TUNER1_IP=firestick-rack1:5555
ENCODER1_URL=http://encoder_48007/0.ts
TUNER2_IP=firestick-rack2:5555
ENCODER2_URL=http://encoder_48007/4.ts
TUNER3_IP=firestick-rack3:5555
ENCODER3_URL=http://encoder_48007/8.ts
TUNER4_IP=firestick-rack4:5555
ENCODER4_URL=http://encoder_48007/12.ts
TUNER5_IP=firestick-travel2:5555
ENCODER5_URL=http://encoder_23393/0.ts
STREAMER_APP=scripts/firetv/dtvdeeplinks
CHANNELSIP=media-server6
ALERT_SMTP_SERVER=smtp.gmail.com:587
ALERT_AUTH_SERVER=smtp.gmail.com
[email protected]
ALERT_EMAIL_PASS=xxxxxxxxxxxxxxxx
[email protected]
UPDATE_SCRIPTS=true
UPDATE_M3US=true
TZ=America/Denver
SPEED_MODE=false
KEEP_WATCHING=4h
NULL_FRAME_INSERTION=TRUE
HEARTBEAT_INTERVAL=0
HOST_DIR=/data

It's the NULL_FRAME_INSERTION=TRUE (and corresponding line in the Compose) that we're implementing here -- if you just want to update your existing stack.

Let me know how this goes, as I'd like to update the repo accordingly, if everything looks good.

1 Like

Updated everything and testing now. Thank you for the stopbmitune modification, that is much cleaner than what I worked out.

I'll report back once I know if this corrects it

1 Like