1. What Astra Is and Why I Built It

Astra started as a personal need: I wanted a single place to track and consume anime and webtoon content I owned or had access to, without the friction of scattered files, inconsistent naming, or platforms that don't let you control playback. The goal was never to replicate something like Plex or Jellyfin — those are general-purpose media servers designed around local storage. Astra is built around a CDN-first model where media is served from BunnyCDN edge nodes, meaning zero local disk involvement and globally consistent latency.

The project grew into something more interesting than a simple media browser. I ended up building a full watchlist system, a webtoon reader with focus mode, an SSE-driven background sync layer, and a frontend design system with section-specific theming. Each of those pieces has an architectural story behind it — this document is that story, written in the order I actually made the decisions.

The platform is live at myastra.fun and serves two distinct content types: anime (video, streamed via HLS/mp4 from BunnyCDN) and webtoon (long-form manga-style image strips, also served from CDN). Those two content types have fundamentally different delivery and display requirements, which is why they got entirely separate subsystems rather than a shared abstraction.

2. CDN Architecture — BunnyCDN as the Media Layer

The first architectural decision was to not store any media on the web server. The PHP application server (a standard VPS) handles routing, authentication, template rendering, and database queries. It never touches video bytes or image data. All media is pulled directly from BunnyCDN by the browser.

I chose BunnyCDN over alternatives like Cloudflare Stream or AWS CloudFront for two reasons. First, pricing: BunnyCDN charges per GB of traffic at a flat rate with no per-request fees, which is predictable for a personal platform with variable traffic patterns. Second, the storage API: BunnyCDN exposes a simple REST API for listing and querying storage zones, which I use in the sync layer to discover new episodes without maintaining a separate manifest file.

The platform uses two separate BunnyCDN storage zones, each with its own base URL constant defined in config.php:

// config.php define('CDN_BASE', 'https://[zone].b-cdn.net'); // anime video define('WEBTOON_CDN_BASE', 'https://[zone2].b-cdn.net'); // webtoon images

Separating the zones was a deliberate decision rather than an oversight. Anime episodes are large (300MB–2GB per file) and access patterns are bursty — a user binges a series then doesn't touch it for a week. Webtoon pages are small (50–300KB each) but accessed in rapid sequential bursts as the reader scrolls. Keeping them in separate zones means I can tune cache TTLs and pull zones independently, and billing is separated cleanly by content type.

2.1 Video Delivery

Each anime episode is stored at a predictable path inside the CDN storage zone, organized by show folder and episode filename. The PHP layer never streams the video — it generates a CDN URL and hands it to the browser's native <video> element. The player runs entirely client-side: HTML5 video with a custom speed control overlay I added as a <select> wired to video.playbackRate.

// episode.php — speed control const speedSel = document.getElementById('vidSpeed'); speedSel.addEventListener('change', () => { vid.playbackRate = parseFloat(speedSel.value); });

The simplicity here is intentional. I considered implementing HLS segmented streaming for adaptive bitrate, but the content library has consistent encoding quality at a fixed bitrate, so ABR would add complexity without delivering a meaningful user benefit. A direct mp4 link to the CDN is sufficient and eliminates the need for a transcoding pipeline.

2.2 Webtoon Image Delivery

Webtoon chapters are different. A chapter consists of 20–80 sequential image strips (typically 800px wide, variable height), all of which need to be loaded as the user scrolls. The CDN serves each image individually. The reader page renders them stacked vertically — no pagination, no lazy-load gating — which is the standard webtoon reading convention.

Page discovery is handled through a HEAD-request scan against the CDN. When a new chapter is synced, the system iterates through candidate page numbers (01.jpg, 02.jpg, ...) and sends HEAD requests to confirm existence before writing the page records to the database. This avoids storing a file manifest separately and works reliably as long as the CDN responds with 404 for missing keys — which BunnyCDN does correctly.

// Pseudocode — webtoon page discovery during sync for ($i = 1; $i <= MAX_PAGES; $i++) { $url = WEBTOON_CDN_BASE . "/$folder/$chapter/" . str_pad($i, 2, '0', STR_PAD_LEFT) . '.jpg'; $head = get_headers($url); if (!str_contains($head[0], '200')) break; // insert page record into manhwa_pages }

3. Database Schema

The database is MySQL accessed through PDO with strict typing enforced via declare(strict_types=1) at the top of every PHP file. I'll go through each table and explain the reasoning behind the column choices rather than just listing them.

3.1 Anime Tables

The anime table is the master record for each series. slug is the URL-safe identifier used in all routes — I prefer slugs over numeric IDs in URLs because they're stable across database reseeds and readable in browser history. folder maps to the BunnyCDN storage path, allowing the sync system to connect CDN data to the database record without needing a separate mapping file.

