Capture Cards give you one piece of what an encoder does. The way they're used to create virtual tuners in Channels is by pairing the Capture Card with a dedicated CDVR Custom Channel Source that transcodes the output from the Capture Card into something that resembles what you can get from an encoder.
In other words, a Capture Card + a dedicated CDVR Source for transcoding = a virtual encoder.
Real, hardware encoders are nicer overall, as you don't need to use your computer's resources to transcode the output. Plus, they're often multi-port -- and almost always network devices, rather than USB.
Most of the appeal of Capture Cards is their lower price point.
HDCP typically isn't an issue anymore for resolutions of 1080p and lower that rely on HDMI 1.x. However, don't expect the same for 4K resolutions and HDMI 2.x. You'll find HDCP issues with most capture devices at 4K. HDCP issues are not subtle -- if your device isn't compatible, there will be no video.
This was a problem that affected a couple of us when we updated, gracenote ID's were wiped out in FastChannels and re-applying the community gracenote ID's fixed it for us. Sounds like maybe you had something different going on in CDVR.
A batch of Spectrum TV sign-in reliability fixes, prompted by a few of you hitting sign-in trouble recently. Thanks for the reports โ here's what changed:
Spectrum sign-in fixes
Spectrum now gets its own isolated browser profile instead of sharing one with other MVPD logins, and any leftover cookies/localStorage from a previous account are fully scrubbed before a fresh sign-in.
Credentials are now saved before attempting sign-in, so a failed first attempt doesn't leave you having to re-enter them.
Handles the one-time "Welcome to Spectrum TV" Terms & Conditions screen automatically โ this was silently blocking sign-in for anyone who hadn't already clicked through it.
If Spectrum returns its own transient "Feature Unavailable" error, FastChannels now automatically waits and retries a couple of times instead of failing outright โ this cleared up in every case we tested.
New: opt-in debug logging
If you do hit a sign-in issue, there's now a Debug logging toggle in Settings (System Stats card). Flip it on before retrying and the logs will capture a lot more detail about what's happening during the sign-in attempt โ page navigation, token validation, and any errors Spectrum's own servers return. Leave it off day-to-day; it's meant for troubleshooting a specific issue, not for normal use. If you're still having trouble signing in after this update, turning this on and sharing the relevant log lines here will help us track it down faster.
Also in this release
Fixed the Bally Sports scraper after their site restructured its internal page data.
As always, pull the latest image to update. Let us know how it goes!
I started to look what this would take. Not gonna lie boys - as simple as you'd think Channel Number mapping would be, it's one of the biggest nightmares to manage in this project for some reason...
To make sure it matches what you're already using, could you share your DirecTV M3U (or just the lines for the duplicate channels)? I need to know which channel gets which sub-number, e.g. whether the main feed stays plain 245 and West is 245.1, or they're 245.1/245.2, and how the ESPN+ group is ordered. I'd rather match your existing numbering exactly than invent a new one.
I knew that from looking at creating a PR myself for this.
Maybe another way to look at this would be to use same logic the ah4c_directv_grabber.sh uses in Project One-Click, along with a supporting file for missing Gracenote IDs required mostly for missing values for DTV Sat customers:
#!/bin/bash
# ah4c_directv_grabber.sh
# 2026.08.22
script=$(basename "$0" | sed 's/\.sh$//')
scriptDir=$(dirname "$0")
exec 3> /config/$script.debug.log
BASH_XTRACEFD=3
set -x
allchannelsGuideJSON=$(cat)
deepLinks="$1"
# Fallback station ids for callSigns whose own capture has no real Gracenote
# mapping (see stationId below), harvested from other captures over time.
# Shared with adbtuner_directv_grabber.sh - lives alongside the script (not
# /config) so it is tracked in this repo; in the container the script itself
# runs from /config, so scriptDir resolves there automatically at runtime.
# sdCallSignPairs is for the rarer case where an HD/SD pair does not even
# resolve as a pair automatically (neither nameBase nor csBase matches, e.g.
# "American Heroes HD" callSign AHCHD vs "American Heroes Channel" callSign
# AHCH) - a manually confirmed SD callSign -> HD callSign mapping.
stationIdOverridesFile="$scriptDir/directv_grabber_stationids.json"
stationIdOverrides='{}'
sdCallSignPairs='{}'
if [ -f "$stationIdOverridesFile" ]; then
stationIdOverrides=$(jq -c '.overrides // {}' "$stationIdOverridesFile" 2>/dev/null || echo '{}')
sdCallSignPairs=$(jq -c '.sdCallSignPairs // {}' "$stationIdOverridesFile" 2>/dev/null || echo '{}')
fi
generateM3U() {
printf '%s\n' "$allchannelsGuideJSON" \
| jq -r --arg deepLinks "${deepLinks:-true}" --argjson stationIdOverrides "$stationIdOverrides" --argjson sdCallSignPairs "$sdCallSignPairs" '
# channelName is left untouched throughout - "HD"/"SD" stay in the
# displayed name. Pairing an HD entry with its SD counterpart uses two
# signals: the channelName with "HD" stripped (nameBase) and the
# callSign with a trailing "HD" stripped (csBase). nameBase is checked
# first and, when it finds a match, is used exclusively - callSign alone
# is not reliable enough, since two different shows can share one raw
# callSign (e.g. "Angels Broadcast Television" and "...Extra" are both
# callSign "ABTV", only the channelName tells them apart). csBase is
# only consulted as a fallback when no entry shares a nameBase, which is
# the common case for local affiliates whose HD/SD names diverge (e.g.
# "... A3 HD" vs "... SD") but whose callSign does not (e.g. "KTLA").
def isHD: (.channelName | test("(?i)\\bHD\\b")) or (.callSign | test("(?i)HD$"));
def nameBase: (.channelName | gsub("(?i)\\bHD\\b"; "") | gsub(" +"; " ") | sub("^ +"; "") | sub(" +$"; "") | ascii_downcase);
def csBase: (.callSign | if test("(?i)HD$") then .[0:-2] else . end | ascii_downcase);
# DirecTV often has not back-filled a real Gracenote/TMS mapping onto the
# HD row of a pair - externalListingId just echoes the row own ccid
# instead. Treat that as "no real id".
def isFakeId: (.externalListingId == .ccid);
# Within a duplicate channelNumber group, drop a non-HD entry only when
# an HD entry in the same group is its counterpart. Anything left over
# (true alternates, e.g. "ION East" vs "WNBA on ION 1/2/3", or distinct
# feeds like "TNT HD" vs "TNT West HD") falls through unchanged to the
# suffix logic below. When the surviving HD entry has no real station
# id, prefer a previously-harvested id for its own callSign (see
# stationIdOverridesFile) over borrowing from its SD counterpart in this
# same capture - the override is a known-good id, whereas the SD
# counterpart id is merely "not obviously fake" and could itself be
# stale or wrong.
def collapseHDPairs:
. as $g
| ($g | map(select(isHD | not))) as $sdEntries
| ($g | map(select(isHD))) as $hdEntries
| $g | map(
. as $item
| if (isHD | not) then
($item | nameBase) as $n
| ($item | csBase) as $c
| ($sdCallSignPairs[$item.callSign]) as $mappedHdCallSign
| if ($hdEntries | map(select(nameBase == $n)) | length) > 0 then empty
elif ($hdEntries | map(select(csBase == $c)) | length) > 0 then empty
elif ($mappedHdCallSign != null) and ($hdEntries | map(select(.callSign == $mappedHdCallSign)) | length) > 0 then empty
else $item
end
else
($item | nameBase) as $n
| ($item | csBase) as $c
| ($sdEntries | map(select(nameBase == $n))) as $byName
| ($sdEntries | map(select(csBase == $c))) as $byCs
| ($sdEntries | map(select(($sdCallSignPairs[.callSign]) == $item.callSign))) as $byManual
| (if ($byName | length) > 0 then $byName
elif ($byCs | length) > 0 then $byCs
else $byManual
end) as $counterparts
| if ($item | isFakeId) then
($stationIdOverrides[$item.callSign]) as $overrideId
| if ($overrideId != null) then ($item | .externalListingId = $overrideId)
else
($counterparts | map(select(isFakeId | not)) | .[0]) as $goodSd
| if $goodSd then ($item | .externalListingId = $goodSd.externalListingId) else $item end
end
else $item
end
end
);
def isAlt: (.channelName | test("(?i)\\bAlternate\\b"));
def isNumberedAlt: ((isAlt | not) and (.channelName | test("[0-9]+\\s*$")));
def altNum: (.channelName | capture("(?<n>[0-9]+)\\s*$").n);
# For a duplicate channelNumber group left over after HD/SD collapsing:
# the one entry that is neither "Alternate" nor ends in a number is the
# main channel and keeps the bare number. Entries named "... Alternate"
# get sequential .1/.2 suffixes; entries named "... N" (e.g. "WNBA on
# ION 1") get a suffix matching their own trailing number instead of
# their position in the source data. If every entry in the group is
# itself numbered and none stands out as a plain main (e.g. "ESPN+ 1"
# through "ESPN+ 7", with no bare "ESPN+"), treat the lowest-numbered
# entry as the implicit main so the rest still line up with their own
# number. Anything else that does not resolve to exactly one main
# channel falls back to the original order-based suffixing so nothing
# is silently mishandled.
def suffixGroup:
. as $g
| ($g | map(select((isAlt | not) and (isNumberedAlt | not)))) as $mains
| if ($mains | length) == 1 then
$mains[0] as $main
| ($g | map(select(. != $main))) as $alts
| ($alts | map(select(isAlt))) as $explicitAlts
| ($alts | map(select(isNumberedAlt))) as $numberedAlts
| [$main]
+ ($explicitAlts | to_entries | map(.value | .channelNumber = ($main.channelNumber + "." + ((.key + 1) | tostring))))
+ ($numberedAlts | map(.channelNumber = ($main.channelNumber + "." + altNum)))
elif (($mains | length) == 0) and (($g | map(select(isNumberedAlt)) | length) == ($g | length)) then
($g | map(. + {_n: (altNum | tonumber)}) | sort_by(._n)) as $sorted
| ($sorted[0] | del(._n)) as $main
| ($sorted[1:] | map(del(._n) | .channelNumber = ($main.channelNumber + "." + altNum))) as $rest
| [$main] + $rest
else
$g | to_entries | map(
if .key == 0 then .value
else .key as $k | .value | .channelNumber = (.channelNumber + "." + ($k | tostring))
end
)
end;
# A station id under 5 digits is not a real Gracenote/TMS mapping (see
# collapseHDPairs). Fall back to a previously-harvested id for this
# callSign (see stationIdOverridesFile) before giving up and showing "".
def stationId:
(.externalListingId // "" | if test("^[0-9]{5,}$") then . else "" end) as $own
| if $own != "" then $own else ($stationIdOverrides[.callSign] // "") end;
"#EXTM3U",
"",
(
(.channelInfoList
| group_by(.channelNumber | tonumber)
| map(collapseHDPairs)
| map(
if length == 1 then .[0]
else suffixGroup
end
)
| flatten
)[]
| "#EXTINF:-1 channel-id=\"\(.channelNumber)\" channel-number=\"\(.channelNumber)\" tvc-guide-stationid=\"\(stationId)\",\(.channelName)",
"http://{{ .IPADDRESS }}/play/tuner/\(if $deepLinks == "false" then .channelNumber else "\(.callSign)~\(.resourceId)" end)",
""
)
'
}
main() {
generateM3U
}
main
directv_grabber_stationids.json:
{
"_description": "Fallback Gracenote/TMS station ids for callSigns whose own capture returned no real id (externalListingId echoing ccid, or missing). Harvested by cross-referencing directv2_sample.m3u (which had real ids for these callSigns) against directv3_sample.m3u (which did not). Shared by ah4c_directv_grabber.sh and adbtuner_directv_grabber.sh as a last-resort fallback, keyed on callSign. sdCallSignPairs maps a known SD callSign to its HD counterpart callSign for pairs that do not resolve automatically (neither channelName nor callSign match cleanly) - the SD row is dropped like any other confirmed duplicate.",
"_generated": "2026-08-22",
"overrides": {
"A&EHD": "51529",
"ACCUHD": "91994",
"AMCHD": "59337",
"AXSTV": "28506",
"BETHD": "63236",
"BRVOHD": "58625",
"CBSSNHD": "59250",
"CHDR": "101103",
"CNBW": "26849",
"CNeHD": "60048",
"COLROC": "136326",
"COOKHD": "68065",
"CWBY": "10188",
"ESPN2HD": "45507",
"ESPNHD": "32645",
"FOXeHD": "20360",
"FRFMHD": "121949",
"FS1HD": "82547",
"FXHD": "58574",
"GolfHD": "61854",
"HDNM": "33668",
"JTV": "16604",
"MLBNHD": "62081",
"MLBaHD": "107156",
"MS NOW": "64241",
"MTHD": "31046",
"NBAHD": "45526",
"NBCeHD": "20459",
"NFLHD": "45399",
"NwsNnHD": "91096",
"SECHD": "89714",
"SEFM": "14886",
"SEWS": "14765",
"SHRTTV": "67454",
"SMTHHD": "110554",
"TBSWHD": "67890",
"TNTWHD": "61340",
"UPTV": "44940",
"USAHD": "58452",
"VH1HD": "60046",
"WNBAION1": "157762",
"WNBAION2": "157760",
"WNBAION3": "157821"
},
"sdCallSignPairs": {
"AHCH": "AHCHD"
}
}
EDIT: The above is mostly just a fancy jq command, and I would imagine something very similar (or maybe exactly that) can be used in Python.
I noticed that guide data in FastChannels does not have the year listed on movie listings. In that there are many movies with the same and or similar titles, the year is the only way to uniquely identify a movie. I have software that counts on having the year for movies.
Please consider adding the movie year to the guide data.
Hey, I know I said not to worry about this one, but would you mind taking another look?
One thing worth noting it works fine on the regular official Sling TV app, but not through the FastChannels player. Same encoder, same network, same everything just swapping which app plays it.
Happy to test on my end once you've had a chance to tweak things, and if you need any logs from me, just let me know.
I did look into it, and the stream worked fine for me - I watched the logs from Firestick too and all looked good. not saying what you're seeing isn't happening though!
Humor me - try hooking that Firestick directly up to your TV and drive the play event from FC. If you don't see any pixelation/buffering, then we can isolate it being the HDMI encoder.
Thanks for letting me know. I used the search function and managed to fill in the Gracenote ID's on all but 6 channels I cared about.
In case anyone has a method to find them, here are my missing ones:
24 Hour Free Movies
FilmRise Sci-Fi
MGM Presents
MGM Presents Action
MyTime Movie Network
Fubo Movies (not 116999)
There are alternate sources of EPG data that can probably compete with the channels subscription so I believe we aren't supposed discuss them here.
Some of those place have available a 3d or 7d Full Guide that has seemingly all the channels in the world at 32K+. The have a Standard and IPTV version available but you want the Standard version which uses Grancenote IDs. Opening the 6-7Gb file and doing a find for the station name will sometime work. There are often duplicates and presumably there is a tool that will parse this.
I'm personally using Playlist managerfor all these projects. If data is available, i'm getting it. If i need to looking someting up, i'm adding my data and pushing it to Channels.
Hey, wanted to follow up I ran a test without the HDMI encoder at all, just FastChannels Player directly on the TiVo 4K stream, to rule that out. I'm using VLC to send the signal to switch channels on the TiVo, and I'm not using Channels DVR. Still seeing the same issues.
Pulled the device logcat and found something concrete 4,228 HTTP 404 errors on ContainerMediaChunk.load, starting at one point and continuing non-stop for the rest of the 36-minute session roughly 2 per second the whole time
androidx.media3.datasource.HttpDataSource$InvalidResponseCodeException: Response code: 404
at androidx.media3.exoplayer.source.chunk.ContainerMediaChunk.load
Also caught a few other things along the way a DNS resolution failure, an app crash, and some ADB connection timeouts.
Happy to share the full logs if that would help track it down.
@KineticMan@bnhf
I got native channel numbers including subchannels and gracenote IDs ("directly" from DirecTV's api) working. PR incoming! With this nearly all of my channels sans a few subchannels have proper gracenote id's.