An anime streaming and community platform built on PHP 8, MySQL and BunnyCDN, with a local Flask control panel that runs the publishing pipeline. This revision documents what has changed since Rev. 1.0: the Flask publishing dashboard that replaced the old Node.js uploader, the token-authenticated ingest API, transactional new-episode notifications, email-verified accounts, the community systems, and the move from dark glassmorphism to a light/dark catalogue theme.
Astra is a self-built streaming site for anime. Video lives on BunnyCDN edge storage and the PHP application never handles video bytes; the web server only does routing, accounts, templates and database queries. Rev. 1.0 of this paper described a two-section platform (anime and webtoon) with a dark glassmorphism interface. A year of changes later, the platform looks and works quite differently, so this revision rewrites the paper against the current source.
The biggest structural change is the publishing pipeline. Episodes used to go through a separate Node.js uploader. They now go through one local Flask application, a web-based console that holds the pipeline settings, runs jobs, streams their logs and pushes configuration to the live server. The website side gained an ingest API that publishes episodes and their notifications in a single database transaction.
| Area | Rev. 1.0 | Rev. 2.0 (current) |
|---|---|---|
| Content | Anime + webtoon sections | Anime only. The webtoon reader and its CDN zone have been retired from the interface. |
| Publishing | Separate Node.js uploader | Local Flask dashboard + api/anime_ingest.php (§3) |
| Accounts | Username + password | Email OTP verification, password reset by email, bans, bot role (§6) |
| Interface | Dark glassmorphism, per-section colours | Sage catalogue theme with Light / Dark / System modes (§7) |
| Player | Native <video> + speed select | Custom controls, keyboard shortcuts, resume, issue reports (§7.3) |
| Community | Comments, watchlist | Forum, watch-time levels, votes, inbox, achievements, referral seasons, AI community accounts (§8) |
| Library stats | SSE sync logged to the console | SSE sync runs silently; the admin library sync shares the publication lock (§5) |
The CDN-first decision from Rev. 1.0 still stands: no media is stored on the web host. Astra now uses a single BunnyCDN storage zone for anime, which it reaches in two different ways. Browsers fetch video from the pull zone (a *.b-cdn.net hostname). The admin sync, the stats crawler and the publishing dashboard talk to the Storage API (storage.bunnycdn.com, or a regional {region}.storage.bunnycdn.com). The two are easy to confuse, so config.php keeps them as separate constants and warns against it in a comment:
Episodes are stored as {folder}/{episode}.mp4. The database stores the relative bunny_path instead of a full URL, and cdn_url() in anime_repo.php joins it to ANIME_CDN_BASE when the page is rendered. That means changing the pull zone only requires a config edit; no rows need rewriting. Video is still a direct MP4 link with no HLS or transcoding stage, because the library is encoded at a fixed quality and adaptive bitrate would add complexity without a real benefit.
In Rev. 1.0, getting an episode online meant a command-line uploader, manual config edits on the server and an admin-panel sync. The rework puts all of that into one local tool, tools/ah-downloader-dashboard: a Flask app (app.py) that serves a single-page console, plus a worker module (downloader.py) that runs inside it. The README states that it no longer depends on the retired Node.js project. It runs on the operator's machine only, bound to 127.0.0.1:5000.
This paper covers the pipeline from a local episode file onwards: storage, upload, publication and the control plane. It deliberately leaves out the acquisition stage, meaning how source files arrive in the local repository.
The old command-line flow spread state across terminal output, environment variables and a config file on the server. Moving it into a localhost web app solved three problems. Settings persist in one file (dashboard-settings.json, which is git-ignored). Long jobs can be watched and stopped without killing a process. And the live server's BunnyCDN constants can be changed from the same screen instead of being edited by hand over FTP. Flask's built-in server is enough because there is only ever one operator and one job at a time; /api/start returns 409 if a run is already active.
| Route | Method | Purpose |
|---|---|---|
| / | GET | Renders the console with saved settings |
| /api/status | GET | Returns running plus the telemetry buffer; polled once a second |
| /api/config | POST | Saves settings locally, then pushes the CDN constants and ingest secret to the server (§3.5) |
| /api/ftp-location | POST | Connects, changes to the configured web root and lists it, as a pre-flight check before any write |
| /api/start | POST | Validates the job list, clamps concurrency to 1–8 and starts the worker thread |
| /api/stop | POST | Sets a threading.Event; the worker stops at its next checkpoint |
| /api/browse | GET | Opens a native Tk folder picker for the local repository path |
Each series in a job goes through three phases: 1 build the work list, 2 fetch and upload episodes in parallel, and 3 publish to the website. Progress is recorded in {repo}/.state.json, keyed per series. Writes are crash-safe: the state is written to a .json.tmp file and then atomically swapped in with Path.replace(), so an interrupted run cannot leave a half-written state file behind.
Episodes already listed in uploaded are skipped. A non-empty local file is uploaded without being fetched again. Uploads are a single HTTPS PUT to https://{storage-host}/{zone}/{slug}/{ep}.mp4 with an AccessKey header, and each path segment is URL-encoded separately. Every network step gets three attempts with linear back-off (2–3 s × attempt). Downloads go to a .tmp file and are only renamed into place once the byte count matches Content-Length, so a truncated file is never mistaken for a complete one.
Cancelling is cooperative. check_cancelled() runs between chunks and between attempts, and raises DownloadCancelled. The current operation finishes cleanly and the state file is kept, so pressing Stop pauses the job rather than discarding it.
With up to eight episodes moving at once, logging every percentage tick as a new line would flood the console. Instead, worker messages carry a small control prefix. A line shaped like \x01{key}\x1f{text} is a progress row, and append_telemetry() overwrites the previous row with the same key (for example series-slug:Ep 003) instead of appending a new one. Ordinary lines are appended as usual. The buffer holds at most 5,000 lines, and the stored row indexes are shifted whenever old lines are trimmed. The browser reads it with a once-a-second poll, which is simpler than a socket and fast enough for a progress display.
Saving settings also updates the live PHP backend over FTP or FTPS. The dashboard downloads the remote file, rewrites only the targeted define() calls with a regular expression, uploads a timestamped copy of the original (config.php.dashboard-backup-YYYYMMDD-HHMMSS) and only then overwrites the file. Values go through php_string(), which escapes backslashes, quotes and newlines so that a pasted key cannot break out of the PHP string literal. If a constant cannot be found, the edit is aborted rather than appended. The ingest secret is updated the same way, in its own endpoint file. SFTP is the default protocol value but is not supported yet; the code rejects it with a clear error instead of failing silently.
Phase 3 sends one JSON POST per series to api/anime_ingest.php, containing the title, folder, slug, description, cover, status and the complete list of uploaded episodes with their bunny_path. The endpoint:
X-Ingest-Token header compared using hash_equals();GET_LOCK('astra_anime_publication', 15)), which it shares with the admin library sync so the two publishing paths can never race when creating the same series;folder and, for a new series, generates a unique slug (slug, slug-1, …);(anime_id, episode_number) pairs are skipped, and non-integer or non-positive numbers are rejected;notify_episode_release() for each newly inserted row inside the same transaction, then commits.
The response returns anime_id, eps_inserted, notifications_created and notification_deliveries, which the dashboard prints to its log. If anything fails, the transaction rolls back and the endpoint returns 503 with a safe-to-retry message. Because insertion is idempotent, resending the full list never duplicates episodes or alerts.
The first version of the worker could leave dbInserted = true from an earlier run after uploading new episodes. If the next ingest request then failed, later runs believed the database was current and never retried. The fix treats a successful CDN upload as pending publication: dbInserted is set back to false and saved before ingest, and dbSyncedEpisodes is only written after the server confirms. Older state files get one full-list resync, which is harmless because ingest is append-only. tests/downloader_publish_retry.py covers four cases using mocked network and browser calls.
MySQL is accessed through a single PDO connection (db()) with ERRMODE_EXCEPTION, FETCH_ASSOC, EMULATE_PREPARES = false and utf8mb4. Every PHP file starts with declare(strict_types=1). The webtoon tables (manhwa, manhwa_chapters, manhwa_pages) documented in Rev. 1.0 are no longer used by the interface.
| Table | Key columns | Notes |
|---|---|---|
| anime | slug, title, alt_names, folder, description, cover_url, status | folder is the stable key used by ingest and sync; status is Ongoing / Completed / Hiatus |
| episodes | anime_id, season, episode_number, bunny_path, title, is_missing | Relative CDN path; is_missing hides episodes whose file has disappeared without deleting the row |
Episode row IDs are now treated as stable identifiers, because comments, votes and the watch-time lease all refer to them. The admin sync therefore updates existing rows in place instead of deleting and re-inserting them.
| Table | Key columns | Notes |
|---|---|---|
| users | username, email, password_hash, avatar_path, bio, role, banned_at, last_seen | Roles include administrator, moderator and bot; bans are a timestamp |
| pending_registrations | email, password_hash, otp_code, expires_at, attempts | Holds a signup until the email code is confirmed (§6.2) |
| password_resets | email, token_hash, expires_at | Stores only the SHA-256 hash of the reset token |
| user_anime | user_id, anime_id, status, is_favorite, score | Watchlist; watching also subscribes the user to release alerts |
| user_watch_history | user_id, anime_id, episode_num, timestamp_sec | One resume point per series; never used to award levels |
New features ship as dated SQL files in migrations/. They follow strict rules: additive only, safe to re-run (CREATE TABLE IF NOT EXISTS, INSERT IGNORE), InnoDB, and no foreign keys. Without foreign keys, cleanup is the application's job. Account deletion, for example, explicitly removes progression, votes, library, inbox and referral rows in one transaction, and soft-deletes the user's posts.
| Migration | Tables |
|---|---|
| 20260911_community_progress | user_progress, community_votes |
| 20260911_notifications | notifications, notification_recipients |
| 20260912_achievements_referrals | user_watch_months, user_achievements, user_profile_style, referral_control, referral_codes, referral_seasons, registration_referrals, referrals, referral_season_results |
| 20260913_community_bots | bot_accounts, bot_worker_state, bot_runs |
The catalogue page shows library totals (titles, episodes, storage used). These are cached in .cache/bunny_stats.json and .cache/bunny_folder_stats.json for 15 minutes (900 s). When the cache is fresh, no script runs. When it is stale, the page opens an EventSource to api/bunny_stats_sse.php, which walks the storage zone depth-first using the Storage API, streams status/progress events, writes the cache, and then sends done, at which point the page reloads.
Rev. 1.0 described logging this progress to the browser console. That logging has since been removed: the client now only listens for done and error, and the sync is invisible to users.
The administrator Library sync (anime/admin/index.php) is the second way episodes get published. It crawls storage, maps each {folder}/…{n}.mp4 to a series and episode number, updates known rows in place (clearing is_missing), inserts new ones, and triggers the same release notifications. It holds the same advisory lock as the ingest API.
Sessions use a named cookie (astra_session) set with HttpOnly, SameSite=Lax, and Secure whenever the request arrives over HTTPS (which .htaccess enforces with a 301). The session ID is regenerated on login to prevent session fixation. Every state-changing request must carry a CSRF token: 32 random bytes per session, checked with hash_equals().
hash_equals() always takes the same time no matter where two strings first differ. A naive === comparison stops at the first mismatched byte, which in theory allows a secret to be guessed one byte at a time by timing responses. The same function is now used for the CSRF token, the ingest token and the email verification code. This is one of the places where Royal Holloway security coursework led directly to a decision in the code.
A signup is no longer written straight into users. The details go into pending_registrations with a bcrypt hash (password_hash(…, PASSWORD_DEFAULT)) and a six-digit code from random_int(). The code is emailed through PHPMailer over authenticated SMTP (implicit TLS on port 465 or STARTTLS on 587). It expires after 15 minutes and allows five attempts. The account is only created once the code matches. If the email cannot be sent, the pending row is rolled back so the user can try again. Usernames must match ^[a-zA-Z0-9_]{3,32}$, and passwords must be at least 8 characters.
Reset links contain a 256-bit random token (bin2hex(random_bytes(32))), but the database stores only its SHA-256 hash, so a leaked table cannot be used to reset accounts. Tokens expire after one hour, can be used once, and requests for the same email are throttled to one every five minutes.
Admin pages check that the session user's role is administrator, after trimming whitespace and lower-casing it, and return HTTP 403 otherwise. This replaces the auth_require_role('admin') helper described in Rev. 1.0. A non-null banned_at blocks login and excludes the account from votes, notifications and leaderboards. Accounts with the bot role cannot sign in, and any existing session for one is destroyed. The older /anime account routes still have their own legacy authentication path; the two have not been merged yet.
The site itself still has no framework and no build step: plain CSS and vanilla JavaScript, deployed by copying files. (The publishing dashboard is local-only and uses the Tailwind CDN build.) What has changed is the look and how the code is organised.
The near-black glassmorphism of Rev. 1.0 has been replaced by a calmer sage palette inspired by the layout of film-catalogue sites: straight poster edges, small control radii (--r: 3px, --r2: 4px, --r3: 6px), flat surfaces, fine dividers and system sans-serif type. assets/style.css is still the structural base. assets/comfort.css loads after it and owns the design tokens for both light and dark themes.
Per-section colours have been retired. render_header() still writes data-section onto <body>, but comfort.css now maps the section variable (--sp) to the shared accent for every section. The cyan/amber split described in Rev. 1.0 no longer exists.
assets/theme.js runs before the stylesheets load to avoid a flash of the wrong theme. It resolves a Light / Dark / System preference, sets data-theme on <html>, keeps meta[name=theme-color] in sync, follows OS changes while set to System, and syncs between open tabs through the storage event. If localStorage is unavailable, it simply falls back to System. The topbar keeps its blur and still switches to .is-compact after 72 px of scrolling.
render_header(string $title, string $section = '', bool $hasDock = false, array $seo = []) now also outputs SEO metadata and JSON-LD. Shared markup has moved into components/ (footer, mobile dock, account actions, account drawer, notification bell, mascot, admin shell), and the inline footer script has been split into navigation.js and ui.js.
inert and return focus when closed. A skip link and visible :focus-visible rings are included, and prefers-reduced-motion turns off decorative transitions.
The bare <video> with a speed dropdown has become a custom player. It has its own play, seek and scrub controls, ±10 s skips, fullscreen, and keyboard shortcuts (Space/K, ←/→, M, F) that are ignored while a dialog is open. Signed-in users' position is saved every 5 s of playback and cleared when the episode ends. When the page is reopened, playback resumes (if the saved point is at least 10 s in) with a small "Resumed from" notice. A Report an issue dialog sends a short, structured report to a private moderation channel. The series hero still uses a separate blurred-artwork <div> (now .idx-hero-bg on the catalogue) rather than a pseudo-element, for the reason explained in §10.
Levels are based only on time actually spent watching. assets/watch-time.js samples video progress against a monotonic clock every 250 ms. Paused, buffering, seeking and hidden-tab time earns nothing (unless the video is in Picture-in-Picture), and playback speed is normalised, so ten seconds at 2× still earns ten seconds. Batches are sent about every five seconds to api/watch_time.php. That endpoint uses a per-user playback lease, increasing sequence numbers and a wall-clock cap, so several tabs or devices cannot multiply the credit.
| Levels | Frame | Watching time to reach |
|---|---|---|
| 1–4 | Fern | 0 |
| 5–9 | Tide | 8 h |
| 10–19 | Celestial | 40 h 30 min |
| 20–34 | Phoenix | 180 h 30 min |
| 35+ | Aurora | 578 h |
Frames are original illustrations (WebP, with a PNG source). Animated particles are drawn on a canvas at no more than 25 fps, only while the frame is visible, and the frame is static under reduced motion. The site acknowledges that this telemetry is not tamper-proof: a determined client could fabricate requests. It is a fairness measure, not DRM.
Comments and forum posts each allow one vote per account (+1, −1 or none). Self-votes, votes from banned accounts and votes on deleted content are rejected on the server. Reputation is calculated by joining votes to content that still exists, rather than stored as a counter, so deletions correct it automatically. The forum has categories, threads, pinning and locking for administrators and moderators, an announcements category, and a 200-post display limit per thread.
Administrators write plain-text notifications in a composer. Before sending, they preview the exact recipients: chosen users, roles, watch-level ranks or @everyone, with banned accounts excluded by default. The preview expires after ten minutes, and sending fails if the audience has changed since. Delivery happens in one transaction, inserted in batches of 500, and sent messages cannot be edited.
Release alerts reuse the same tables. notify_episode_release() notifies every non-banned, non-bot user whose list status for that series is watching. Each release is identified by sha256("astra:episode-release:{anime}:{episode}"), stored in the unique send_key column, so re-uploads, path edits and repeated syncs can never trigger a second alert. A release with no watchers is still recorded, so users who start watching later do not receive old alerts.
There are 22 achievements: membership year, lifetime watch hours (1 to 1,000), annual bronze/silver/gold tiers, a "year of watching" badge (12 distinct calendar months), level milestones and referral milestones. Users can feature up to five badges on a profile banner, chosen from six original SVG presets.
Referrals use 16-character invite codes. An invite only counts once the invited account has 30 minutes of real watch time, and that check runs in the same transaction that credits the watch time. Seasons go from draft to open to closed. Only one can be open at a time, and closing a season freezes the full ranking. A single referral_control row is used as a lock so that signup attribution, qualification and season changes happen one at a time.
cron/community-bots.php is a command-line-only worker: cron/.htaccess blocks web access and the script exits if it is not run from the CLI. An hourly Hostinger cron job runs it. It creates at most one new account per elapsed hour and, on each run, writes a short two-account exchange in anime comments, episode comments or one forum category, using Eden AI's V3 chat-completions API. Only topic titles, bot personas and up to six earlier bot messages are sent to the model, never user data. Output is checked against a strict JSON shape, and anything containing HTML, URLs or quote markup is rejected. Both posts and the run record commit together, and an advisory lock stops two workers overlapping.
The API key is kept one directory above public_html, and an admin pause switch, a config flag and an environment variable must all allow the worker before it does anything. Bot accounts carry an internal bot role that administrators can see. They cannot log in, vote, earn watch time, show achievements or receive notifications, and they are excluded from online counts and referral scores. They are not currently labelled as bots on the public site.
The repository approach from Rev. 1.0 has grown with the platform. Page files call domain functions and never build SQL themselves. Every query is a prepared statement with bound parameters.
| Repository | Domain |
|---|---|
| anime_repo.php | Catalogue, episodes, cdn_url(), alternative titles |
| user_anime_repo.php | Watchlist status, favourites, scores |
| comments_repo.php · forum_helper.php | Comments and forum threads/posts |
| community_repo.php | Levels, playback lease, votes, reputation |
| notifications_repo.php · episode_notifications.php | Inbox, composer, release alerts, publication lock |
| achievements_repo.php · referrals_repo.php | Badges, banners, invite codes, seasons |
| bots_repo.php | Bot selection, cooldowns, publication guards |
Most recent features were built in an offline copy of the site on a machine without PHP. Verification was therefore split into layers, and I have tried to be clear about what was and was not run:
tests/*_policy.php) that need PHP 8.1+ with PDO SQLite, plus PHP linting.An early profile.php queried a user_history table and a last_seen column that production did not have at the time, and the page returned a 500 error. The migration rules in §4.3 (dated, additive, re-runnable files applied to testing first) are the long-term fix.
The blurred series hero was first built as a ::before with inset: calc(-1 * var(--pad)). It never rendered, and replacing it with a real <div> fixed it. I should have tested the approach on its own before integrating it. The explicit-div pattern is still used for .idx-hero-bg.
The stats sync first appeared as a visible toast, then as styled console logging. Neither helped users, and the current client logs nothing. Debug output should be designed out from the start rather than moved around.
Treating "file is on the CDN" as "episode is live" caused the silent publish gap described in §3.6. Each external side effect should have its own recorded state and its own retry.
Keeping migrations free of foreign keys made them safe to apply on shared hosting without touching existing tables. The cost is that integrity depends on the PHP deletion paths. Rows removed by hand outside those paths can leave orphans that need manual cleanup.
| Layer | Technology |
|---|---|
| Web application | PHP 8 (strict types), PDO, Apache .htaccess, Hostinger hosting |
| Database | MySQL / InnoDB, utf8mb4, advisory locks |
| Media | BunnyCDN Storage API + pull zone, direct MP4 |
| PHPMailer over authenticated SMTP | |
| Frontend | Vanilla JS, CSS custom properties, Server-Sent Events, canvas |
| Publishing | Python, Flask 3, Playwright, ftplib (FTP/FTPS), ThreadPoolExecutor |
| Automation | PHP CLI cron worker, Eden AI V3 chat completions |
| Testing | Python + SQLite fixtures, Node, PHP policy suites, browser fixtures |
The principle from Rev. 1.0 still holds: each piece of complexity should solve a problem I actually ran into. The dashboard exists because the old upload flow was fragile. The ingest transaction exists because publication needed to be atomic. Release-alert keys exist because duplicate alerts were the obvious way the notification system would fail. Astra is a platform I use myself, and most of these changes came from points where using it was awkward.