-
Notifications
You must be signed in to change notification settings - Fork 0
Developer Casting Verification
Casting is the one feature in otakase that no automated test can fully
verify. The unit tests cover the pure logic — skip spans, the completion
threshold, device parsing, the server, and watchCast's failure handling
through fakes — but three things need a real device on a real network:
whether the Default Media Receiver accepts what ffmpeg produced, whether it
reports the states the poll loop expects, and whether it can reach this
machine at all.
Run this checklist after any change to internal/cast/ or
internal/cast_playback.go.
Setup: a Chromecast (or anything running the Default Media Receiver — a
Google/Nest Hub, an Android TV with casting on) powered on and on the same
subnet as otakase. ffmpeg on PATH. Then pick an episode as usual and run
otakase -cast.
Expected sequence: Looking for cast devices... → a device menu (skipped if
only one answers, or if CastDevice names one that is found) → Preparing the stream for <device>... → the episode starts within ~30s → Playing on <device>.
-
Listen to the audio for a full minute. Not a glance — listen. A bitstream filter wrong for this container produced perfectly fine video with undecodable audio, while ffmpeg exited 0 and otakase reported a healthy cast.
TestRemuxedStreamDecodesCleanlynow guards it, but only the device's own decoder settles it. -
Video plays without stutter or artefacts.
-
Subtitles appear, on a stream that has them. They are drawn into the picture, so they cannot be turned off from the TV — that is expected.
.assstyling and positioning should look as it does in mpv. -
Check which encoder was used:
grep 'burning subtitles'the debug log. If it fell back tolibx264, the GPU probe failed — worth knowing, since the CPU cost is much higher on a long film. -
The panel is the only thing on the terminal. No text scrolls past it while the episode plays, and anything otakase has to say arrives as a desktop notification instead. Between episodes too: the panel is held for the whole cast, so there is no gap where plain text gets through.
-
Your scrollback survives. After
q, the terminal is as it was before the cast, with the panel gone rather than left behind. -
The panel does not blink between episodes. It is taken once for the cast and held, so the frame is continuous from the first episode to the last. Any flicker at an episode boundary means the screen is still being taken per episode.
-
Resize the window mid-episode. The panel follows within a second, stays centred, and leaves no trail of the old frame.
-
Every control key works: space pauses and resumes, left/right seek, up/down change the TV's volume,
qstops and saves the position. -
Press right five times quickly. The episode must move about fifty seconds, not ten — each press acts on where the last one left it.
-
Pause, then seek. The device resumes on any seek, so the status line must go back to PLAYING rather than staying PAUSED.
-
Lips match the voice after every restart, on a dub and on a sub: from the start, after a resume, and after seeking both ways. A restart re-encodes even with nothing to burn, so on a dub the debug log shows a
cast: re-encoding withline after the first seek. Audio that trails the picture by seconds on a dub only after a seek means a copied restart is back. -
Pause for three minutes. The episode must still be there — the stall bound is two minutes and must be suspended while paused.
- Seeking from the TV remote or the Google Home app is picked up within about a second.
- With
SkipOp/SkipEdon, the opening is jumped automatically — casting resolves its own skip times, because it never reaches the place local playback gets them from. If nothing skips, check the debug log for a skip lookup line before assuming the show has no times. - At
PercentageToMarkComplete(85% by default), otakase printsEpisode N marked as watched.,curd_history.txtgains an entry, and the tracker updates if remote tracking is on. - Not marked early. Check
curd_history.txtdirectly, not just the terminal line — ideally on a slow provider, where the device can catch up to the live edge of a playlist that is still growing.
This is where every bug in this feature has lived. Each of these once
reported Playback finished. instead of the truth.
- Kill ffmpeg mid-episode (
pkill ffmpeg). Expect the real reason, not a clean finish — and checkcurd_history.txtand your tracker afterwards: a truncated episode must not be marked watched. - Cast to a device that cannot reach this machine (guest VLAN, or AP
client isolation). After 30s expect
Casting failed: cast: <device> never started playing -- it may not be able to reach this machine on the network, printed to a real terminal you can actually read. NotPlaying on X.followed byPlayback finished.on a black screen. - Stop the cast from the Google Home app, and separately cast something else to the same device. Expect otakase to notice and say so within the two-minute stall bound, rather than polling a frozen status forever.
- Ctrl+C mid-episode. The terminal must return immediately, the device
must stop,
ps aux | grep ffmpegmust show nothing left, and<StoragePath>/cast-scratch/must be empty.
- Watch the scratch directory's peak size during a long episode or a
film:
du -sh <StoragePath>/cast-scratch/.-c copydownloads far faster than playback, so the whole episode lands on disk within minutes. Confirm that is acceptable on your storage before a 4 GB film surprises you. - After a normal finish, the terminal still shows the session's output —
including
Episode N marked as watched.— rather than a bare prompt.
The single most expensive failure in this feature's history is a cast that works on one day and silently does nothing the next, and the cause is almost always the host firewall rather than the code.
The trap: ufw status lies. It reads the saved config, not the live
kernel ruleset, so a rule can be listed and absent at the same time. The symptom
is a device that accepts LAUNCH and LOAD, then never fetches anything -- no
media session is ever created, which reads like a refused load but is usually a
dropped packet. The server's request log settles it: no requested line means
nothing arrived.
Two mechanisms produce it, and they look identical from here:
-
iptables.servicerestoring a stale snapshot. It runsiptables-restorefrom/etc/iptables/iptables.rulesat boot, which ufw knows nothing about. Whichever service writes last wins, so ufw's rules can be wiped at every boot. Confirmed on this machine in September 2026: the snapshot was dated May, carried:INPUT DROP, held allow-rules for a NordVPN install that had since been removed, and mentioned port 8010 nowhere. ufw reported the cast rule present throughout.On a ufw system this service is redundant -- ufw persists its own rules -- and running both means two owners for one table:
systemctl is-enabled iptables ip6tables # expect disabled on a ufw host sudo systemctl disable --now iptables -
Docker rewriting the filter table when its daemon or any container starts, also without going through ufw. Check
systemctl is-active dockerbefore reaching for this one; it is not the cause when Docker is inactive.
Check which firewalls actually own the table before diagnosing anything:
for s in ufw iptables ip6tables nftables firewalld docker; do
printf '%s: enabled=%s active=%s\n' "$s" \
"$(systemctl is-enabled $s 2>/dev/null || echo n/a)" \
"$(systemctl is-active $s 2>/dev/null || echo n/a)"
doneProve inbound works without spending a cast on it. Bind a plain HTTP server to the LAN address and fetch it from a phone on the same Wi-Fi. This takes the program out of the question entirely -- if this fails, the bug is not in this repo:
python3 -m http.server 8010 --bind <this-machine-lan-ip>
# then open http://<this-machine-lan-ip>:8010/ on a phoneA 200 in that server's output is inbound confirmed. Silence is a dropped
packet.
Check the live chain, not the status:
sudo iptables -L ufw-user-input -n -v | grep -E "8010|48010"An empty result means the rule is in the config and not in the kernel. Re-apply
it with ufw allow rather than editing the rules file, because ufw allow
writes the config and applies it:
sudo ufw allow from 192.168.0.0/24 to any port 8010 proto tcp comment 'otakase cast'Expect to do this again after a Docker restart. sudo iptables -L DOCKER-USER -n -v
shows whether Docker is intercepting ahead of ufw's chain.
Also check the service, not just the file: /etc/nftables.conf on some
systems carries a policy drop input chain that looks entirely guilty, while
nftables.service is disabled and the file is not loaded at all. Confirm with
systemctl is-enabled nftables before blaming it.
And watch the log, not the symptom. A device that plays a public stream but not yours is a network problem. A device that plays neither is a receiver problem. A browser on a phone that shows an error page looks identical whether the packet was dropped or the URL was wrong -- the server's access log is what distinguishes them.
One more measurement trap: a device that still has a previous stream loaded will report it as playing. Stop the media and confirm the receiver is idle before concluding anything from a status reading.
otakase-debug.log under the storage path records what the device did. Three
lines answer almost everything:
-
cast: <ip> requested /playlist.m3u8— the device reached this machine. If no request appears at all, it could not. The usual cause is a host firewall dropping inbound connections; setCastPortand allow that one port rather than the whole ephemeral range. This failure is indistinguishable from a device that never got the load, which is why the request log exists. -
cast: <ip> requested /seg00000.ts— it accepted the manifest. Playlist requests with no segment requests mean it fetched the manifest and refused it. -
cast: device status: ... player=... idleReason=...— what the receiver says about itself.media=<nil>means the receiver is running with no media session at all, which is what a stream it cannot decode looks like.
OTAKASE_CAST_KEEP=1 otakase -cast keeps the stream directory instead of
deleting it, so the segments can be probed after a failure:
ffprobe -v error -show_entries stream=codec_name,profile,level,width,height \
-of default=noprint_wrappers=1 <dir>/seg00000.tsThat directory is the whole episode, so delete it when finished -- half a gigabyte is normal.
Three real failures were found this way, and all three looked identical from
the outside: missing CORS headers (the device fetched the manifest and could
not read it), .ts served as a text type from the system mime database, and a
stream declaring H.264 Level 5.0 to a decoder specified for 4.1.
- Launch from the Hyprland keybind with casting on. A terminal opens, showing the status line and the control keys.
- Every key works there, the same as in a terminal launch.
- Close the window mid-episode, then check each of these separately —
a partial pass is not a pass:
- the TV stops playing
- the position was saved (check
curd_history.txt) - no ffmpeg process survives
-
<StoragePath>/cast-scratch/is empty
- Let an episode complete in the spawned window, then check AniList or
MyAnimeList actually advanced — not just
curd_history.txt. The child process signs in separately from the one that launched it, and local history is written either way, so the terminal line alone cannot tell you remote tracking worked. - Set
CastTerminalto a command that does not exist. The episode still casts, in the launching process, with a message saying why there is no window — a missing terminal must never mean a missing episode. - Check
<StoragePath>/cast-session/is empty afterwards. Those files hold a stream URL with an authentication token.
- Let an episode finish. The panel shows a countdown and the next episode starts on its own.
- Press a key during the countdown. It stops, and otakase exits without starting the next episode.
- Check the tracker advanced for both episodes, not just the first.
- Between episodes, confirm
<StoragePath>/cast-scratch/holds one episode at a time rather than accumulating. - Press
qduring an episode. The next one must not start — stopping is not finishing.
A cast launched from the rofi keybind runs in a terminal the viewer is not at. Nothing in that window may wait for a keyboard — see Cast Window Prompts for what each prompt decides instead. These are the cases only a real device and a real room can settle.
- Finish a whole season from a rofi launch without touching the keyboard. The window must never show a menu, a text prompt, or sit waiting. The last thing printed should be a completion summary, and the window should close or return to a prompt on its own.
- At the end of a season, a score picker appears in the panel, in the
same frame as the episode countdown -- header, message row, keys. It opens
at 8/10 and shows
Ns to answer, counting down from 10. - Press ↑ once, then take 30 seconds deciding. The clock must vanish and
the picker must still be there. The footer should now read
q to skip. - Press ↑, pause 30s, press ↓, pause 30s, then Enter. It must still be waiting. Every press puts the two minutes back, so a deadline set by the first keypress cannot expire mid-thought.
- Press ↑ and then walk away for more than two minutes. It declines on
its own. That deadline is deliberately not on the panel, so this is the one
thing worth confirming by hand: the season should end normally with
rating skipped (cast window), not sit on the prompt. - ↑ and ↓ move the score, Enter saves it. Then the panel reads
Rated N.and the summary readsrating saved (N). Press nothing and it readsNo answer -- rating skipped.withrating skipped (cast window). - Press ↑ three times quickly — it must move three points, not one. Reading one key per tick is how the first version of this ate every keypress.
- Press space or an arrow left/right during the picker. Nothing should happen. Those are playback controls, and a viewer pressing one by accident must not have their rating written or discarded.
- Check the rating on the tracker, not just the panel.
Rated 8.with no score on the entry means the write failed while the panel claimed success, which is the bug this replaced. - Confirm the summary names what it assumed. It should read
rating skipped (cast window)andsequel skipped (cast window)rather than the plainrating skipped, so a suppressed decision is visible rather than silent. - The completion summary reaches the terminal, not a notification. It is held while the panel owns the screen and printed once the screen is released, so it is the last line in the scrollback. If it arrives as a desktop notification instead, the deferral is broken.
- Your AniList list is untouched by the suppressed prompts. A cast
finale must not have added a sequel to Watching or Plan to Watch, and
SkipRemoteSyncmust still hold — check the entry by hand. - Force a dead end mid-season. The reliable way is to point
Providerat a hostname that does not resolve while casting a season that already has episode 1 cached, then let the countdown carry into episode 2. Expect one automatic re-search and thenCould not find a stream for episode 2; stopping the cast.It must stop — this path used to be an unboundedfor {}that only a viewer backing out could end, and an auto-answer without a bound turns it into an infinite loop. - The dead-end diagnosis reaches you somehow — the panel holds the
terminal for the whole cast, so it arrives as a desktop notification
rather than as text on screen. If nothing arrives at all, check
otakase-debug.log; the panel is only allowed to swallow it into a notification, never discard it. - Confirm the window is not left raw after any of the above. If
keystrokes echo oddly afterwards,
resetand report it — a prompt ending while the cast reader is still parked is exactly the sequence that would do that.
Not bugs; these are deliberate, and documented in the README.
- Seeking and resuming both rebuild the stream from the target, because the receiver ignores SEEK on a playlist with no EXT-X-ENDLIST. Each one costs a few seconds of rebuffering. Worth checking on hardware: that burned subtitles are still in sync after a seek, and that the panel reports episode time rather than the rebuilt stream's own clock.
- Soft subtitles cannot be cast; the Default Media Receiver renders WebVTT only.