Have you tried a factory reset? I'd suggest trying that before a re-flash. Just be sure you know what settings need to be changed after.
You NAILED IT!
Got the stream back. For others stumbling into this issue, try doing the factory reset.
I cant tell you how many times in the past Ive hot plugged that device and have never seen this.
Now to resurrect all my settings, but I see the stream in the dash
edit:
As far as the brand new LinkPi unit, it came with firmware rev 5.0. I went and tried every firmware update up to latest 5.3.0 build 20260731 and still not working with the Hagibus. Will report back tomorrow after the other brand comes here.
Thank You bnhf and mackid1993 for the scripts to load ah4c. If you recall I'm the person who lost the ability to access my direct tv devices using ADB. I loaded ah4c successfully. What are my next steps to add a tuner without ADB access. Thank you for any assistance.
I mean, generally, there aren't. You need ADB. There's something wrong with your Osprey boxes. I can tell you that with near certainty. I would reset them.
Do you prefer issues to be left here or on github? I was about to leave an issue about tuning but noticed there are no issues there. Here's the short version: I can manually tune a channel with adb -s 192.168.99.35:5555 shell am start -n com.xfinity.cloudtvr.tenfoot/com.xfinity.common.view.LaunchActivity https://www.xfinity.com/stream/live/watch/6890122293828367105/WE%20HD.
ah4c is sending this command: adb -s 192.168.99.35:5555 shell am start -n com.xfinity.cloudtvr.tenfoot/com.xfinity.common.view.LaunchActivity https://www.xfinity.com/stream/live/WE HD/6890122293828367105/WE HD. This results in Warning: Activity not started, its current task has been brought to the front.
The spaces in the name make it treat the URL as 3 separate arguments. The following command works:
adb -s 192.168.99.35:5555 shell am start -n com.xfinity.cloudtvr.tenfoot/com.xfinity.common.view.LaunchActivity "https://www.xfinity.com/stream/live/WE%20HD/6890122293828367105/WE%20HD"
where
adb -s 192.168.99.35:5555 shell am start -n com.xfinity.cloudtvr.tenfoot/com.xfinity.common.view.LaunchActivity "https://www.xfinity.com/stream/live/WE HD/6890122293828367105/WE HD"
does not.
It seems like it's not a matter of enclosing it in quotes. Spaces and other special characters need to be replaced with %20, etc. This also applies to & which needs to be encoded as %26. I haven't tried to record A&E yet but suspect it has a chance of being an issue.
This is my first time testing Xfinity after spending the last few days capturing the Channel IDs so that I could make an m3u with almost all of my Xfinity channels. Please let me know if I'm missing something easy. For reference, here's the section of the m3u for WE HD:
#EXTINF:-1 channel-id="817" channel-number="817" tvc-guide-stationid="59296" tvg-group="HD" tvg-logo="https://tmsimg.fancybits.co/assets/s59296_ll_h15_ab.png?w=360&h=270",WEHD
http://{{ .IPADDRESS }}/play/tuner/WE%HD-6890122293828367105
Incidentally, I plan to share my entire m3u in case it helps anyone else once I nail down the issues like this. I'm off to test it with a channel with no spaces to see if that works. Thanks again for the software. I'm ready to have my channels back again!
Can you share your scripts?
Sure, these were all in /scripts/firetv/xfinity. I've been using the software for all of 5 days, so there's a better than average chance I haven't configured something properly.
#!/bin/bash
# bmitune.sh for firetv/xfinity
# 2025.03.13
#Debug on if uncommented
set -x
#Global
dvr="$CHANNELSIP:8089"
channelNameID="$1"
channelID=$(echo $1 | awk -F- '{print $2}')
channelName=$(echo $1 | awk -F- '{print $1}')
specialID="$channelName"
streamerIP="$2"
streamerNoPort="${streamerIP%%:*}"
adbTarget="adb -s $streamerIP"
packageName=com.xfinity.cloudtvr.tenfoot
packageAction=com.xfinity.common.view.LaunchActivity
m3uName="${STREAMER_APP#*/*/}.m3u"
m3uChannelID=$(grep -B1 "/play/tuner/$channelNameID" "/opt/m3u/$m3uName" | awk -F 'channel-id="' 'NF>1 {split($2, a, "\""); print a[1]}')
channelNumber=$(curl -s http://$dvr/api/v1/channels | jq -r '.[] | select(.id == "'$m3uChannelID'") | .number')
[[ $SPEED_MODE == "" ]] && speedMode="false" || speedMode="$SPEED_MODE"
read -a autoCropChannels <<< "$AUTOCROP_CHANNELS"
#Trap end of script run
finish() {
echo "bmitune.sh is exiting for $streamerIP with exit code $?"
}
trap finish EXIT
updateReferenceFiles() {
# Handle cases where stream_stopped or last_channel don't exist
mkdir -p $streamerNoPort
[[ -f "$streamerNoPort/stream_stopped" ]] || echo 0 > "$streamerNoPort/stream_stopped"
[[ -f "$streamerNoPort/last_channel" ]] || echo 0 > "$streamerNoPort/last_channel"
# Write PID for this script to bmitune_pid for use in stopbmitune.sh
echo $$ > "$streamerNoPort/bmitune_pid"
echo "Current PID for this script is $$"
}
#Set encoderURL based on the value of streamerIP
matchEncoderURL() {
case "$streamerIP" in
"$TUNER1_IP") encoderURL=$ENCODER1_URL ;;
"$TUNER2_IP") encoderURL=$ENCODER2_URL ;;
"$TUNER3_IP") encoderURL=$ENCODER3_URL ;;
"$TUNER4_IP") encoderURL=$ENCODER4_URL ;;
"$TUNER5_IP") encoderURL=$ENCODER5_URL ;;
"$TUNER6_IP") encoderURL=$ENCODER6_URL ;;
"$TUNER7_IP") encoderURL=$ENCODER7_URL ;;
"$TUNER8_IP") encoderURL=$ENCODER8_URL ;;
"$TUNER9_IP") encoderURL=$ENCODER9_URL ;;
*) exit 1 ;;
esac
}
#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"
echo "Killing app and re-tuning..."
$adbTarget shell am force-stop $packageName; sleep 2
tuneChannel
exit 0
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
}
tuneCheck() {
sleep 40
ffmpeg -i $encoderURL -frames:v 1 -y $streamerNoPort/screencapture.jpg -loglevel quiet
tesseract $streamerNoPort/screencapture.jpg $streamerNoPort/screencapture
grep -q "Filter\|Today" $streamerNoPort/screencapture.txt
if [ $? == 0 ]; then
echo "Deeplink tuning appears to have failed. Killing app and re-tuning..."
$adbTarget shell am force-stop $packageName
tuneChannel
else
echo "Deeplink tuning appears to have been successful!"
fi
}
appFocusCheck() {
appFocus=$($adbTarget shell dumpsys window windows | grep -E 'mCurrentFocus' | cut -d '/' -f1 | sed 's/.* //g')
if [[ $appFocus == $packageName ]]; then
return 0
else
return 1
fi
}
#Special channels to kill app or reboot FireStick
specialChannels() {
if [ $specialID = "exit" ]; then
echo "Exit $packageName requested on $streamerIP"
rm $streamerNoPort/last_channel $streamerNoPort/adbAppRunning
$adbTarget shell am force-stop $packageName
exit 0
elif [ $specialID = "reboot" ]; then
echo "Reboot $streamerIP requested"
rm $streamerNoPort/last_channel $streamerNoPort/adbAppRunning
$adbTarget reboot
exit 0
elif [[ -f $streamerNoPort/adbCommunicationFail ]]; then
rm $streamerNoPort/adbCommunicationFail
exit 1
else
echo "Not a special channel (exit nor reboot)"
#if appFocusCheck; then
#echo "$packageName is the app in focus, OK to tune"
#fi
fi
}
#Variable delay based on whether app was running or needed to be launched
#and whether less than maxTime seconds (maxTime/3600 for hours) has passed while sleeping
launchDelay() {
local lastChannel
local lastAwake
local timeNow
local timeElapsed
local maxTime=14400
lastChannel=$(<"$streamerNoPort/last_channel")
lastAwake=$(<"$streamerNoPort/stream_stopped")
timeNow=$(date +%s)
timeElapsed=$(($timeNow - $lastAwake))
if (( $lastChannel == $specialID )) && (( $timeElapsed < $maxTime )); then
echo "Last channel selected on this tuner, no channel change required"
exit 0
elif [ -f $streamerNoPort/adbAppRunning ] && (( $timeElapsed < $maxTime )); then
activeAudioCheck
#sleep 14
rm $streamerNoPort/adbAppRunning
echo $specialID > "$streamerNoPort/last_channel"
else
activeAudioCheck
#sleep 32
echo $specialID > "$streamerNoPort/last_channel"
fi
}
#Tuning is based on deeplink values from xfinity.m3u.
tuneChannel() {
$adbTarget shell am start -n $packageName/$packageAction https://www.xfinity.com/stream/live/$channelName/$channelID/$channelName
echo -e "#!/bin/bash\n\nwhile true; do sleep $KEEP_WATCHING; $adbTarget shell input keyevent KEYCODE_DPAD_DOWN; done" > ./$streamerNoPort/keep_watching.sh && chmod +x ./$streamerNoPort/keep_watching.sh
[[ $KEEP_WATCHING ]] && nohup ./$streamerNoPort/keep_watching.sh &
}
#For Xfinity channels defined in AUTOCROP_CHANNELS, that have black bars on 4 sides, this function determines crop values for LinkPi Encoders. Standard aspect ratios are used.
determineCrop() {
sleep 40
maxRetries=5
attempt=0
cropX=0
while (( cropX == 0 || cropX > 250 )); do
((attempt++))
echo "CropX ($cropX) is currently equal to 0 greater than 250..."
if (( attempt >= maxRetries )); then
echo "No sensible crop values found in $maxRetries retries. No autocrop applied."
return 1
fi
sleep 5 # Wait for 5 seconds before rechecking
# Re-run crop detection
cropValues=$(ffmpeg -i "$encoderURL" -vf "cropdetect=limit=40:round=2:reset=1" -t 5 -loglevel verbose -f null - 2>&1 | grep -o 'crop=[0-9]*:[0-9]*:[0-9]*:[0-9]*' | tail -n 1)
# Re-extract crop values
cropX=$(echo "$cropValues" | sed -n 's/.*crop=[0-9]*:[0-9]*:\([0-9]*\):[0-9]*/\1/p')
done
cropWidth=$(echo "$cropValues" | sed -n 's/.*crop=\([0-9]*\):[0-9]*:[0-9]*:[0-9]*/\1/p')
cropHeight=$(echo "$cropValues" | sed -n 's/.*crop=[0-9]*:\([0-9]*\):[0-9]*:[0-9]*/\1/p')
#cropX=$(echo "$cropValues" | sed -n 's/.*crop=[0-9]*:[0-9]*:\([0-9]*\):[0-9]*/\1/p')
cropY=$(echo "$cropValues" | sed -n 's/.*crop=[0-9]*:[0-9]*:[0-9]*:\([0-9]*\)/\1/p')
# Calculate the detected aspect ratio
detectedAspectRatio=$(echo "scale=6; $cropWidth / $cropHeight" | bc)
# List of standard aspect ratios
declare -A aspectRatios=(
#["1.33"]="1.3333" # 4:3
#["1.66"]="1.6667" # European Widesceen
["1.78"]="1.7778" # 16:9
["1.85"]="1.8500" # Cinema Widescreen
#["2.00"]="2.0000" # Netflix Originals
["2.20"]="2.2000" # 70mm
["2.35"]="2.3500" # CinemaScope
["2.39"]="2.3900" # Modern Widescreen
)
# Find the closest aspect ratio
closestAspectRatio=""
minDiff="1000.0"
for aspectRatio in "${!aspectRatios[@]}"; do
ratio=${aspectRatios[$aspectRatio]}
ratioDiff=$(echo "scale=6; ($detectedAspectRatio - $ratio) ^ 2" | bc)
if (( $(echo "$ratioDiff < $minDiff" | bc -l) )); then
minDiff=$ratioDiff
closestAspectRatio=$ratio
fi
done
echo "Detected Aspect Ratio: $detectedAspectRatio"
echo "Using Closest Standard Aspect Ratio: $closestAspectRatio"
cropLeft=$(echo "$cropX" | awk '{print int(($1+1)/2)*2}')
cropRight=$cropLeft
cropTop=$(echo "scale=6; $cropLeft / $closestAspectRatio" | bc | awk '{print int(($1+1)/2)*2}')
cropBottom=$cropTop
echo "Aspect Ratio of Cropped Area: $closestAspectRatio"
echo "Pixels to Crop from Left: $cropLeft"
echo "Pixels to Crop from Right: $cropRight"
echo "Pixels to Crop from Top: $cropTop"
echo "Pixels to Crop from Bottom: $cropBottom"
}
currentAiring() {
while true; do
currentAiringJSON=$(curl -s "http://$dvr/devices/ANY/guide/now?time=$(date +%s)" |
jq '[.[] | .Airings[] | select(.Channel == "'$channelNumber'") |
{
"Channel": .Channel,
"Title": .Title,
"StartTime": .Time,
"Duration": .Duration,
"EndTime": (.Time + .Duration)
}
]'
)
currentTime=$(date +%s)
currentAiringEnd=$(echo "$currentAiringJSON" | jq -r '.[0].EndTime')
[ "$currentAiringEnd" -gt "$currentTime" ] && sleep $((currentAiringEnd - currentTime)) \
&& linkpiCrop 0 0 0 0
sleep 90
[ "$currentAiringEnd" -gt "$currentTime" ] && determineCrop && linkpiCrop $cropLeft $cropRight $cropTop $cropBottom
done
}
linkpiCrop() {
linkpiStreamID=$(echo "$encoderURL" | awk -F'stream' '{print $2}')
linkpiCropLeft="\"$1\""
linkpiCropRight="\"$2\""
linkpiCropTop="\"$3\""
linkpiCropBottom="\"$4\""
linkpiPasswordMD5=$(echo -n admin | md5sum | awk '{print $1}')
linkpiJSON=$(curl -v -c linkpi_cookie --digest -u "$LINKPI_USERNAME:admin" "http://$LINKPI_HOSTNAME/link/user/lph_login?username=$LINKPI_USERNAME&passwd=$linkpiPasswordMD5")
lHash=$(echo "$linkpiJSON" | jq -r '.data."L-HASH"')
pHash=$(echo "$linkpiJSON" | jq -r '.data."P-HASH"')
hHash=$(echo "$linkpiJSON" | jq -r '.data."H-HASH"')
# echo "L-HASH: $lHash"
# echo "P-HASH: $pHash"
# echo "H-HASH: $hHash"
curl -s --digest -u "$LINKPI_USERNAME:admin" -b linkpi_cookie -X POST -L http://$LINKPI_HOSTNAME/link/encoder/set_cap_chns \
-H "L-HASH: $lHash" \
-H "P-HASH: $pHash" \
-H "H-HASH: $hHash" \
-H "Content-Type: application/json" \
-d '[{"id":'$linkpiStreamID', "L":'$linkpiCropLeft', "R":'$linkpiCropRight', "T":'$linkpiCropTop', "B":'$linkpiCropBottom'}]'
curl -s --digest -u "$LINKPI_USERNAME:admin" -b linkpi_cookie -L http://$LINKPI_HOSTNAME/link/user/lph_logout \
-H "L-HASH: $lHash" \
-H "P-HASH: $pHash" \
-H "H-HASH: $hHash"
}
buildCurrentAiring() {
mkdir -p /opt/$encoderIP
{
echo -e "#!/bin/bash\n"
echo "dvr=$dvr"
echo "encoderURL=$encoderURL"
echo -e "channelNumber=$channelNumber\n"
declare -f currentAiring
echo
declare -f determineCrop
echo
declare -f linkpiCrop
echo -e "\ncurrentAiring\n"
} > "/opt/$encoderIP/$encoderStreamNumber.sh"
chmod +x "/opt/$encoderIP/$encoderStreamNumber.sh"
nohup "/opt/$encoderIP/$encoderStreamNumber.sh" &
}
main() {
updateReferenceFiles
matchEncoderURL && encoderStreamNumber="${encoderURL##*/}" && encoderIP="${encoderURL#*//}" && encoderIP="${encoderIP%%:*}"
specialChannels
#launchDelay
tuneChannel
printf "%s\n" "${autoCropChannels[@]}" | grep -qx "$channelNumber" \
&& determineCrop && linkpiCrop $cropLeft $cropRight $cropTop $cropBottom && buildCurrentAiring
#[[ $speedMode == "true" ]] && activeAudioCheck 40 false 5 1 || [[ $speedMode == "false" ]] # (maxDuration, preTuneAudioCheck, sleepBeforeAudioCheck, sleepAfterAudioCheck)
#tuneCheck
:
}
main
#!/bin/bash
# prebmitune.sh for firetv/xfinity
# 2025.01.25
#Debug on if uncommented
set -x
streamerIP="$1"
streamerNoPort="${streamerIP%%:*}"
adbTarget="adb -s $streamerIP"
mkdir -p $streamerNoPort
#Trap end of script run
finish() {
echo "prebmitune.sh is exiting for $streamerIP with exit code $?"
}
trap finish EXIT
adbConnect() {
adb connect $streamerIP
local -i adbMaxRetries=2
local -i adbCounter=0
while true; do
$adbTarget shell input keyevent KEYCODE_WAKEUP
local adbEventSuccess=$?
if [[ $adbEventSuccess -eq 0 ]]; then
break
fi
if (($adbCounter > $adbMaxRetries)); then
touch $streamerNoPort/adbCommunicationFail
echo "Communication with $streamerIP failed after $adbMaxRetries retries"
exit 1
fi
sleep 1
((adbCounter++))
done
}
# adbWake() {
# #packageLaunch="tv.youi.clientapp.AppActivity"
# packageName="com.sling"
# packagePID=$($adbTarget shell pidof $packageName)
# if [ ! -z $packagePID ]; then
# $adbTarget shell input keyevent KEYCODE_WAKEUP
# $adbTarget shell am start -n $packageName/$packageLaunch
# echo "Waking $streamerIP"
# touch $streamerNoPort/adbAppRunning
# else
# $adbTarget shell input keyevent KEYCODE_WAKEUP
# $adbTarget shell am start -n $packageName/$packageLaunch
# echo "Starting $packageName on $streamerIP"
# fi
# }
main() {
adbConnect
#adbWake
}
main
#!/bin/bash
# stopbmitune.sh for firetv/xfinity
# 2025.03.14
#Debug on if uncommented
set -x
dvr="$CHANNELSIP:8089"
streamerIP="$1"
streamerNoPort="${streamerIP%%:*}"
channelNameID="$2"
adbTarget="adb -s $streamerIP"
packageName="com.xfinity.cloudtvr.tenfoot"
m3uName="${STREAMER_APP#*/*/}.m3u"
m3uChannelID=$(grep -B1 "/play/tuner/$channelNameID" "/opt/m3u/$m3uName" | awk -F 'channel-id="' 'NF>1 {split($2, a, "\""); print a[1]}')
channelNumber=$(curl -s http://$dvr/api/v1/channels | jq -r '.[] | select(.id == "'$m3uChannelID'") | .number')
[[ $SPEED_MODE == "" ]] && speedMode="false" || speedMode="$SPEED_MODE"
read -a autoCropChannels <<< "$AUTOCROP_CHANNELS"
printf "%s\n" "${autoCropChannels[@]}" | grep -qx "$channelNumber" && croppedChannel="true"
#Set encoderURL based on the value of streamerIP
matchEncoderURL() {
case "$streamerIP" in
"$TUNER1_IP") encoderURL=$ENCODER1_URL ;;
"$TUNER2_IP") encoderURL=$ENCODER2_URL ;;
"$TUNER3_IP") encoderURL=$ENCODER3_URL ;;
"$TUNER4_IP") encoderURL=$ENCODER4_URL ;;
"$TUNER5_IP") encoderURL=$ENCODER5_URL ;;
"$TUNER6_IP") encoderURL=$ENCODER6_URL ;;
"$TUNER7_IP") encoderURL=$ENCODER7_URL ;;
"$TUNER8_IP") encoderURL=$ENCODER8_URL ;;
"$TUNER9_IP") encoderURL=$ENCODER9_URL ;;
*) exit 1 ;;
esac
}
linkpiCrop() {
linkpiStreamID=$(echo "$encoderURL" | awk -F'stream' '{print $2}')
linkpiCropLeft="\"$1\""
linkpiCropRight="\"$2\""
linkpiCropTop="\"$3\""
linkpiCropBottom="\"$4\""
linkpiPasswordMD5=$(echo -n admin | md5sum | awk '{print $1}')
linkpiJSON=$(curl -v -c linkpi_cookie --digest -u "$LINKPI_USERNAME:admin" "http://$LINKPI_HOSTNAME/link/user/lph_login?username=$LINKPI_USERNAME&passwd=$linkpiPasswordMD5")
lHash=$(echo "$linkpiJSON" | jq -r '.data."L-HASH"')
pHash=$(echo "$linkpiJSON" | jq -r '.data."P-HASH"')
hHash=$(echo "$linkpiJSON" | jq -r '.data."H-HASH"')
# echo "L-HASH: $lHash"
# echo "P-HASH: $pHash"
# echo "H-HASH: $hHash"
curl -s --digest -u "$LINKPI_USERNAME:admin" -b linkpi_cookie -X POST -L http://$LINKPI_HOSTNAME/link/encoder/set_cap_chns \
-H "L-HASH: $lHash" \
-H "P-HASH: $pHash" \
-H "H-HASH: $hHash" \
-H "Content-Type: application/json" \
-d '[{"id":'$linkpiStreamID', "L":'$linkpiCropLeft', "R":'$linkpiCropRight', "T":'$linkpiCropTop', "B":'$linkpiCropBottom'}]'
curl -s --digest -u "$LINKPI_USERNAME:admin" -b linkpi_cookie -L http://$LINKPI_HOSTNAME/link/user/lph_logout \
-H "L-HASH: $lHash" \
-H "P-HASH: $pHash" \
-H "H-HASH: $hHash"
}
#Check if bmitune.sh is done running
bmituneDone() {
bmitunePID=$(<"$streamerNoPort/bmitune_pid")
keepWatchingPID=$(pgrep -f "$streamerNoPort/keep_watching.sh")
#keepWatchingPPID=$(ps -o ppid= -p "$keepWatchingPID")
keepWatchingCPID=$(pgrep -P $keepWatchingPID)
if [[ $croppedChannel ]]; then
currentAiringPID=$(pgrep -f "$encoderIP/$encoderStreamNumber.sh")
#currentAiringPPID=$(ps -o ppid= -p "$currentAiringPID")
currentAiringCPID=$(pgrep -P $currentAiringPID)
fi
while ps -p $bmitunePID > /dev/null; do
echo "Waiting for bmitune.sh to complete..."
sleep 2
done
#[[ $KEEP_WATCHING ]] && kill $keepWatchingPID && pkill -P $keepWatchingCPID
[[ $KEEP_WATCHING ]] && kill $keepWatchingPID $keepWatchingCPID
rm ./$streamerNoPort/keep_watching.sh
#[[ $croppedChannel ]] && linkpiCrop 0 0 0 0 && kill $currentAiringPID && pkill -P $currentAiringCPID
[[ $croppedChannel ]] && linkpiCrop 0 0 0 0 && kill $currentAiringPID $currentAiringCPID
rm ./$encoderIP/$encoderStreamNumber.sh
}
#Stop stream
adbStop() {
[[ $speedMode == "true" ]] \
&& stop="input keyevent KEYCODE_BACK; \
input keyevent KEYCODE_HOME" \
|| stop="am force-stop $packageName"
$adbTarget shell $stop; sleep 2
echo "Streaming stopped for $streamerIP"
}
#Device sleep
adbSleep() {
sleep="input keyevent KEYCODE_SLEEP"
$adbTarget shell $sleep
echo "Sleep initiated for $streamerIP"
date +%s > $streamerNoPort/stream_stopped
echo "$streamerNoPort/stream_stopped written with epoch stop time"
}
main() {
[[ croppedChannel ]] && matchEncoderURL && encoderStreamNumber="${encoderURL##*/}" \
&& encoderIP="${encoderURL#*//}" && encoderIP="${encoderIP%%:*}"
bmituneDone
adbStop
adbSleep
}
main
True callsigns never have spaces. It looks like you're trying to use the channel name maybe, or something similar. If you look at xfinity.m3u from the repo, you'll see there are no spaces.
Also, this is the URL form I've I used for the last three years:
<adbtarget> shell am start -n <packageName>/<packageAction> https://www.xfinity.com/stream/live/<callSign>/<channelID>/<callSign>
The problem is when I try to tune with
adb -s 192.168.99.35:5555 shell am start -n com.xfinity.cloudtvr.tenfoot/com.xfinity.common.view.LaunchActivity "https://www.xfinity.com/stream/live/WEHD/6890122293828367105/WEHD"
it fails because the Xfinity stream URL has that space in it. I've tested multiple iterations, and the only one that works for me is encoding the space to %20. Even putting it in quotes didn't help.
EDIT: Single-quotes did the trick!
adb -s 192.168.99.35:5555 shell am start -n com.xfinity.cloudtvr.tenfoot/com.xfinity.common.view.LaunchActivity 'https://www.xfinity.com/stream/live/WE HD/6890122293828367105/WE HD'
I ran into this with MS NOW having spaces out of nowhere. Quoting helped me like this:
$adbTarget shell "am start -n $packageName/$packageAction 'https://www.xfinity.com/stream/live/$channelName/$channelID/$channelName'"
it fails because the Xfinity stream URL has that space in it. I've tested multiple iterations, and the only one that works for me is encoding the space to %20. Even putting it in quotes didn't help.
Do you have other stations like this? Or, are we just talking about an outlier?
Do you have other stations like this? Or, are we just talking about an outlier?
I have three off the top of my head: A&E, H&I, and WE HD. My version of MSNOW doesn't have a space. I have 206 channels in the list so am not sure if there are others. I just remember correcting those three.
Edit, I also have "E! HD."
I ran into this with MS NOW having spaces out of nowhere. Quoting helped me like this:
$adbTarget shell "am start -n $packageName/$packageAction 'https://www.xfinity.com/stream/live/$channelName/$channelID/$channelName'"
This fixed the issue for me and now WE HD tunes properly. Now the issue I have, no doubt because I configured something incorrectly, is the LinkPi shows the correct channel is playing, but ah4c never receives the stream. All I get is endless pre-roll. I'm sure this is user error. My encoders are setup like:
TUNER1_IP=192.168.99.35:5555
ENCODER1_URL=http://192.168.99.15/live/stream0
CMD1=
TUNER2_IP=192.168.99.45:5555
ENCODER2_URL=http://192.168.99.15/live/stream1
CMD2=
Where http://192.168.99.15/live/stream0 matches what LinkPi shows as the Play URL under the Stream tab. What do I need to change here?
Thanks for the help!
I was just coming to that realization, thanks! I was reading through the new compose and .env trying to decide if I'd update first, when I noticed the :8090 I missed the first time. I just restarted with that in the 09.03 version and will see if that works.
Maybe also try a minimal compose and the new settings UI. Might be easier to setup...
ENCODER1_URL=http://192.168.99.15:8090/stream0
CMD1=
TUNER2_IP=192.168.99.45:5555
ENCODER2_URL=http://192.168.99.15:8090/stream1
Got me the same results. The LinkPi shows it's tuned to the correct channel, but Channels gets only 2:20 of pre-roll before the feed stops due to lack of signal in ah4c. I feel like I'm on the verge of getting this right. Maybe I'll transition to the new web-based compose and setup and make sure everything is copied correctly. I'll also go back to edit line 176 of bmitune.sh to add the quotes around the adb command to tune channels with spaces in them.
$adbTarget shell "am start -n $packageName/$packageAction 'https://www.xfinity.com/stream/live/$channelName/$channelID/$channelName'"
What firmware is your LinkPi on? Also what unit do you have? I had some issues with the July firmware on my ENC5 V2, and I ended up rolling back to the May firmware, and that helped. It almost sounds like what you're experiencing, although without seeing, I can't confirm.
Could you share also your entire compose and .env?
The LinkPi shows it's tuned to the correct channel, but Channels gets only 2:20 of pre-roll before the feed stops due to lack of signal in ah4c.
What encoder codec are you using for output? h264 or h265? And, does your ENCODER_CODEC= value match?
I'm encoding using H.264 on:
App version
5.2.0 build 20260531_3272
SDK version
5.2.0 build 20260531_30036
Sys version
5.2.0 build 20260531
My compose.yaml:
services:
# 2026.09.03
# 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:
# This docker-compose typically requires no editing. Use the Environment variables section of Portainer to set your values.
image: bnhf/ah4c:${TAG:-latest}
container_name: ${CONTAINER_NAME:-ah4c}
hostname: ${HOSTNAME:-ah4c}
dns_search: ${DOMAIN:-localdomain} # Specify the name of your LAN's domain, usually local or localdomain
runtime: ${DOCKER_RUNTIME:-runc} # Closed captions only. Set DOCKER_RUNTIME=nvidia for an NVIDIA GPU with the CUDA engine build. Requires the NVIDIA container toolkit.
devices:
- ${GPU_DEVICE:-/dev/null} # Closed captions only. Set GPU_DEVICE=/dev/dri to let the Vulkan engine build use an Intel or AMD GPU. Left at the default it passes /dev/null, which always exists and does nothing.
ports:
- ${HOST_PORT:-7654}:7654 # Port used by this ah4c proxy
environment:
- AH4C_COMPOSE=2026.09.03 # Compose file date stamp - do not change. The startup log flags it when out of date.
# ── Proxy identity ──────────────────────────────────────────────────────
- IPADDRESS=${IPADDRESS} # Hostname or IP address of this ah4c extension to be used in M3U file (also add port number if not in M3U)
# ── Tuners ──────────────────────────────────────────────────────────────
# One trio per tuner: TUNERn_IP is the box to tune; video comes from CMDn if set, otherwise ENCODERn_URL.
- NUMBER_TUNERS=${NUMBER_TUNERS} # Number of tuners you'd like defined - add a matching TUNERn_IP, ENCODERn_URL and CMDn trio below for each beyond 9
- TUNER1_IP=${TUNER1_IP} # Streaming device #1 with adb port in the form hostname:port or ip:port
- ENCODER1_URL=${ENCODER1_URL} # Tuner #1 source URL (http://hostname/stream). Streamed directly when CMD1 is blank; otherwise referenced by CMD1 as ${ENCODER1_URL}.
- CMD1=${CMD1} # Optional tuner #1 command; ah4c streams its stdout (end it with "-f mpegts -") instead of fetching ENCODER1_URL. No shell.
- TUNER2_IP=${TUNER2_IP} # Streaming device #2 with adb port in the form hostname:port or ip:port
- ENCODER2_URL=${ENCODER2_URL} # Tuner #2 source URL. Streamed directly when CMD2 is blank; otherwise referenced by CMD2 as ${ENCODER2_URL}.
- CMD2=${CMD2} # Optional tuner #2 command; ah4c streams its stdout instead of fetching ENCODER2_URL. See CMD1.
- TUNER3_IP=${TUNER3_IP} # Streaming device #3 with adb port in the form hostname:port or ip:port
- ENCODER3_URL=${ENCODER3_URL} # Tuner #3 source URL. Streamed directly when CMD3 is blank; otherwise referenced by CMD3 as ${ENCODER3_URL}.
- CMD3=${CMD3} # Optional tuner #3 command; ah4c streams its stdout instead of fetching ENCODER3_URL. See CMD1.
- TUNER4_IP=${TUNER4_IP} # Streaming device #4 with adb port in the form hostname:port or ip:port
- ENCODER4_URL=${ENCODER4_URL} # Tuner #4 source URL. Streamed directly when CMD4 is blank; otherwise referenced by CMD4 as ${ENCODER4_URL}.
- CMD4=${CMD4} # Optional tuner #4 command; ah4c streams its stdout instead of fetching ENCODER4_URL. See CMD1.
- TUNER5_IP=${TUNER5_IP} # Streaming device #5 with adb port in the form hostname:port or ip:port
- ENCODER5_URL=${ENCODER5_URL} # Tuner #5 source URL. Streamed directly when CMD5 is blank; otherwise referenced by CMD5 as ${ENCODER5_URL}.
- CMD5=${CMD5} # Optional tuner #5 command; ah4c streams its stdout instead of fetching ENCODER5_URL. See CMD1.
- TUNER6_IP=${TUNER6_IP} # Streaming device #6 with adb port in the form hostname:port or ip:port
- ENCODER6_URL=${ENCODER6_URL} # Tuner #6 source URL. Streamed directly when CMD6 is blank; otherwise referenced by CMD6 as ${ENCODER6_URL}.
- CMD6=${CMD6} # Optional tuner #6 command; ah4c streams its stdout instead of fetching ENCODER6_URL. See CMD1.
- TUNER7_IP=${TUNER7_IP} # Streaming device #7 with adb port in the form hostname:port or ip:port
- ENCODER7_URL=${ENCODER7_URL} # Tuner #7 source URL. Streamed directly when CMD7 is blank; otherwise referenced by CMD7 as ${ENCODER7_URL}.
- CMD7=${CMD7} # Optional tuner #7 command; ah4c streams its stdout instead of fetching ENCODER7_URL. See CMD1.
- TUNER8_IP=${TUNER8_IP} # Streaming device #8 with adb port in the form hostname:port or ip:port
- ENCODER8_URL=${ENCODER8_URL} # Tuner #8 source URL. Streamed directly when CMD8 is blank; otherwise referenced by CMD8 as ${ENCODER8_URL}.
- CMD8=${CMD8} # Optional tuner #8 command; ah4c streams its stdout instead of fetching ENCODER8_URL. See CMD1.
- TUNER9_IP=${TUNER9_IP} # Streaming device #9 with adb port in the form hostname:port or ip:port
- ENCODER9_URL=${ENCODER9_URL} # Tuner #9 source URL. Streamed directly when CMD9 is blank; otherwise referenced by CMD9 as ${ENCODER9_URL}.
- CMD9=${CMD9} # Optional tuner #9 command; ah4c streams its stdout instead of fetching ENCODER9_URL. See CMD1.
# ── Streaming app and DVR ───────────────────────────────────────────────
- 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)
- PYATV=${PYATV:-false} # Set to TRUE to run docker-start-pyatv.sh at container start for Apple TV tuners via pyatv, instead of the default docker-start.sh used for adb-based tuners. Case-insensitive; anything else runs the default.
- CHANNELSIP=${CHANNELSIP} # Hostname or IP address of the Channels DVR server itself
- FASTCHANNELS_URL=${FASTCHANNELS_URL} # Base URL of your FastChannels container, so the firetv/fastchannels scripts can use its built-in ah4c integration to tune. A URL pre-filled into an exported script overrides this.
# ── Failure alerts ──────────────────────────────────────────────────────
# Email via SMTP and/or a webhook GET, sent when a tune fails. Leave blank to disable.
- 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=${ALERT_WEBHOOK_URL} # URL to GET when an alert fires (same failures that trigger the e-mail); put $reason in the URL and it's replaced with the URL-encoded message. Blank disables it.
# ── Guide tuning and content updates ────────────────────────────────────
- LIVETV_ATTEMPTS=${LIVETV_ATTEMPTS} # For FireTV Live Guide tuning only, set maximum number of attempts at finding the desired channel
- CREATE_M3US=${CREATE_M3US:-false} # Set to true to create device-specific M3Us for use with Amazon Prime Premium channels -- requires a FireTV device
- UPDATE_SCRIPTS=${UPDATE_SCRIPTS:-true} # Set to true if you'd like the sample scripts and STREAMER_APP scripts updated whether they exist or not
- UPDATE_M3US=${UPDATE_M3US:-true} # Set to true if you'd like the sample m3us updated whether they exist or not
# ── Timezone and session handling ───────────────────────────────────────
- TZ=${TZ} # Your local timezone in Linux "tz" format
- SPEED_MODE=${SPEED_MODE:-false} # 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.
# ── Autocrop (LinkPi encoders only) ─────────────────────────────────────
- AUTOCROP_CHANNELS=${AUTOCROP_CHANNELS} # Space separated list of channels (by number) with black borders on 4 sides to autocrop while maintaining aspect ratio. Requires LinkPi Encoder!
- LINKPI_HOSTNAME=${LINKPI_HOSTNAME} # Hostname or IP of the LinkPi Encoder's web API. Required for AUTOCROP_CHANNELS.
- LINKPI_USERNAME=${LINKPI_USERNAME} # Username for the LinkPi Encoder's web API. Required for AUTOCROP_CHANNELS.
- LINKPI_PASSWORD=${LINKPI_PASSWORD} # Password for the LinkPi Encoder's web API. Required for AUTOCROP_CHANNELS; not currently read by the bundled scripts, which log in with the LinkPi default password instead.
# ── Custom startup script ───────────────────────────────────────────────
- USER_SCRIPT=${USER_SCRIPT} # Path to a custom script to run alongside ah4c at container startup. Blank runs nothing extra.
# ── Recording continuity and playback timing ────────────────────────────
# Keep recordings starting on the program and free of gaps within the DVR's 30s tune window.
- NULL_FRAME_INSERTION=${NULL_FRAME_INSERTION:-false} # 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. Case-insensitive (true/True/TRUE all work); anything else, including 1/yes, leaves the feature off.
- PLAYBACK_DETECTION=${PLAYBACK_DETECTION:-false} # Set to TRUE to hold the stream until the device reports audio playing and the picture moving, then start on a keyframe, so recording begins on the program, not the loading screen. Requires adb; network tuners only. Case-insensitive; anything but true leaves it off.
- PLAYBACK_STATIC_TIMEOUT=${PLAYBACK_STATIC_TIMEOUT} # Only used with PLAYBACK_DETECTION=TRUE. Seconds the box may keep its prior player/session before the check falls back to gating on motion alone. Default 2 suits Ospreys; apps like DirecTV that hold one player across channel changes need more. 0 or unset uses the default.
- PLAYBACK_DELAY=${PLAYBACK_DELAY} # Hold every tune this long before handing the DVR the program, so a slow-starting app still records within the DVR's 30s window. Black or a mounted pre-roll fills the wait; the box's own video is never passed through. Accepts a bare number (seconds) or a duration like 30s/1m, capped at 10m. Network tuners only. Empty or 0 disables it.
- ENCODER_CODEC=${ENCODER_CODEC:-h264} # The video codec your encoder outputs: h264 (default) or h265. Filler black/pre-roll must match it, or playback won't cross to the program at hand-off. Pre-roll video is converted to match; an existing H.265 stream is copied. Leave at h264 unless your encoder is H.265. Case-insensitive; h265/hevc both work.
- HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL:-180} # In supported scripts (currently osprey), seconds between keepalive keyevents sent during playback to stop the app's UI inactivity timer from resetting the stream. Default 180; set to 0 to disable.
# ── NVIDIA GPU ──────────────────────────────────────────────────────────
# Used by the CUDA caption engine and any CMDn calling h264_nvenc/hevc_nvenc. Needs the NVIDIA container toolkit.
- NVIDIA_VISIBLE_DEVICES=${NVIDIA_VISIBLE_DEVICES} # For the CUDA caption engine and/or an NVENC CMDn. Set to all alongside DOCKER_RUNTIME=nvidia to expose an NVIDIA GPU. Empty means no GPU and is the default.
- NVIDIA_DRIVER_CAPABILITIES=${NVIDIA_DRIVER_CAPABILITIES} # Set to compute,utility for the CUDA caption engine. A CMDn using h264_nvenc/hevc_nvenc also needs video - use compute,utility,video (or all).
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
- ${HOST_DIR}/ah4c/captions:/opt/captions # Closed caption settings, and the speech model, engine and any GPU driver downloaded from the Closed Captions page. Stays empty unless you turn captions on
- ${PREROLL_FILE:-${HOST_DIR}/ah4c/preroll}:/opt/preroll # A video or still image shown to the DVR instead of NULL packets, during a PLAYBACK_DELAY/PLAYBACK_DETECTION hold or a NULL_FRAME_INSERTION stall. Set PREROLL_FILE to a host path, or drop the file into this directory. Anything ffmpeg reads; prepared once at startup, loops until the real stream is ready
restart: unless-stopped
networks: {}
and .env:
TAG=latest
CONTAINER_NAME=ah4c
HOSTNAME=ah4c
DOMAIN=localdomain [xxxx].ts.net
DOCKER_RUNTIME=runc
GPU_DEVICE=/dev/dri
NVIDIA_VISIBLE_DEVICES=
NVIDIA_DRIVER_CAPABILITIES=
HOST_PORT=7654
IPADDRESS=192.168.99.63:7654
NUMBER_TUNERS=5
TUNER1_IP=192.168.99.35:5555
ENCODER1_URL=http://192.168.99.15:8090/stream0
CMD1=
TUNER2_IP=192.168.99.45:5555
ENCODER2_URL=http://192.168.99.15:8090/stream1
CMD2=
TUNER3_IP=
ENCODER3_URL=
CMD3=
TUNER4_IP=
ENCODER4_URL=
CMD4=
TUNER5_IP=
ENCODER5_URL=
CMD5=
TUNER6_IP=
ENCODER6_URL=
CMD6=
TUNER7_IP=
ENCODER7_URL=
CMD7=
TUNER8_IP=
ENCODER8_URL=
CMD8=
TUNER9_IP=
ENCODER9_URL=
CMD9=
STREAMER_APP=scripts/firetv/xfinity
PYATV=false
CHANNELSIP=192.168.99.61
FASTCHANNELS_URL=
ALERT_SMTP_SERVER=
ALERT_AUTH_SERVER=
ALERT_EMAIL_FROM=
ALERT_EMAIL_PASS=
ALERT_EMAIL_TO=
ALERT_WEBHOOK_URL=
LIVETV_ATTEMPTS=
CREATE_M3US=false
UPDATE_SCRIPTS=true
UPDATE_M3US=false
TZ=America/New_York
SPEED_MODE=true
KEEP_WATCHING=235m
AUTOCROP_CHANNELS=
LINKPI_HOSTNAME=
LINKPI_USERNAME=
LINKPI_PASSWORD=
USER_SCRIPT=
NULL_FRAME_INSERTION=false
PLAYBACK_DETECTION=true
PLAYBACK_STATIC_TIMEOUT=12
PLAYBACK_DELAY=
PREROLL_FILE=
ENCODER_CODEC=h264
HEARTBEAT_INTERVAL=
HOST_DIR=/share/docker
I was on the verge of updating to the latest ah4c version but I'll wait in case you come up with something to try before that.