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

No, the audio trigger is more to see that you're ready to fire the tune.
It basically determines readiness to tune. Once there's audio, then it's safe to start tuning. I'm referring to what I implemented for the Osprey scripts, FYI, not anything anyone else has implemented for other platforms.

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

The problem with that is it's too early. You need something a little bit later to determine the tune is completed.

I was thinking bitrate would be the correct gate to use. Like once bitrate has gone over a certain threshold that would determine video is playing, not sitting on a splash screen.

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.

CMD1=sh -c 'ffmpeg -hide_banner -nostdin -rw_timeout 5000000 -probesize 500000 -analyzeduration 500000 -t 25 -i $ENCODER1_URL -an -sn -vf scale=160:-2,mpdecimate -vsync 0 -frames:v 72 -f null - 2>&1 | grep -qE "frame= *72" || sleep 6; exec ffmpeg -hide_banner -v error -nostdin -probesize 500000 -analyzeduration 500000 -i $ENCODER1_URL -c:v copy -c:a copy -f mpegts -'

This seems to do it.

1 Like

Playback Detection Rediness Script

I found a much better way of doing this. It's much more reliable and avoids pretty much every edge case, and here it is:

.env example

##Osprey 1
TUNER1_IP=192.168.210.16:5555
ENCODER1_URL=http://192.168.210.90:8090/stream0
CMD1=/opt/scripts/streamgate.sh 1
##Osprey 2
TUNER2_IP=192.168.210.17:5555
ENCODER2_URL=http://192.168.210.90:8090/stream1
CMD2=/opt/scripts/streamgate.sh 2
##Osprey 3
TUNER3_IP=192.168.210.18:5555
ENCODER3_URL=http://192.168.210.90:8090/stream2
CMD3=/opt/scripts/streamgate.sh 3
##Osprey 4
TUNER4_IP=192.168.210.19:5555
ENCODER4_URL=http://192.168.210.90:8090/stream3
CMD4=/opt/scripts/streamgate.sh 4
##Osprey 5
TUNER5_IP=192.168.210.20:5555
ENCODER5_URL=http://192.168.210.90:8090/stream4
CMD5=/opt/scripts/streamgate.sh 5

streamgate.sh: installed to root of scripts folder.

#!/bin/sh
#
# streamgate.sh -- ah4c CMDn helper. Waits for the Android box to actually be
# playing video, then streams the encoder through untouched.
#
# Put it in the scripts directory you bind-mount for ah4c (/opt/scripts inside
# the container), make it executable, then in the .env:
#
#   CMD1=/opt/scripts/streamgate.sh 1
#
# TUNERn_IP and ENCODERn_URL are read from the environment.
# Self-contained: nothing needs adding to the pre/stop scripts.
#
# DEBUG=1 logs every poll.

MIN_WAIT=${MIN_WAIT:-1}          # ignore "playing" before this many seconds
TUNE_TIMEOUT=${TUNE_TIMEOUT:-40} # give up and stream anyway after this. Costs
                                 # nothing on a tune that works -- the loop
                                 # returns the moment it detects -- so this only
                                 # governs how long a cold box gets before you
                                 # end up watching its splash screen.
SETTLE=${SETTLE:-0.25}           # pause after detecting, before handing off
CONFIRM=${CONFIRM:-3}            # consecutive polls that must agree before handing
                                 # off. A deeplink into an already-running app
                                 # switches channels in place -- "Activity not
                                 # started, intent delivered to currently running
                                 # top-most instance" -- so the new decoder is
                                 # allocated a beat before anything is decoded.
                                 # One sighting is not playback.