ColumnTypePurpose
idINT PKInternal identifier
slugVARCHAR UNIQUEURL identifier — used in all routes
titleVARCHARDisplay name
cover_urlTEXTFull CDN URL for cover art
statusVARCHARAiring / Completed / Hiatus
folderVARCHARBunnyCDN storage folder name
descriptionTEXTSynopsis, nullable
created_atDATETIMERecord insertion time

The episodes table links episodes to their parent series and stores the CDN path for the video file. I chose to store the full CDN path rather than constructing it at query time because folder structures occasionally deviate from the show slug — some shows have season subdirectories, others don't.

ColumnTypePurpose
idINT PKInternal identifier
anime_idINT FKReferences anime.id
episode_numberINTDisplay episode number
titleVARCHAREpisode title, nullable
cdn_pathTEXTFull CDN URL for the video file
duration_secsINTDuration in seconds, nullable
created_atDATETIMERecord insertion time

3.2 Webtoon Tables

The webtoon side has three tables where anime has two, because chapters are discrete navigable units (unlike episodes, which are just links) and pages are first-class records needed for the reader.

TableKey ColumnsNotes
manhwaslug, title, cover_url, folder, alt_namesalt_names is a comma-separated search field — enables finding a series by its Korean title or romanized variants
manhwa_chaptersmanhwa_id, chapter_number, title, cdn_prefixcdn_prefix is the CDN folder path for the chapter; pages are addressed relative to it
manhwa_pageschapter_id, page_number, cdn_urlOne row per image strip; full CDN URL stored to avoid reconstruction logic at render time

3.3 User & Watchlist Tables

The users table is deliberately minimal. I resisted the temptation to add columns during development — anything not needed by the current feature set doesn't exist. This paid off: when I needed to fix a 500 error caused by querying a non-existent user_history table in an earlier version, the clean schema made the diagnosis immediate.

ColumnTypePurpose
idINT PKInternal identifier
usernameVARCHAR UNIQUELogin handle and public display name
password_hashVARCHARbcrypt hash — never plaintext
avatar_pathVARCHARServer-relative path to uploaded avatar
bioTEXTUser-written profile bio, nullable
roleVARCHARuser / admin
created_atDATETIMERegistration timestamp

The user_anime table is the watchlist system. It's a pivot table between users and anime with additional state columns. I use a single table for all list states rather than separate favorites, watching, and completed tables — this keeps queries simple and makes it easy to show a user's full library in one join.

ColumnTypePurpose
user_idINT FKReferences users.id
anime_idINT FKReferences anime.id
statusENUMwatching / completed / planned / dropped
is_favoriteTINYINTBoolean flag — shown separately on profiles
progress_epINTLast watched episode number
updated_atDATETIMELast status change

3.4 Sync & Operations Tables

Two tables support background operations rather than user-facing features:

TablePurpose
cdn_scansRecords the result of each BunnyCDN scan — timestamp, items found, bytes counted. Used to determine cache freshness and avoid redundant API calls.
manhwa_sync_jobsQueue table for webtoon sync operations. When a new manhwa folder is detected, a job row is inserted; a background process picks it up, runs the HEAD-request page scan, and marks the job complete.

4. The SSE Sync Layer

The anime stats shown on the library page — total titles, episode count, total storage — are not computed live on each request. Computing them live would require listing BunnyCDN storage contents on every page load, which is both slow (network I/O to a CDN API) and potentially rate-limited. Instead, I cache these numbers in a JSON file on disk and refresh them in the background using Server-Sent Events.

// SSE sync flow — bunny_stats_sse.php PAGE LOAD (cache stale or absent) │ ▼ EventSource('/api/bunny_stats_sse.php') // browser opens SSE connection │ ▼ PHP: List BunnyCDN folders via REST API // phase: listing │ emit progress events to browser ▼ PHP: Count episodes + sum bytes per folder // phase: counting │ emit progress events to browser ▼ Write .cache/bunny_stats.json // atomic write Write .cache/bunny_folder_stats.json │ ▼ emit 'done' event → browser reloads // window.location.reload()

The cache TTL is 15 minutes (900 seconds). On page load, PHP checks generated_at in the stats JSON — if the cache is fresh, no JavaScript runs and the page renders immediately with the stored numbers. If the cache is stale or absent, a small script block opens an EventSource connection to the SSE endpoint.

An earlier version of this sync displayed a visual toast overlay in the UI to show sync progress. I moved it to the browser console instead — the UI toast was distracting and felt like debug output polluting the end-user experience. The SSE events still fire with the same data; they're just logged with styled console.log calls (%c[Astra Sync] in teal) rather than rendered as DOM elements.

