Synchronized Closed Captions for Channels

Not too sure whats going on here. Just fired it up for the first time and it crashes with this error

/usr/bin/python: No module named py_captions_for_channels.main; 'py_captions_for_channels' is a package and cannot be directly executed

Which installation process did you use?

1 Like

Docker/Portainer/Intel GPU

what operating system and version?

Lubuntu and I'm running the latest version

Found it — thanks for reporting this, it was a real bug. Not your setup.

The short version: the container's docker-compose file had a leftover developer setting that replaces the app's code inside the container with whatever's in the folder next to your compose file. For a normal git clone install that's harmless, but with Portainer that folder doesn't actually contain the source — so it wiped out the container's entry point and it crashed on startup with that error. Nothing you did wrong.

I just pushed a fix. Once the new image finishes building (check back in an hour or so), here's what to do:

  1. Pull the new image and redeploy the stack in Portainer (or docker compose pull && docker compose up -d if you're on the CLI).
  2. If you're sticking with Portainer, there's now a dedicated Docker / Portainer section in INSTALL.md — worth following since it also covers a couple of Portainer-specific gotchas around the recordings folder path that would've bitten you next (they need to be set as stack environment variables, not just in .env).

Let me know if it still doesn't come up after redeploying and I'll dig further.

After it successfully installs, the next challenge will be to get it to work. I've tested CPU-only with Intel, but I have not fully tested parakeet or groq on an Intel GPU system yet. So, you're in beta territory. Here's the instructions for switching to parakeet after running the installation wizard (The wizard doesn't know about parakeet or groq yet):

Two equivalent ways — no container restart needed either way, since the transcription step runs as a fresh subprocess (python -m py_captions_for_channels.embed_captions) per job that re-reads .env from scratch. Whatever's set takes effect on the next job, not before.

Via the web UI: :gear: Settings → find WHISPER_LOCAL_ENGINE → switch dropdown from faster-whisper to parakeet → Save. PARAKEET_DEVICE should already default to cpu (the only supported value right now).

Via .env directly (or the equivalent Portainer stack var if you went that route):

WHISPER_ENGINE=local
WHISPER_LOCAL_ENGINE=parakeet
PARAKEET_DEVICE=cpu