POLL=${POLL:-0.25}
BLACK_CHECK=${BLACK_CHECK:-auto} # auto | 1 | 0 -- see the note below
BLACK_AFTER=${BLACK_AFTER:-6}    # in auto, only start sampling after this many seconds
BLACK_PCT=${BLACK_PCT:-100}      # pblack is an integer; 100 is the >99.8 rule
ON_TIMEOUT=${ON_TIMEOUT:-fail}   # fail | stream. On fail the script exits without
                                 # sending anything, so ah4c sees the tune die and
                                 # Channels can drop the tuner or move to another
                                 # one -- better than handing it a splash screen.

n="$1"
[ -n "$n" ] || { echo "usage: $0 <tuner-number>" >&2; exit 2; }
eval "TUNER_IP=\$TUNER${n}_IP"
eval "ENCODER_URL=\$ENCODER${n}_URL"
[ -n "$ENCODER_URL" ] || { echo "ENCODER${n}_URL not set" >&2; exit 2; }

log() { echo "streamgate[$n]: $*" >&2; }
adbsh() { adb -s "$TUNER_IP" shell "$@" 2>/dev/null | tr -d '\r'; }
now_ms() { echo $(( $(date +%s%N) / 1000000 )); }

# One round trip per poll. Emits the resource_manager dump, then a marker, then
# the first PlaybackState line. No window/focus lookup: which app is in front
# is unstable exactly during a tune, and nothing below needs to know.
pollDevice() {
    adbsh 'dumpsys media.resource_manager; echo __MS__; dumpsys media_session | grep -m1 PlaybackState'
}

# The client id of whichever secure video decoder is allocated, or nothing.
# Android hands out a new client per playback session, so a *changed* id is the
# signal -- "a decoder exists" is not, since the previous channel's decoder is
# still allocated when this script starts.
secureCodecId() {
    printf '%s\n' "$1" | awk '
        /^[[:space:]]*Process Pid override/ { exit }
        /^[[:space:]]*Events logs/          { exit }
        /^[[:space:]]*Processes:/           { inproc = 1; next }
        !inproc { next }
        /^[[:space:]]*Id:/ { id = $2; next }
        /video-codec/ || /videoCodec/ {
            l = tolower($0)
            if (index(l, "non-secure") == 0 && index(l, "secure") > 0) {
                print id; found = 1; exit
            }
        }
        END { exit found != 1 }'
}

# state=2 stopped, state=3 (or PLAYING(3)) with speed=1 playing. Tested in this
# order because a line can carry both and playing is the stronger claim.
mediaSessionState() {
    case "$1" in
        *state=3*|*"PLAYING(3)"*)
            case "$1" in *speed=1*) echo playing; return ;; esac ;;
    esac
    case "$1" in
        *state=2*) echo stopped; return ;;
    esac
    echo unknown
}

# HDCP-black fallback -- a last resort, not a third opinion.
#
# When protected content plays, HDCP blanks the framebuffer, so a screengrab
# comes back all black. Real signal, expensive to read: screencap plus decode is
# about a second, and when the screen IS black it runs twice with a 0.3s gap to
# reject a single dark transition frame, so ~2.3s. The poll loop blocks for the
# whole of it, which stretches polls from ~0.5s apart to 2-3.5s apart and adds
# that much latency to detection.
#
# So it does not run on every poll. BLACK_CHECK=auto (the default) holds it back
# until BLACK_AFTER seconds have passed with the cheap checks finding nothing --
# a tune that resolves normally never pays for it at all, and a box that comes
# up cold, where neither a decoder nor a media session ever appears, still gets
# a signal instead of running out the clock and streaming the splash screen.
# Set 1 to sample from the start, 0 to disable it outright.
#
# It is blind in one direction: a screen that is merely off is just as black as
# one HDCP has blanked, so on a sleeping box it claims playback that is not
# happening. That is the reason it goes last and starts late.
#
# The range conversion is load-bearing. screencap returns full-range RGB and
# blackframe wants YUV, so ffmpeg's implicit scaler puts pure black at luma 16
# and a threshold of 1 would then match nothing at all.
screenIsBlack() {
    _pct=$(adb -s "$TUNER_IP" exec-out screencap -p 2>/dev/null |
           ffmpeg -hide_banner -nostats -v info -f png_pipe -i - \
                  -vf scale=in_range=full:out_range=full,format=gray,blackframe=amount=0:threshold=1 \
                  -f null - 2>&1 |
           sed -n 's/.*pblack:\([0-9]*\).*/\1/p' | head -1)
    [ -n "$_pct" ] || return 1
    [ "$_pct" -ge "$BLACK_PCT" ]
}
blackScreenPlaying() {
    screenIsBlack || return 1
    sleep 0.3
    screenIsBlack
}