// Console-only sync progress — anime/index.php var es = new EventSource('/api/bunny_stats_sse.php'); console.log('%c[Astra Sync] Starting archive stats sync…', 'color:#00e0a4;font-weight:bold;'); es.addEventListener('done', function() { es.close(); setTimeout(function() { window.location.reload(); }, 500); });

5. PHP Layout System

Every page on the platform shares a common layout: the topbar, the search overlay, the mobile bottom navigation, and the footer. Rather than duplicating this markup across files, I centralized it in layout.php with two exported functions: render_header(string $title, string $section = '') and render_footer().

The $section parameter is the key architectural piece. It gets written into the body tag as a data-section attribute, which CSS uses to apply per-section theming:

// layout.php — render_header echo "<body data-section='" . h($section) . "'>"; // assets/style.css — section theming body[data-section="anime"] { --sp: #22d3ee; --sp-dim: #0e7490; --sp-rgb: 34,211,238; } body[data-section="webtoon"] { --sp: #f59e0b; --sp-dim: #b45309; --sp-rgb: 245,158,11; } body { --sp: #e85d04; --sp-dim: #c2410c; --sp-rgb: 232,93,4; }

The --sp variable (section primary) is used throughout the component CSS for accent colors on buttons, hover states, tag badges, and episode list highlights. This means every component automatically picks up the correct section color without any conditional logic in the PHP templates — the CSS cascade handles it entirely.

render_footer() closes the <main> tag and outputs three things: the search overlay, the mobile bottom navigation, and the shared JavaScript block. Putting all JS in render_footer() means it executes after the DOM is fully rendered — no DOMContentLoaded wrappers needed.

5.1 Search Overlay