This only takes effect if WHISPER_ENGINE=local (or if it's on groq and it falls back to local on a failed cloud call)

Ok, thats a bit confusing

All of the environmental variables should be in the docker-compose file then. Having another .env file on top of the environmental variables that already has to be set is confusing AF and unnecessary. I think thats causing all the bugs here

Fixed and tested end-to-end — please give it another try. Redeploy from the current main/latest image; the crash you hit is gone. There's now a dedicated pre-flight script in INSTALL.md that validates your DVR URL and recordings path before deploying, so you shouldn't hit a repeat of the original issue. After it's up, run the in-app Setup Wizard (:gear: gear icon) — it's a required step, not optional, see below. Let me know how it goes.

Neither of the local transcription engines, faster-whisper nor parakeet support the Intel GPU, so CPU is the only option for now. CTranslate2 (Whisper) only supports NVIDA, and the project doesn't have any plans for Intel support at this time. Parakeet supports it, but it doesn't work yet, so that may change soon.

Thumbnail sketch of the actual process:

  1. Portainer Pre-flight setup-portainer-env.sh — validates the DVR is reachable, sets up the recordings mount, writes a config file. Runs before Docker touches anything.
  2. Deploy — paste docker-compose.yml into Portainer, load the config file as environment variables, deploy.
  3. Setup Wizard (:gear: gear icon, web dashboard) — required, not optional. The pre-flight step deliberately doesn't know the mapping between how the DVR server reports a recording's path internally and where that file actually lives inside the container — those two paths are rarely identical. Without running the wizard, the app can start up fine and still fail to find any actual recording — it looks like it's working until you try to process something. The wizard queries the DVR API and auto-detects this mapping for you.
  4. Select a recording from the Recordings list. That produces a one-time manual process. Use the whitelist checkbox to record every future instance of that show or use the whitelist interface itself to create more complex processing rules.
  5. Turn off Dry Run in Settings when ready to go live.

If not using Portainer, use setup-linux.sh and continue the process above from step 3. This also works with Portainer but you don't get full Portainer control (no stack created).

You can try Groq after you set up local transcription. Get a free API key from them, put the key into settings and change the transcription source from local to cloud (Groq).

Actually, I might be able to get some Intel transcription support. I'm building and testing it now. Meanwhile, go ahead with the current image.

Thanks for this! I've been trying for a few days to figure out a way to add Whisper-detected captions after the fact, only to find out you already solved this months ago! I've run a test file, and the captions were noticeably better than what we had (real time ah4c transcription). Nothing against ah4c because it does a great job in real time, matching any live program I've ever seen with captions. It's just the majority of our Channels watching involves pre-recorded shows where the captioning nearly exactly matches the speech instead of lagging by 2-4 seconds.

I noticed what might be my first issue. Trying to add Guy's Grocery Games to the whitelist using the checkbox in Recordings has no effect. I'm assuming the apostrophe is throwing it off. I manually added it as Grocery Games since I think there won't be other matches.

I've also noticed .orig files continue to be created after deselecting the "Keep Original" option, saving and restarting the container. However, I have multiple jobs in progress and am waiting to see if they are auto deleted when all jobs are complete.

I'm looking forward to pairing this with my new ah4c setup from now on. Thanks again!

On some recordings, the audio language is set to "und" in the metadata. I have these settings in .env:

AUDIO_LANGUAGE=eng

# Subtitle language ("same", "none", or ISO code)
SUBTITLE_LANGUAGE=same

# Fallback strategy when language not found
LANGUAGE_FALLBACK=first

Even with this, the "und" is truncated to "un," and captioning fails with:

2026-10-06 12:14:24 [ERROR] pipeline.manual_process::/media/DVR/TV/The Rookie/The Rookie S01E05 2018-11-20 The Roundup 2026-10-06-1059.mpg::20261006-161314-864634: Faster-Whisper transcription failed: 'un' is not a valid language code (accepted language codes: af, am, ar, as, az, ba, be, bg, bn, bo, br, bs, ca, cs, cy, da, de, el, en, es, et, eu, fa, fi, fo, fr, gl, gu, ha, haw, he, hi, hr, ht, hu, hy, id, is, it, ja, jw, ka, kk, km, kn, ko, la, lb, ln, lo, lt, lv, mg, mi, mk, ml, mn, mr, ms, mt, my, ne, nl, nn, no, oc, pa, pl, ps, pt, ro, ru, sa, sd, si, sk, sl, sn, so, sq, sr, su, sv, sw, ta, te, tg, th, tk, tl, tr, tt, uk, ur, uz, vi, yi, yo, zh, yue)

Is there a way in Py Captions to force a language when "und" is detected? For now AI helped me figure out how to change it to eng with ffmpeg:

target="The Rookie S01E01 2018-10-16 Pilot 2026-10-01-1259.mpg"
tmp="The Rookie S01E01 2018-10-16 Pilot 2026-10-01-1259.tmp.mpg"

if ffmpeg -i "$target" -map 0:v -map 0:a -c copy -metadata:s:a:0 language=eng -f mpegts "$tmp"; then
  mv -f "$tmp" "$target"
  echo "Metadata successfully updated."
else
  echo "FFmpeg failed; keeping original."
  rm -f "$tmp"
fi

I'd prefer to either force ah4c to set "eng" when "und" is detected or have Py Captions do the same. It doesn't even need to change metadata like I am. If it can just change "und" to "eng" (or the pre-defined AUDIO_LANGUAGE) when sending the command to Faster-Whisper, I think that would suffice.

Thanks!

Thanks for the detailed report — all three were real bugs, and they're fixed:

"und" audio language: Py Captions was cutting the language tag down to two letters, so und became un. It now converts the tag properly to the code Whisper expects. When the tag is missing or und, it uses your AUDIO_LANGUAGE setting, so with your .env those recordings will be transcribed as English. You won't need the ffmpeg metadata workaround anymore.

.orig files with Keep Original turned off: The setting was saved but never actually used. Now, with it off, the .cc4chan.orig backup is deleted as soon as the captioned file has successfully replaced the recording. .orig files that already exist won't be deleted automatically, so you'll need to remove those by hand. Also note that without the backup, reprocessing a recording will start from the already-captioned file.

Whitelist checkbox and apostrophes: You guessed right — the apostrophe in "Guy's Grocery Games" broke the checkbox. That's fixed. Titles with characters like ., ? or parentheses are now also saved so that they match exactly. Your manual "Grocery Games" entry will keep working, so you don't need to change it.

Glad the captions are working well with your ah4c setup!

The whitelist processor may still have some issues because I have noticed that sometimes a program that I expected to be processed wasn't. That may be the issues you found, or it might be related to the time constraints not working properly. TBD...

Awesome, thanks for the quick response! I wound up transcribing everything I've recorded since I setup ah4c, but it will be nice to have those in place going forward! It also helped me find multiple programs where I recorded enough things in a row to trigger the "are you still watching" from Xfinity. Since I didn't have ah4c setup properly, I wound up with a few hours of the Xfinity guide. So riveting!

Thanks again!

One minor oddity I've noticed is that the captions will display the next upcoming caption long before the words are spoken if there's a good bit of silence. For example, a show goes to commercial with "We'll be right back after these messages" and then the caption reads "Have you ever been in a car accident?" for 10-20 seconds until the screen fades to black, the commercial starts, and the announcer finally says those words. The captions will be in sync until the next period of silence, such as "Welcome back to the show" appearing immediately after the last commercial's final words and remaining until the show actually starts up and the host says that phrase.

It doesn't always happen, but it's frequent enough that I figured I'd point it out in case there's any way to fix it.

Let me know if you'd rather I leave comments/questions in the repo or here. One question I have is if there's any way to run Py Captions against two CDVR instances. I'm in the process of setting my mom up with her own CDVR that uses our server as the m3u and EPG. (She lives in a detached apartment on our She can watch live or start a recording using our tuners with ah4c, but Py Captions won't process the files because they're on a different server. I thought about running another container with her CDVR as the target, but I think that would overload the GPU if both containers were trying to process a recording at the same time. It would be great if somehow I could add both servers to the one Py Captions instance, allowing it to internally sequence caption detection one at a time no matter which server hosts the recording.

I made some updates addressing requests by @tanderson92 as well as a couple of my own. The multi-server support is not tested yet. I'll get around to that soon, but it's included if you want to beta test for me :slight_smile: That's something that I considered a while ago because I'm running two DVR servers, one with a decent GPU and the other not. I solved that problem by pointing the second server to the first one as imported content. That works for some recordings, but not for anything only available on the second server. This solution should do the job. It requires that you run all instances of the captioning program on the server with the GPU. Here's the full description of the updates:

py-captions-for-channels update: caption timing fixes and multi-DVR GPU sharing

Caption fixes

  • Captions no longer drift early on Chrome Capture (CC4C) recordings. Browser-captured recordings drop tiny bits of audio hundreds of times an hour, mostly around ad breaks. That made captions creep earlier through the show, by several seconds by the end of an hour. Those drops are now corrected. On a test recording, a caption that used to show nearly 6 seconds early now lands within about 0.3 seconds of the speech.
  • Captions no longer show up early or hang around through silences. Thanks to the forum report on this: after a commercial break or a pause, a caption could appear 10–20 seconds before the words were spoken, or stay on screen through the silence. Captions now start when the first word is spoken and clear about a second after the last one. A new setting, Word-Level Caption Timing, is on by default.
  • Fewer awkward line breaks. Captions used to be forced onto a second line at 42 characters, which often left a single word alone on line two. The player now wraps lines to fit your screen.
  • No more half-screen walls of text. Long stretches of speech are split into shorter captions that follow one another. A new Caption Lines setting (default 2) controls how much text each caption holds.

Several Channels DVRs, one GPU

If you have more than one Channels DVR but only one machine with a good GPU, you can now run one instance per DVR on that machine. Each instance has its own settings and web UI, and they take turns on the GPU automatically.

  • To add one: run the same installer you used the first time and choose add another instance. This works with the Linux installer, the Windows installer (choose A when it says ports are in use), Docker Desktop and Portainer. The installer sets up a separate folder, container name, ports and network-share mount for the new instance, and offers to update the existing one.
  • What you'll see: a job queued while another instance is using the GPU shows Waiting for GPU (in use by …), then starts when the GPU is free. Jobs from different DVRs are handled in the order they arrive.
  • DVRs running on Windows work too: share the recordings folder with read/write access and point the installer at it.
  • Full guide: docs/MULTI_INSTANCE.md

Also fixed: a single instance could sometimes run two jobs at once, for example a manual reprocess alongside an automatic job, which split the GPU and slowed both down. Jobs now run one at a time.

Upgrading

  • Compose / Linux / Windows installs: run git pull, then docker compose pull && docker compose up -d in your install folder.
  • Portainer: replace the stack's compose file with the current docker-compose.yml, then update the stack with Re-pull image turned on.

Recordings that were already captioned keep their old timing until you reprocess them. If you kept the originals, you can reprocess from the web UI.

Wow fantastic update, thanks again! I setup my second instance and have been putting it through its paces. I tried to have manual and scheduled recordings on both servers, and so far it's been taking turns without any GPU OOM or other errors. I haven't had time to actually watch anything yet because I'm out of town another few days, but when I get back I'm looking forward to seeing the new caption timings.

This has really been a game changer. Thanks again for your hard work.