splitRM() { printf '%s\n' "$1" | sed -n '1,/^__MS__$/p' | sed '$d'; }
splitMS() { printf '%s\n' "$1" | sed -n '/^__MS__$/,$p' | sed '1d' | head -1; }

waitForVideo() {
    start=$(now_ms)
    min_ms=$(( MIN_WAIT * 1000 ))
    timeout_ms=$(( TUNE_TIMEOUT * 1000 ))
    black_after_ms=$(( BLACK_AFTER * 1000 ))

    # Snapshot what the previous channel is still holding, so it can be told
    # apart from whatever this tune allocates.
    last_black=-99999
    _b=$(pollDevice)
    BASE_CODEC=$(secureCodecId "$(splitRM "$_b")")
    base_session=$(mediaSessionState "$(splitMS "$_b")")
    [ "$base_session" = "playing" ] && armed=0 || armed=1
    [ -n "$DEBUG" ] && log "baseline codec=${BASE_CODEC:-none} session=${base_session}"

    hits=0
    while :; do
        elapsed=$(( $(now_ms) - start ))
        if [ "$elapsed" -ge "$timeout_ms" ]; then
            log "no playback after $(( elapsed / 1000 ))s (codec=${codec_id:-none} base=${BASE_CODEC:-none} session=${ms_state:-unqueried})"
            return 1
        fi

        out=$(pollDevice)
        codec_id=$(secureCodecId "$(splitRM "$out")")
        ms_state=$(mediaSessionState "$(splitMS "$out")")

        # A decoder that is not the one we started with means new playback.
        if [ -n "$codec_id" ] && [ "$codec_id" != "$BASE_CODEC" ]; then
            state=playing
        else
            state="$ms_state"
            case "$BLACK_CHECK" in
                1) use_black=1 ;;
                0) use_black=0 ;;
                *) [ "$elapsed" -ge "$black_after_ms" ] && use_black=1 || use_black=0 ;;
            esac
            # Rate limited: the capture and decode cost about a second, so
            # unthrottled it would become the poll interval.
            if [ "$use_black" = "1" ] && [ "$state" != "playing" ] &&
               [ $(( elapsed - last_black )) -ge 2000 ]; then
                last_black=$elapsed
                blackScreenPlaying && { state=playing; via_black=1; }
            fi
        fi

        # The session cannot be told apart by identity, so it only counts once
        # it has dropped at least once since we started.
        [ "$state" != "playing" ] && armed=1

        [ -n "$DEBUG" ] && log "t=$(( elapsed / 100 ))ds codec=${codec_id:-none} base=${BASE_CODEC:-none} session=${ms_state} armed=${armed} hits=${hits} -> ${state}"

        if [ "$state" = "playing" ] && [ "$armed" = "1" ] && [ "$elapsed" -ge "$min_ms" ]; then
            hits=$(( hits + 1 ))
            if [ "$hits" -ge "$CONFIRM" ]; then
                sleep "$SETTLE"
                if [ -n "$codec_id" ] && [ "$codec_id" != "$BASE_CODEC" ]; then
                    _via="codec ${codec_id} (was ${BASE_CODEC:-none})"
                elif [ "$via_black" = "1" ]; then
                    _via="hdcp black screen"
                else
                    _via="session ${ms_state}"
                fi
                log "playback detected after $(( elapsed / 1000 ))s via ${_via}, ${hits} confirmations"
                return 0
            fi
        else
            hits=0
            via_black=
        fi

        sleep "$POLL"
    done
}

