Chasing TV Ads and Starting Bedtime Music From Claude Code
Last week I wrote about connecting Claude Code to my Sony TV over ADB. That post covered the setup: finding the TV, turning on debugging, fixing the region, installing an app. If you have not done that part yet, start there.
This one is about what happened once the connection was just sitting there. I used it twice in less than a day. Last night to go after an ad I should not be seeing, and this morning to put on music for my daughter. One of those is finished. The other is still in progress, and I think the unfinished one is more useful to read about.
An ad inside an app I already pay for
I pay for a sports streaming app. When I open a replay, it still plays a pre-roll first, with a small label in the corner that says Ad 1 of 1. Paying for something and still sitting through ads bothers me more than it probably should, so I asked Claude to remove them.
The obvious idea is to patch the app. Before trying anything, Claude checked whether that was even possible. It pulled the APK off the TV over ADB and searched the dex files for signs of the usual video ad plumbing: Google IMA, FreeWheel, Yospace, dynamic ad insertion, VAST and VMAP.
adb shell pm path <package>
adb pull <path-from-above> app.apk
unzip -o app.apk -d app/
The answer was no. The app turned out to be a Cordova shell. The native part is a thin wrapper, and the player and its ads arrive as JavaScript from the service's servers when the app starts. You could patch that APK all week and the ads would still show up, because they are not in it.
I was happy with that answer. It took a few minutes and saved me an evening on something that could never have worked.
Blocking ads at the DNS level
If you cannot change the app, you can change what the TV is allowed to reach. Android TV has a Private DNS setting, the same one Android phones have. Point it at a filtering DNS server and every app on the TV asks that server for addresses. When an app asks for a known ad server, the filter gives it nothing back.
You can set it in the TV's network settings, or over ADB:
adb shell settings put global private_dns_mode hostname
adb shell settings put global private_dns_specifier dns.adguard-dns.com
That hostname is AdGuard's public filter. Claude set it, checked that a known ad domain now resolved to nowhere, and opened a replay. The pre-roll played anyway.
So the next step was a personal AdGuard DNS server. The free account gives you your own server, a device entry for the TV with its own private DNS hostname, a query log, and custom rules. Swapping the TV over is one line:
adb shell settings put global private_dns_specifier <your-id>.d.adguard-dns.com
The query log is the reason to bother. The public filter is a black box. Your own server shows you every lookup the TV makes, in order, as it happens.
Only the TV is affected
The first thing I asked was whether this would change anything for my laptop or the home wifi. It does not. Private DNS is a setting on one device. The TV sends its lookups to that hostname over an encrypted connection, and the id in the hostname tells AdGuard which device they came from. My Mac keeps using whatever DNS it used before. So does the router, and so does every other device in the house.
That also makes it easy to undo. This puts the TV back on the normal DNS from the network:
adb shell settings put global private_dns_mode opportunistic
Two plays without an ad prove nothing
After the switch I watched two replays. No ad either time. It would have been easy to call that a win.
Claude did not, and it was right. My new server was running the same block list as the public filter, which had already failed. Nothing about the blocking had changed. The more likely reason for the missing ads is frequency capping, where the ad server stops showing you an ad after you have seen it a few times. Two quiet replays after an evening of testing look exactly like that.
The real test is slower:
- Open the query log for the TV.
- Start a fresh replay and leave it running until the ad actually appears.
- Read the lookups from the seconds right before the ad and find the host that served it.
- Add a custom rule for that host, like
||ads.example.com^. - Play another replay and check two things: no ad, and the match itself still plays.
That last check matters. Block the wrong host and the video can die along with the ad.
There is also a point where you stop. Some services stitch the ad into the same stream as the video on their own servers, which is called server side ad insertion. If the ad comes from the same host as the match, DNS has nothing to separate. Block the host and you lose both.
I do not know yet which kind this app is. That is the next replay. What I have now is a method, and a filter I can watch instead of guess about.
The TV as a bedtime remote
This morning was simpler. I typed one line into Claude Code:
open youtube, piano sleeping disney songs for my daughter
I did not say which TV, which app, or how. Claude checked that the ADB connection was still alive and found the YouTube package on the TV. Then it tried the obvious route, a SEARCH intent with the query. The TV app ignored it.
One small gotcha on the way: the first try split the query at the spaces, because the shell on the TV saw each word as a separate argument. Wrap the whole am start command in quotes inside adb shell "...", and quote the query inside that.
Next the YouTube profile picker came up. Claude picked the profile with Premium, so no ad would cut into a lullaby.
What worked was a VIEW intent with a normal YouTube search URL. The TV app opens it straight on the results page:
adb shell "am start -a android.intent.action.VIEW \
-d 'https://www.youtube.com/results?search_query=piano+sleeping+disney+songs' \
com.google.android.youtube.tv"
Focus lands on the top result, so one press of OK starts it:
adb shell input keyevent KEYCODE_DPAD_CENTER
The top result was a six hour collection of Disney songs on piano for deep sleep. Good enough.
Check what is actually playing
This is the part I care about. Pressing OK does not mean the job is done, so Claude checked the result two ways.
First it read the TV's media session, which is how Android tells the system what is playing:
adb shell dumpsys media_session | grep -E "state=|description="
That gives you the playback state, the title and the position. State 3 means playing. Then it took a screenshot to see what was really on the screen:
adb exec-out screencap -p > tv.png
Both agreed. The position also showed something I would have missed. The video had not started at the beginning. It resumed at 4 hours 13 minutes, out of 6 hours 14. Claude told me, and offered to restart it from zero.
It sounds small, but that is why I trust it at bedtime. A script that pressed OK would have told me it worked. Claude told me what was on the TV, including the part I would not have checked.
Same connection, two jobs
Both of these ran on the ADB connection from last week and nothing else. No smart home hub, no extra app on the TV.
Looking back, the two jobs had the same shape. With the ads, Claude would not let two quiet replays count as proof. With YouTube, it would not let a pressed button count as playback. Both times it went and looked before telling me anything, and if something is going to run my TV while my daughter falls asleep, that is what I want it to do.