24 Aug 2026
Rewriting aniani in Rust
aniani started as a python/pyqt6 desktop app, a search/watch/download/track anime client with mpv and vlc backends, a couple of scraper sources, discord rich presence, the works. it worked. it also shipped with an interpreter tax i didnt want to keep paying: every anime cover on the discover dashboard is a jpeg that has to get decoded before it hits the screen, and python's decode path (even through pillow/qt's own image loading) is doing that with a lot more overhead per image than a compiled language will. that adds up fast across 25+ covers on a scrollable row. so, going full systems mode: rust for the rewrite, go and zig staying in rotation for whatever else needs them next.
not a port. a remake. i didnt sit down and translate every python file 1:1, i rebuilt the thing feature by feature and made real calls about what was actually worth carrying over versus what only existed to paper over one specific python-shaped problem.
what actually ported
mpv and vlc backends, both real, both driving the actual player over their real control protocols. mpv over its json ipc unix socket (get/set property, seek, volume, speed, subtitle/audio cycling). vlc over its http control interface, a fresh random port and password every launch so a stale leftover vlc process from an earlier run can never get silently polled instead of the current one.
anidb.app, the primary stream source, needs curl_chrome136 (curl-impersonate) on path since anidb rejects plain curl's tls fingerprint with a 403. same regex-based scraping approach as the python version, since thats genuinely what the site gives you, no json api to speak of for the parts that matter.
nyaa.si + qbittorrent-nox, torrent search over nyaa's rss feed (no scraping, no anti-bot), sequential download with first/last-piece priority through qbittorrent's webui rest api, so a growing file can get handed to the player once enough of the start has buffered.
discord rich presence, browsing/watching states, same shared discord application id the python version used so it works out of the box with zero setup.
anilist oauth sync, the pin-based implicit grant flow (paste a client id, open the authorize page, paste the token back), progress pushed after each episode played.
downloads, ffmpeg remuxing hls to mp4 with progress parsed off ffmpeg's own stderr, cancellable, a downloads tab that shows in-progress jobs and a browsable library of whats already saved.
yuma (aniwatchtv.to), search and episode listing, scraped for real with the scraper crate's css selectors. stream resolution i deliberately did not port: that site's actual video embed needs a per-request client key plus a cryptojs-compatible aes decrypt of the sources payload, both of which shift whenever the site's own js changes. the python version kept that isolated in its own vendored module specifically so it could be updated independently of everything else, reimplementing a moving-target crypto scheme from scratch here wasnt worth faking. genuinely unimplemented, not a stub pretending to work, tracked in the repo's TODO.txt.
real bugs, found by actually running it
the cover fetch was silently broken. continue-watching cards were coming back with placeholder boxes instead of covers. the anilist fallback path (used when jikan, tried first, is down) deserialized the response into a struct shaped for a completely different query, data.Page.media[], when the actual query it was sending back a single data.Media object. every anilist cover lookup was failing to parse and returning none, silently, no error surfaced anywhere. caught it by adding real logging instead of guessing, rewrote the parse against serde_json::Value directly instead of a mismatched typed struct, fixed.
jikan really is down sometimes, not a bug in the rewrite, confirmed by hand with a plain curl straight against jikan's api and getting a 504 back from jikan itself, myanimelist unreachable on their end. the same flakiness the original python readme already documented in detail. the fallback-to-anilist path is exactly what it's for, and once the parse bug above was fixed, it correctly recovered.
the discord icon was a blank question mark. turned out the icon url i carried over from the python version, cdn.simpleicons.org/myanimelist, serves svg. discord's rich presence assets need a raster format, png or jpeg, not svg, so it silently failed to render as anything. swapped to a real mal png icon, fixed.
nested scroll areas dont scroll. wrapped the whole tab body in an outer vertical scroll area, then wrapped the episode list in its own inner one for a fixed-height box. egui's known footgun: the inner one stops responding to wheel input once its nested inside an already-scrolling outer region. fix was just not nesting them, let the episode list flow as part of the page scroll instead of boxing it separately.
the "app not responding" dialog, and the actual root cause. hit this repeatedly and every surface-level fix (moving blocking calls off the ui thread, tightening the repaint interval) helped but didnt fully explain it. dug into hyprland itself: hyprctl getoption misc:anr_missed_pings came back 5, a real, native hyprland feature that shows exactly this dialog after 5 missed wayland xdg_wm_base pings. process cpu and thread state during a hang showed everything idle-sleeping on futex_wait/poll, not a livelock, not a deadlock, just a real compositor-level responsiveness watchdog that a reactive (only-repaint-when-needed) egui event loop can occasionally miss. switched the app to call ctx.request_repaint() unconditionally every frame instead of a scheduled interval, so the wayland event loop gets serviced continuously instead of in bursts.
the perf case, actually checked
the whole premise was "rust is fast, python's decode path isnt." wanted to actually confirm that instead of just asserting it. the debug build (unoptimized, opt-level=0) of the exact same app measurably stuttered decoding the discover dashboard's covers, slow frames over a second long logged on startup. the release build (opt-level=3) of the identical code, same covers, same machine: zero slow-frame log entries across repeated runs. thats not python versus rust as a language claim in isolation, thats compiled-and-optimized versus not, but its also exactly the axis python can never move on for cpu-bound work like image decode, since there's no release-mode compile step to fall back on.
whats left
yuma's actual stream resolution (the aes decrypt pipeline, no plans to add it), macos (completely untouched, no testing, no /Applications binary-discovery fallback), a real system tray icon (closing the window minimizes instead of quitting now, see below, but theres no actual tray icon to click back in from yet), and windows is only confirmed on a couple of machines so far, not broad hardware/driver coverage.
update, windows for real this time
compiles and runs on actual windows hardware now, two separate machines confirmed, not just a green ci checkmark that never touched real drivers. three real bugs on the way there. no opengl fallback backend at all on one of the machines, fixed by adding wgpu's webgl feature, which despite the name also covers native windows opengl via wgl, not just literal browser webgl. a console window flashing open behind the app on every single launch, fixed with windows_subsystem = "windows". and curl_chrome136 not getting found at all unless it happened to already be on path, so now its also checked sitting right next to the exe itself before giving up.
update, anilist sync is actually two-way now
the old local-watch-pushes-to-anilist-only limitation from the list above is closed. whoami/connect/sync-toggle push local progress up same as before, and a new my_list() call pulls your current/repeating list back down from anilist and shows it as its own row on the discover tab, click one to jump straight into search for it. no in-app editing of anilist entries beyond what actually playing an episode already pushes, thats a deliberate scope line, not a bug.
update, minimize instead of quit
closing the window used to just kill the process. now it hides instead: close_requested -> CancelClose + Visible(false), so discord presence and the worker thread both keep running in the background instead of dying with the window. paired that with an fd-lock-based single-instance lock, launching the exe again while its already running doesnt spawn a second process anymore, it just writes a "show" signal file and exits immediately, and the original instance picks that up and un-hides plus focuses itself. i scoped this down from what i actually wanted: a real system tray icon needs the tray-icon crate wired into eframe's own winit event loop, a bigger, riskier integration i didnt attempt this round. so right now the only way back in after closing the window is relaunching the exe, which is a real limitation, not a finished feature, and its listed honestly above instead of glossed over.
update, the vlc quirk from the list above is fixed, linux/hyprland only
this was the one bug that used to bite me constantly, carried straight over from the python version and listed as unported above. vlc's -I dummy interface means its process lifecycle isnt tied to its own video window at all, so closing that window directly, super+q, not from inside the app itself, used to leave the vlc process running as an orphan the app never noticed. fixed with window_gone(), which polls hyprctl clients -j for a window matching the vlc process's pid/class. once a window's been seen and then goes missing while the process is still alive, the worker thread stops it and cleans up state exactly like a normal user-initiated stop. same mechanism also reattaches to an already-running vlc instance after a relaunch now, VlcPlayer::new() reads the shared port/pid/password files a previous run wrote instead of always starting from a blank slate. windows and macos get none of this since hyprctl doesnt exist there, window_gone() just always returns false on those platforms, a safe default, not a silent failure.
one more real bug closed out alongside all this: continue-watching used to fail to resume correctly if the episode in question was already downloaded, it does now.
code lives on master, all of the above still uncommitted locally while it settles.