The search overlay is a full-screen modal (#searchOverlay) triggered by a button in the topbar, the / key, or Ctrl+K. It has two tabs — Anime and Webtoon — and the active tab determines where the form submits: /anime/?q= or /webtoon/?q=. The active tab pre-selects based on the current page's body.dataset.section, so opening search from the anime section defaults to searching anime.

5.2 Mobile Bottom Navigation

On mobile, the topbar collapses and a fixed bottom navigation bar (.site-nav-mobile) appears with five slots: Home, Anime, Webtoon, Search, and Profile. The active state is determined by comparing window.location.pathname to a data-path attribute on each button, with prefix matching for section pages. This runs inline in render_footer() rather than a separate file to keep the JS footprint small.

6. Authentication & CSRF

Authentication is session-based. On login, the user's ID, username, and role are written into $_SESSION after bcrypt password verification. Every protected route calls auth_require_login() at the top, which redirects to the login page if no valid session exists.

State-mutating POST requests — watchlist updates, profile edits, comments — all require a CSRF token. The token is generated once per session and stored in $_SESSION['csrf']. Forms include it as a hidden field; the handler verifies it with hash_equals() to prevent timing attacks. I deliberately use hash_equals rather than a direct string comparison here — it's one of the places where the security coursework at Royal Holloway informed a concrete code decision.

⌖ Design Decision — CSRF

Using hash_equals() for CSRF token comparison prevents timing-based token oracle attacks. A naive === comparison short-circuits on the first mismatched byte, making it possible (in theory) to brute-force token bytes one at a time by measuring response time. hash_equals() always takes constant time regardless of where the comparison fails.

Admin routes have an additional role check: auth_require_role('admin') reads the role from the session and responds with HTTP 403 if it doesn't match. I don't expose any admin functionality through the same routes as user functionality — the admin panel is a separate file tree.

7. Frontend Design System

The frontend is written in plain CSS and vanilla JavaScript — no framework, no build step. I made this choice early and it's held up well. The platform's interactivity is modest: a search overlay, a video speed control, a webtoon focus mode, a mobile nav. None of these required React or Vue. The absence of a build pipeline means deployment is a file copy, which suits a single-developer project.

7.1 Glassmorphism

The visual language is dark glassmorphism: near-black backgrounds with blur-layered surface panels. The topbar is the clearest example — it uses backdrop-filter: blur(20px) saturate(160%) over a semi-transparent dark fill, creating the impression of frosted glass over whatever content is scrolling behind it.

/* .topbar */ background: rgba(9, 9, 13, .82); backdrop-filter: blur(20px) saturate(160%); -webkit-backdrop-filter: blur(20px) saturate(160%);

The topbar also responds to scroll. Once the user passes 72px, a .is-compact class is applied via JavaScript, shrinking the height and adding a sharper box shadow. This gives the navigation a sense of depth at rest that becomes more pronounced as the user descends into page content.

7.2 Blurred Hero Backgrounds

On anime detail pages and webtoon series pages, the cover art bleeds out behind the hero section as a blurred, darkened background wash. This is implemented with an explicit <div class="anime-hero-bg"> element — a child of the hero section — rather than a CSS pseudo-element.

The reason for the explicit div is a CSS limitation: inset: calc(-1 * var(--pad)) is invalid because you can't negate a var() expression directly. An earlier version used ::before with that pattern and the blur simply didn't render. The div approach is cleaner anyway — it's easier to debug in DevTools and avoids pseudo-element stacking context issues.

/* .anime-hero-bg — blurred cover art layer */ .anime-hero-bg { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(32px) brightness(.2); transform: scale(1.1); /* prevents blur edge artifacts */ z-index: 0; }

7.3 Webtoon Focus Mode

The webtoon reader has a focus mode that hides the navigation to give full-screen space to the page strip. It works by toggling a body.focus-mode class. CSS then suppresses the topbar with opacity: 0; pointer-events: none rather than display: none — this preserves layout flow and avoids the content reflow that would occur if the topbar's space were reclaimed.

Focus mode is triggered by a button in the reader or the Escape key. The same Escape key handler also closes the drawer menu if it's open, with priority given to the drawer: if the drawer is open, Escape closes the drawer; if only focus mode is active, Escape exits focus mode. This ordering is natural from a UX standpoint — the drawer is a foreground element that should always be dismissable first.

8. Repository Pattern for Data Access

Database access is not scattered across page files. Each content domain has a dedicated repository file — anime_repo.php, user_anime_repo.php — that owns all queries for that domain. Page files call repository functions and receive typed arrays; they never construct SQL directly.

// anime_repo.php — example function get_anime_list(string $q = ''): array { $pdo = db(); if ($q === '') { return $pdo->query("SELECT * FROM anime ORDER BY title ASC")->fetchAll() ?: []; } $stmt = $pdo->prepare("SELECT * FROM anime WHERE title LIKE ? ORDER BY title ASC"); $stmt->execute(['%' . $q . '%']); return $stmt->fetchAll() ?: []; }

The db() function returns a singleton PDO instance with error mode set to ERRMODE_EXCEPTION and FETCH_ASSOC as the default fetch mode. I use exceptions rather than return-value checking because exceptions propagate naturally through call stacks — a query failure in a repository function will surface at the page level without requiring explicit error handling at every callsite.

9. Decisions I Would Make Differently

Not everything was right on the first pass. Three things stand out as genuine missteps that I had to correct during development.

⌖ Mistake 1 — Querying Non-Existent Tables

An early version of profile.php queried a user_history table and a last_seen column that didn't exist in the production database. This caused a 500 error in production — the page was completely broken. The fix was straightforward (remove the query, use only user_anime data) but it happened because I was developing against a different schema than production. Lesson: keep a single canonical schema file and run it in both environments.

⌖ Mistake 2 — CSS Pseudo-Element Blur

The series hero blurred background was first implemented with a ::before pseudo-element using inset: calc(-1 * var(--pad)). This is invalid CSS — you can't negate a custom property with -1 * inside calc() without an intermediate variable. The blur simply didn't apply. I should have tested the pseudo-element approach in isolation before integrating it. The fix was replacing the pseudo-element with a plain <div>, which is arguably cleaner anyway.

⌖ Mistake 3 — UI Debug Output in Production

The SSE sync progress was originally rendered as a visible toast overlay in the bottom corner of the anime library page. It was useful during development but confusing in production — users saw a syncing indicator for a process they had no mental model of. Moving it to the browser console keeps the debugging value without polluting the UI. I should have considered the production UX of debug output earlier.

10. Full Stack Summary

PHP 8
Backend runtime
MySQL
Relational database via PDO
BunnyCDN
Video and image delivery
SSE
Background sync protocol
Vanilla JS
No framework, no build step
Plain CSS
Custom properties, glassmorphism

The thing I'm most satisfied with on Astra is that each piece of complexity — the SSE sync, the section theming, the focus mode — solves a real problem rather than existing for its own sake. The CDN-first architecture means I never worry about server disk space. The repository pattern means I can change a query in one place without hunting through page files. The data-section theming means the anime and webtoon sections feel visually distinct without duplicating a single CSS rule.

It's a platform I actually use, which keeps the design honest. When something feels wrong to use, I fix it — that's why the sync toast moved to the console, why the webtoon reader got focus mode, why the profile page shows a watchlist rather than an empty shell. The code reflects accumulated friction points turned into solutions.

Contents
Platform
myastra.fun