if [ -n "$TUNER_IP" ]; then
    adb connect "$TUNER_IP" >/dev/null 2>&1
    if ! waitForVideo && [ "$ON_TIMEOUT" = "fail" ]; then
        log "failing the tune rather than streaming whatever is on screen"
        exit 1
    fi
else
    log "TUNER${n}_IP not set -- no gate"
fi

# curl, not ffmpeg: ffmpeg demuxes and analyses the input before it muxes
# anything out, ~5s of setup on this encoder, where curl is a byte pipe at
# ~0.2s. Same bytes either way -- the raw passthrough ah4c already does on its
# network-encoder path.
exec curl -s -N "$ENCODER_URL"

I still may iterate on this a little bit, so I'll note if I make any updates, but this might be something to throw on GitHub.
Update 8/2: On GitHub and fully documented, it's been working great.

Ignore the above. I rewrote this in Go now, and it is so much quicker to tune and just all around better.
It's fully open source, MIT licensed, anyone can use it or modify it. There's a ton of environment variables to change if anyone needs to tweak things. The defaults are working great on my Ospreys, and I'm tuning in about 7-8 seconds on a box that has been used before, and about 10 seconds on a box that was freshly booted. This is from sleep.

I hope this helps some people and makes your streams look prettier.

Edit:

I just want to note that I'm actively auditing this and pushing updates, and have already pushed a couple of new builds to this little package. I'm still running adversarial tests against it to just make sure there are no weird edge cases or anything.

Update: Release 1.0.0 · mackid1993/ah4c-streamgatego · GitHub

I believe I have this in a pretty good, robust place. It's working really, really well, I have to say. I am quite happy with the end result here, after working on this all day. I hope some people get to try this out and it's beneficial.

1 Like

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 updated my little helper app. I was experiencing some buffering, so I made some changes. The main thing is, I bundled curl in rather than using the direct stdout. It seems to have helped quite a bit. Release 1.0.2 · mackid1993/ah4c-streamgatego · GitHub

If anyone's interested in trying it, it's located right here. But it's been working well for me so far, and so far the buffering has stopped, although it was intermittent.

1 Like

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 @mackid1993. 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

The robots make us stronger. :joy:

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

100% - the ways I could reproduce the issue yesterday now work correctly and the tuner releases within a couple seconds! Thank you!

2 Likes

I have to say, this project is just so great. I really hope it doesn't go anywhere. Even if ADBTuner goes open source, this really still needs to stick around. Just so many things it can do, it's basically a blank canvas. I think they complement one another rather than duplicate.

2 Likes

You hit the nail on the head. I utilize ah4c to run my Osprey's and ADBTuner w/Fruitlinks to pick up my ESPN+ stuff to watch & record.

2 Likes

Hey guys, so I tried my hand at implementing a wait for video playback detection feature in ah4c directly similar to what exists in ADB Tuner. I built a test Docker container. It just swaps right in. If anyone's interested in trying it or can help me make sure it works properly for most providers, I tested with my Ospreys and it worked great.

The reason why I did this was the streamgate I built was having some buffering/latency issues, likely because it was piping out to that CMD implementation. I tuned with this last night a couple of times and it was so much better. I plan on putting it through the paces later on today, which is why it's marked as a draft right now.

I would really love some feedback from someone who has YouTube TV because that has a really long high motion splash screen.

The container is at ghcr.io/mackid1993/ah4c:latest. Then in the docker-compose.yml add

- PLAYBACK_DETECTION=${PLAYBACK_DETECTION}
and .env PLAYBACK_DETECTION=TRUE

I would really love feedback on this potential feature addition obviously opt in.

1 Like