1. Overview and What Changed

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.

1.1 Changes since Rev. 1.0

AreaRev. 1.0Rev. 2.0 (current)
ContentAnime + webtoon sectionsAnime only. The webtoon reader and its CDN zone have been retired from the interface.
PublishingSeparate Node.js uploaderLocal Flask dashboard + api/anime_ingest.php (§3)
AccountsUsername + passwordEmail OTP verification, password reset by email, bans, bot role (§6)
InterfaceDark glassmorphism, per-section coloursSage catalogue theme with Light / Dark / System modes (§7)
PlayerNative <video> + speed selectCustom controls, keyboard shortcuts, resume, issue reports (§7.3)
CommunityComments, watchlistForum, watch-time levels, votes, inbox, achievements, referral seasons, AI community accounts (§8)
Library statsSSE sync logged to the consoleSSE sync runs silently; the admin library sync shares the publication lock (§5)

2. CDN Architecture

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:

// config.php (values redacted) define('ANIME_CDN_BASE', 'https://[zone].b-cdn.net'); // pull zone — delivers video define('CDN_BASE', ANIME_CDN_BASE); // compat alias define('BUNNY_ANIME_ZONE', '[zone]'); // Storage API — sync and upload define('BUNNY_ANIME_REGION', 'de'); define('BUNNY_ANIME_KEY', '…'); define('BUNNY_ANIME_ROOT', ''); // Do NOT use the pull zone URL (*.b-cdn.net) for API calls — auth will fail.

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.

3. The Publishing Pipeline — Flask Dashboard

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.

// Astra publishing pipeline (Rev. 2.0) ┌──────────────────────────── OPERATOR MACHINE ─────────────────────────────┐ │ Browser UI (templates/index.html, polls /api/status every 1 s) │ │ │ fetch() │ │ Flask app.py 127.0.0.1:5000 │ │ /api/config /api/start /api/stop /api/status /api/ftp-location │ │ │ thread + stop_event │ telemetry ring (5,000 lines) │ │ Downloader ThreadPoolExecutor (1–8 slots) │ │ │ local repo/{slug}/{ep}.mp4 + repo/.state.json (resume state) │ └────────┬──────────────────────────────┬──────────────────────┬────────────┘ │ HTTPS PUT (AccessKey) │ HTTPS POST │ FTP / FTPS ┌────────▼─────────┐ ┌───────────▼───────────┐ ┌──────▼──────────────┐ │ Bunny Storage │ │ api/anime_ingest.php │ │ config.php defines │ │ {slug}/{ep}.mp4 │ │ X-Ingest-Token │ │ (backup, then edit) │ └────────┬─────────┘ │ GET_LOCK → TX → notify│ └─────────────────────┘ │ pull zone └───────────┬───────────┘ ▼ ▼ viewers' browsers MySQL: anime · episodes · notifications

3.1 Why a local web app instead of a CLI

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.

3.2 Control-plane routes

RouteMethodPurpose
/GETRenders the console with saved settings
/api/statusGETReturns running plus the telemetry buffer; polled once a second
/api/configPOSTSaves settings locally, then pushes the CDN constants and ingest secret to the server (§3.5)
/api/ftp-locationPOSTConnects, changes to the configured web root and lists it, as a pre-flight check before any write
/api/startPOSTValidates the job list, clamps concurrency to 1–8 and starts the worker thread
/api/stopPOSTSets a threading.Event; the worker stops at its next checkpoint
/api/browseGETOpens a native Tk folder picker for the local repository path

3.3 Job phases and resumable state

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.

// .state.json — one entry per series { "…series key…": { "title": "…", "folderName": "series-slug", "uploaded": [1, 2, 3], // confirmed on Bunny Storage "dbInserted": true, // last ingest succeeded "dbSyncedEpisodes": [1, 2, 3] // exactly what the website confirmed } }

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.

3.4 Telemetry with in-place progress rows

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.

3.5 Pushing configuration to the live server

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.

# app.py — targeted define() rewrite pattern = rf"(define\(\s*['\"]{re.escape(name)}['\"]\s*,\s*)(['\"])(.*?)\2(\s*\)\s*;)" output, count = re.subn(pattern, rf"\g<1>{php_string(value)}\g<4>", output, count=1) if count == 0: raise ValueError(f"Could not find {name} in remote PHP file")

3.6 The ingest API

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:

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.

⌖ Bug Fixed — "Uploaded" Is Not "Published"

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.

4. Database Schema

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.

4.1 Catalogue

TableKey columnsNotes
animeslug, title, alt_names, folder, description, cover_url, statusfolder is the stable key used by ingest and sync; status is Ongoing / Completed / Hiatus
episodesanime_id, season, episode_number, bunny_path, title, is_missingRelative 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.

4.2 Accounts and library

TableKey columnsNotes
usersusername, email, password_hash, avatar_path, bio, role, banned_at, last_seenRoles include administrator, moderator and bot; bans are a timestamp
pending_registrationsemail, password_hash, otp_code, expires_at, attemptsHolds a signup until the email code is confirmed (§6.2)
password_resetsemail, token_hash, expires_atStores only the SHA-256 hash of the reset token
user_animeuser_id, anime_id, status, is_favorite, scoreWatchlist; watching also subscribes the user to release alerts
user_watch_historyuser_id, anime_id, episode_num, timestamp_secOne resume point per series; never used to award levels

4.3 Feature migrations

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.

MigrationTables
20260911_community_progressuser_progress, community_votes
20260911_notificationsnotifications, notification_recipients
20260912_achievements_referralsuser_watch_months, user_achievements, user_profile_style, referral_control, referral_codes, referral_seasons, registration_referrals, referrals, referral_season_results
20260913_community_botsbot_accounts, bot_worker_state, bot_runs

5. Library Stats and Storage Sync

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.

// anime/index.php — only emitted when the cache is stale var es = new EventSource('/api/bunny_stats_sse.php'); es.addEventListener('done', function(){ es.close(); setTimeout(function(){ location.reload(); }, 400); }); es.addEventListener('error', function(){ es.close(); });

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.

6. Authentication and Account Security

6.1 Sessions and CSRF

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().

⌖ Design Decision — Constant-Time Comparison

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.

6.2 Email-verified registration

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.

6.3 Password reset

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.

6.4 Roles and bans

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.

7. Frontend Design System

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.

7.1 From glassmorphism to a catalogue theme

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/comfort.css — theme tokens (excerpt) */ :root { --bg:#f5f5f0; --text:#243830; --accent:#35644b; } :root[data-theme="dark"] { --bg:#151a18; --text:#e8eee6; --accent:#b5cf9c; } body[data-section] { --sp:var(--accent); --sp-rgb:var(--accent-rgb); --sp-dim:var(--accent-dim); }

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.

7.2 Layout, navigation and accessibility

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.

7.3 The episode player

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.

8. Community Systems

8.1 Watch-time levels and avatar frames

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.

// community_repo.php — level curve level = 1 + floor( sqrt( watched_ms / 1800000 ) ) // level N begins at 30·(N−1)² minutes
LevelsFrameWatching time to reach
1–4Fern0
5–9Tide8 h
10–19Celestial40 h 30 min
20–34Phoenix180 h 30 min
35+Aurora578 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.

8.2 Votes, reputation and the forum

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.

8.3 Inbox and release notifications

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.

8.4 Achievements and referral seasons

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.

8.5 AI community accounts

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.

9. Repository Pattern for Data Access

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.

RepositoryDomain
anime_repo.phpCatalogue, episodes, cdn_url(), alternative titles
user_anime_repo.phpWatchlist status, favourites, scores
comments_repo.php · forum_helper.phpComments and forum threads/posts
community_repo.phpLevels, playback lease, votes, reputation
notifications_repo.php · episode_notifications.phpInbox, composer, release alerts, publication lock
achievements_repo.php · referrals_repo.phpBadges, banners, invite codes, seasons
bots_repo.phpBot selection, cooldowns, publication guards

10. Testing, and Decisions I Would Make Differently

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:

⌖ Mistake 1 — Developing Against the Wrong Schema

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.

⌖ Mistake 2 — Pseudo-element Blur

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.

⌖ Mistake 3 — Debug Output in Production

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.

⌖ Mistake 4 — Conflating Upload with Publication

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.

⌖ Trade-off — No Foreign Keys

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.

11. Stack Summary

LayerTechnology
Web applicationPHP 8 (strict types), PDO, Apache .htaccess, Hostinger hosting
DatabaseMySQL / InnoDB, utf8mb4, advisory locks
MediaBunnyCDN Storage API + pull zone, direct MP4
EmailPHPMailer over authenticated SMTP
FrontendVanilla JS, CSS custom properties, Server-Sent Events, canvas
PublishingPython, Flask 3, Playwright, ftplib (FTP/FTPS), ThreadPoolExecutor
AutomationPHP CLI cron worker, Eden AI V3 chat completions
TestingPython + 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.

3
Pipeline Phases
22
Achievements
4
Feature Migrations
Stack
PHP 8 · MySQL · BunnyCDN · Flask · Playwright · Eden AI
Author
Yi Jie Liu
2025–2026