Created
August 4, 2026 09:32
-
-
Save jonelf/6c8af835462b8c335379227863a06556 to your computer and use it in GitHub Desktop.
Unfollows all podcasts in the macOS Podcasts app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- Unfollow all shows in Podcasts.app | |
| -- | |
| -- Verified against the live UI (macOS 26 / Darwin 25.5.0) using System Events | |
| -- accessibility scripting. Podcasts.app exposes almost no standard container | |
| -- roles (no scroll area/table/outline) -- everything is nested AXGroups, so | |
| -- this walks the tree manually via `UI elements of X` rather than relying on | |
| -- System Events' recursive `every <class> of <container>` (which does not | |
| -- reliably descend through this app's structure). | |
| -- | |
| -- HOW TO RUN: osascript unfollow_all_podcasts.applescript | |
| -- Podcasts must already be running. You don't need to have it frontmost | |
| -- first -- the script switches to its Space itself via the Dock icon, since | |
| -- `tell application "Podcasts" to activate` alone does not reliably switch | |
| -- macOS Spaces. | |
| -- | |
| -- SAFETY: capped at maxIterations so a UI mismatch can't spin forever. Each | |
| -- iteration re-fetches the first followed-show tile fresh (rather than | |
| -- caching a list up front), since unfollowing a show removes it from the | |
| -- grid and shifts the rest. | |
| set maxIterations to 1 | |
| set unfollowedCount to 0 | |
| set unfollowedNames to {} | |
| using terms from application "System Events" | |
| on findFirstMatch(e, roleWanted, descWanted, depth, maxDepth) | |
| if e is missing value then return missing value | |
| set roleStr to "" | |
| try | |
| set roleStr to role of e | |
| end try | |
| set descStr to "" | |
| try | |
| set descStr to description of e | |
| end try | |
| if roleStr is roleWanted and descStr is descWanted then | |
| return e | |
| end if | |
| if depth < maxDepth then | |
| try | |
| set kidList to UI elements of e | |
| repeat with k in kidList | |
| set found to my findFirstMatch(k, roleWanted, descWanted, depth + 1, maxDepth) | |
| if found is not missing value then | |
| return found | |
| end if | |
| end repeat | |
| end try | |
| end if | |
| return missing value | |
| end findFirstMatch | |
| on findFirstMatchByRole(e, roleWanted, depth, maxDepth) | |
| if e is missing value then return missing value | |
| set roleStr to "" | |
| try | |
| set roleStr to role of e | |
| end try | |
| if roleStr is roleWanted then | |
| return e | |
| end if | |
| if depth < maxDepth then | |
| try | |
| set kidList to UI elements of e | |
| repeat with k in kidList | |
| set found to my findFirstMatchByRole(k, roleWanted, depth + 1, maxDepth) | |
| if found is not missing value then | |
| return found | |
| end if | |
| end repeat | |
| end try | |
| end if | |
| return missing value | |
| end findFirstMatchByRole | |
| on findButtonContainingName(e, nameSubstr, depth, maxDepth) | |
| if e is missing value then return missing value | |
| set roleStr to "" | |
| try | |
| set roleStr to role of e | |
| end try | |
| if roleStr is "AXButton" then | |
| set nm to "" | |
| try | |
| set nm to name of e | |
| end try | |
| if nm contains nameSubstr then return e | |
| end if | |
| if depth < maxDepth then | |
| try | |
| set kidList to UI elements of e | |
| repeat with k in kidList | |
| set found to my findButtonContainingName(k, nameSubstr, depth + 1, maxDepth) | |
| if found is not missing value then | |
| return found | |
| end if | |
| end repeat | |
| end try | |
| end if | |
| return missing value | |
| end findButtonContainingName | |
| end using terms from | |
| tell application "System Events" | |
| if not (exists process "Podcasts") then | |
| display dialog "Podcasts isn't running. Open it first." buttons {"OK"} default button 1 | |
| return | |
| end if | |
| -- Switch to whatever Space Podcasts' window lives on. `activate` alone | |
| -- does not reliably do this; clicking the Dock icon does. | |
| tell process "Dock" | |
| set dockIcon to first UI element of list 1 whose name is "Podcasts" | |
| click dockIcon | |
| end tell | |
| delay 0.8 | |
| tell process "Podcasts" | |
| set frontmost to true | |
| set w to window 1 | |
| -- Resolve these once, up front: the deep recursive searches are the | |
| -- real cost, not the small `delay`s. showsGroup and mainGroup are | |
| -- stable AXUIElement references that stay valid as show tiles come | |
| -- and go underneath them, so there's no need to re-search the whole | |
| -- window tree on every single iteration -- only refresh them (via | |
| -- the try/on error fallback below) if a reference ever goes stale. | |
| set showsGroup to my findFirstMatch(w, "AXGroup", "Shows", 0, 20) | |
| if showsGroup is missing value then | |
| display dialog "Stopped: couldn't find the Shows grid." buttons {"OK"} default button 1 | |
| return | |
| end if | |
| -- The open context menu always shows up as a shallow sibling of the | |
| -- big content subtree (2 levels under the window), so instead of a | |
| -- full recursive search for it each time (which would have to walk | |
| -- the entire show grid first), just check the direct children of | |
| -- this top-level group. | |
| set mainGroup to item 1 of (UI elements of w) | |
| repeat with i from 1 to maxIterations | |
| try | |
| set kids to UI elements of showsGroup | |
| on error | |
| set showsGroup to my findFirstMatch(w, "AXGroup", "Shows", 0, 20) | |
| if showsGroup is missing value then | |
| display dialog "Stopped after unfollowing " & unfollowedCount & " show(s): couldn't find the Shows grid." buttons {"OK"} default button 1 | |
| exit repeat | |
| end if | |
| set kids to UI elements of showsGroup | |
| end try | |
| set firstShow to missing value | |
| repeat with k in kids | |
| set r to "" | |
| try | |
| set r to role of k | |
| end try | |
| if r is "AXButton" then | |
| set firstShow to k | |
| exit repeat | |
| end if | |
| end repeat | |
| if firstShow is missing value then | |
| -- Grid may be mid-reflow right after a previous unfollow; | |
| -- retry briefly before concluding we're actually done. Check | |
| -- immediately first, then only pay small delays between | |
| -- retries -- for as long as it takes to settle, not a fixed | |
| -- wait every time. | |
| set retryFound to false | |
| repeat with attempt from 1 to 6 | |
| set kids to UI elements of showsGroup | |
| repeat with k in kids | |
| set r to "" | |
| try | |
| set r to role of k | |
| end try | |
| if r is "AXButton" then | |
| set firstShow to k | |
| set retryFound to true | |
| exit repeat | |
| end if | |
| end repeat | |
| if retryFound then exit repeat | |
| if attempt < 6 then delay 0.05 | |
| end repeat | |
| if not retryFound then exit repeat -- no more followed shows | |
| end if | |
| set showName to "" | |
| try | |
| set showName to description of firstShow | |
| end try | |
| try | |
| -- The app can still be mid-animation from the previous | |
| -- removal, and AXShowMenu fired into that seems to get | |
| -- silently dropped rather than queued -- waiting longer | |
| -- doesn't help, but re-firing the action does. So retry the | |
| -- action itself, polling cheaply (shallow children of | |
| -- mainGroup, not a full tree walk) in between. Exits the | |
| -- moment the menu shows up; only pays the ~2s budget on the | |
| -- rare attempt that's genuinely slow. | |
| set theMenu to missing value | |
| repeat with attempt from 1 to 14 | |
| perform action "AXShowMenu" of firstShow | |
| delay 0.1 | |
| try | |
| repeat with k in (UI elements of mainGroup) | |
| set r to "" | |
| try | |
| set r to role of k | |
| end try | |
| if r is "AXMenu" then | |
| set theMenu to k | |
| exit repeat | |
| end if | |
| end repeat | |
| end try | |
| if theMenu is not missing value then exit repeat | |
| end repeat | |
| if theMenu is missing value then | |
| -- Last resort: the expensive full-tree search, in case | |
| -- the menu isn't where we expect it to be. | |
| set theMenu to my findFirstMatchByRole(w, "AXMenu", 0, 20) | |
| end if | |
| if theMenu is missing value then error "context menu did not open" | |
| set unfollowItem to missing value | |
| set mi to menu items of theMenu | |
| repeat with mItem in mi | |
| set inm to "" | |
| try | |
| set inm to name of mItem | |
| end try | |
| if inm is "Unfollow" then | |
| set unfollowItem to mItem | |
| exit repeat | |
| end if | |
| end repeat | |
| if unfollowItem is missing value then error "no \"Unfollow\" item in menu" | |
| perform action "AXPress" of unfollowItem | |
| -- Some shows prompt to remove/keep downloaded episodes | |
| -- ("Remove all episodes of ... from your Library?" with | |
| -- buttons "Remove From Library" / "Keep All Episodes"). | |
| -- Auto-confirm removal, per explicit user choice. This | |
| -- dialog is rare (only shows with actual downloads trigger | |
| -- it) and its appearance isn't instant, so check right away | |
| -- with no delay, then only pay small delays between retries | |
| -- -- and only for as long as it takes to show up, not a | |
| -- fixed wait every single time. | |
| set removeBtn to missing value | |
| repeat with attempt from 1 to 5 | |
| try | |
| set sheetCount to count of sheets of w | |
| if sheetCount > 0 then | |
| set theSheet to sheet 1 of w | |
| set sheetBtns to buttons of theSheet | |
| repeat with aBtn in sheetBtns | |
| set bn to "" | |
| try | |
| set bn to name of aBtn | |
| end try | |
| if bn contains "Remove" then | |
| set removeBtn to aBtn | |
| exit repeat | |
| end if | |
| end repeat | |
| end if | |
| end try | |
| if removeBtn is missing value then | |
| set removeBtn to my findButtonContainingName(w, "Remove From Library", 0, 6) | |
| end if | |
| if removeBtn is not missing value then exit repeat | |
| if attempt < 5 then delay 0.06 | |
| end repeat | |
| if removeBtn is not missing value then | |
| perform action "AXPress" of removeBtn | |
| end if | |
| set unfollowedCount to unfollowedCount + 1 | |
| set end of unfollowedNames to showName | |
| on error errMsg | |
| try | |
| key code 53 -- Escape, in case a menu got left open | |
| end try | |
| display dialog "Stopped after unfollowing " & unfollowedCount & " show(s). Error on \"" & showName & "\": " & errMsg buttons {"OK"} default button 1 | |
| exit repeat | |
| end try | |
| end repeat | |
| end tell | |
| end tell | |
| display notification ("Unfollowed " & unfollowedCount & " show(s).") with title "Podcasts Cleanup" | |
| return unfollowedNames |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment