Misaka Network, the Self-Propagating system that created itself to allow me to automate the processing of new videos and directories for my self-hosted streaming service. How I replaced a monolithic admin script with a webhook-driven, event-based system. A deep dive into event-driven architecture, Strapi V5 webhooks, anime references, and automating media ingestion without writing a single loop.
A technical deep-dive into event-driven architecture and how I replaced a monolithic admin script with a clean, reactive webhook system in Unlimited Blades Work.
What are Webhooks?
Before I tell you about my streaming app and the architectural shift I made, let me establish some common ground. Webhooks are something most developers have heard of, but their full power is often underappreciated until you find the right problem for them to solve.
At their core, a webhook is a simple idea: instead of your application constantly asking another service "hey, did anything change?", you tell that other service "when something changes, let me know by calling this URL". It is a push-based communication model as opposed to the traditional pull-based polling model.
More concretely, a webhook is an HTTP endpoint that you expose in your application. When an event happens in an external system, that system sends an HTTP POST request to your endpoint with a payload that describes what just happened. Your server receives that payload, does something with it, and returns a response to acknowledge receipt.
That is all there is to it. There is no persistent connection, no socket to maintain, no background polling loop. You simply register a URL and wait for the world to call you.
The payload a webhook sends typically looks like structured JSON data. It contains enough information to understand what happened, what entity changed, and what the new state of that entity is. In the context of a content management system like Strapi V5, a webhook might tell you that a specific entry was just created, that a user updated a record, or that content was published or deleted.
How do webhooks work?
The lifecycle of a webhook call is straightforward, but understanding each step helps clarify why this pattern is so powerful.
sequenceDiagram
participant User as User / Admin
participant CMS as Strapi V5 (CMS)
participant Backend as Private Cloud Backend
participant Queue as Job Queue
User->>CMS: Creates or updates a Directory entry
CMS->>Backend: POST /webhooks/entry.create (JSON payload)
Backend->>Backend: Validates event type and UID
Backend->>Queue: Adds PROCESS_DIRECTORY job
Backend-->>CMS: 200 OK (acknowledged)
Note over Backend,Queue: Webhook handler returns immediately
Queue->>Backend: Job is picked up by cron engine
Backend->>Backend: Processes directory on disk
The sequence above illustrates the key insight: the webhook handler does its job quickly and returns. It doesn't block, it doesn't process the full task inline. It simply acknowledges the event and delegates the heavy work to a job queue. This makes the webhook endpoint resilient and fast, and it decouples the moment of notification from the moment of processing.
The external system, Strapi in this case, does not care whether the actual processing finishes in one second or one hour. It only needs to know that the backend received the signal. From Strapi's perspective, the transaction is complete the moment it gets a 200 OK response.
What are Event-Driven Applications?
A webhook is the mechanism, but the broader philosophy behind it is called event-driven architecture. In an event-driven system, the flow of the application is determined by events rather than by a sequential, imperative script that runs from top to bottom.
The contrast with traditional procedural code is striking. In a traditional script, the author of the code decides the full sequence of operations at the time of writing. The program runs, executes step one, then step two, then step three, and finishes. The entire lifecycle is visible in a single place, and the program is in full control of timing and ordering.
In an event-driven system, things are different. Individual components do not know about each other in detail. They only know how to react to a specific event. A component receives an event, does its specific job, and as a side effect of that job it might trigger new events for other components to react to. The system is alive. It responds to the world as it changes.
graph LR
A["Traditional Script"] --> B["Step 1: Scan directories"]
B --> C["Step 2: Verify parent in DB"]
C --> D["Step 3: Store directories"]
D --> E["Step 4: Process episodes"]
E --> F["Step 5: Run FFmpeg"]
F --> G["Done (or failed)"]
H["Event-Driven System"] --> I["Event: directory.created"]
I --> J["Queue job"]
J --> K["Process directory"]
K --> L["Event: child_directory.created"]
L --> I
K --> M["Event: episode.created"]
M --> N["Queue episode job"]
N --> O["Process episode (FFmpeg)"]
The event-driven model comes with significant benefits. It is more resilient because each component is isolated and a failure in one does not cascade to everything else. It is more scalable because you can process multiple events concurrently. It is also far more maintainable because each handler has a single, well-defined responsibility.
The downside is that it requires more upfront design. You need to think in terms of events, producers, and consumers rather than a simple script. And debugging can be harder because the flow of execution is not linear. But for complex, long-running operations like processing a tree of media files, the tradeoffs are absolutely worth it.
The problem I needed to solve
Unlimited Blades Work is a self-hosted streaming application that I run on my private cloud. It allows me to watch movies, anime, and TV series from any device. The content is stored on disk in a hierarchical folder structure: top-level directories contain subdirectories, which in turn contain video files. A directory might be an anime series, and its subdirectories might be seasons, with individual episodes inside each season.
The application uses Strapi V5 as its headless CMS and database layer. Every directory and every episode exists as a Strapi entry. The streaming backend then reads those entries to know where the files live on disk, what metadata they have, what audio tracks and subtitle tracks they contain, and so on.
The structural challenge here is fundamental: I needed to map a tree of data on disk into a relational database. A file system is a tree. A relational database is tables with foreign key relationships. These two models do not naturally align, and bridging them requires careful thought (believe me).
The original script to intialize the database
My first solution was a single, monolithic TypeScript script called initializeDatabase.ts. When I wanted to add new content to the library, I would SSH into my server, navigate to the backend project, and run this script manually. It would scan the disk, figure out what needed to be added to Strapi, and do all the work in one sequential pass.
The approach was clever in its own way. As the name implies, the initializeDatabase.ts script scanned the entire disk using a recirsive strategy, then for each folder/video file it find it would process it or add it to the pendingToScan array that was being iterated with a WHILE loop that checked whether or not the pendingToScan array had anything inside of it.
That way ensuring that with one single script, the app would be usable and then I'd just have to run a different, smaller script for processing videos. It required a lot of manual work for the new entries but when you have a very large media library to begin with (like me) a script like this is all you need to start watching your movies and stuff.
Scanning the disk
The script started by calling scanAndOrganizeDirectories function, which accepted a list of root paths from environment variables (the INITIAL_PATH env variable). For each root path, it would call scanSingleFolder function from the disk service. This function read the filesystem at that path and returned a LocalDirectory object containing the folder's display name, its path relative to the base, its age rating (derived from naming conventions on the folder itself), a list of its immediate child subdirectories, and a list of its immediate video files as LocalEpisode objects.
The pending queue pattern
The core of the scanning logic used a classic breadth-first traversal pattern. After scanning the root directory, its child directory paths were placed into a pendingToScan array. Then a while loop kept running as long as that array had items. Each iteration would pop a path from the array, scan it, add its results to the final list, and push any new child directories it found back into the pending array.
flowchart TD
A["Start: Root directories from env"] --> B["scanSingleFolder for each root"]
B --> C["Collect child directories into pendingToScan"]
C --> D{{"pendingToScan is empty?"}}
D -- "No" --> E["Pop a path from pendingToScan"]
E --> F["scanSingleFolder for this path"]
F --> G["Add result to finalResult"]
G --> H["Push child dirs into pendingToScan"]
H --> D
D -- "Yes" --> I["All directories collected"]
I --> J["sortDirectories by depth"]
J --> K["Begin uploading to Strapi"]
This loop meant that after the scan phase, finalResult contained every single directory in the entire tree, flattened into a single array. The tree structure was represented not by nesting but by each LocalDirectory holding a parent_directory path string.
Sorting by depth
Once the flat list was assembled, sortDirectories would sort it by the number of path segments in the parent_directory field. Directories with fewer levels came last. This was critical for the next phase: you cannot create a child directory in Strapi if its parent does not exist yet. By processing parents before children, the script ensured that every time it needed to link a child to its parent, the parent was already in the database.
Uploading to Strapi
The main loop then iterated through the sorted array of directories. For each one, it would:
- Check if the directory already existed in Strapi using
verifyDirectoryExistance, to avoidcreating duplicates. - If the directory had a parent, query Strapi to find the parent's
documentId, since that ID isneeded to create the foreign key relationship. - Create the directory entry in Strapi using the platform service SDK (my custom automated solution for consuming Strapi's data).
- For each episode inside that directory, call
verifyEpisodeExistanceto check if it alreadyexisted. - If the episode was a V2 file (MKV and similar formats that require processing), invoke theFFmpeg service via
processVideoFileto extract audio tracks, subtitle tracks, and durationmetadata. - Store or update the episode entry in Strapi with all of that data.
The manual script to process video files
Separate from the database initialization, there was also a testFfmpeg.ts script (later renamed to a more general video processing script) that I would run to test the FFmpeg pipeline against a specific file. The workflow was:
- SSH into the server.
- Navigate to the backend project directory.
- Run the script with the path to the video file as a command-line argument.
- Wait for FFmpeg to finish extracting audio and subtitle tracks.
- Copy the resulting metadata manually.
- Create or update the episode entry in Strapi by hand.
This was tedious for a single episode, and completely impractical for an entire new anime series with multiple seasons and dozens of episodes.
Where the script started to break down
The script worked. It solved the problem. But over time it grew. Edge cases were added: what if a directory had already been partially imported? What if an episode existed but with a different version? What if the parent directory couldn't be found? What if FFmpeg failed for one episode but not others? Each new edge case added more conditional branches, more error-tracking arrays, more logging, more complexity.
The initializeDatabase.ts file grew past 3000 lines of densely interleaved logic. The utils it depended on added hundreds more. The script had too many responsibilities. It scanned the disk, validated environments, managed Strapi communications, ran FFmpeg, tracked failed and skipped entries, and wrote debug JSON files. It knew too much about everything.
Even for me, the author, tracing a bug through that script required holding a lot of context in mind at once. And running it required SSH access to the server every single time I downloaded something new.
There had to be a better way.
The new system: Webhooks and the Misaka Network (Yes, thats the name)
The new system is built around a simple principle: instead of the backend doing all the work in one go, Strapi V5 tells the backend what to do as entries change. The backend only needs to handle one directory or one episode at a time, and Strapi's own event system becomes the engine that drives the whole process forward.
I named the service layer handling this logic the Misaka Network, an internal codename for the subsystem that processes media content reactively. Why? Because it simply delegates the processing of a video (which is hard, takes a lot of time, and calculations) to an external brain that does the heavy lifting. And because it sounds cool.
The high-level architecture
graph TD
User["User (via Strapi Admin UI or Smartphone)"]
Strapi["Strapi V5 CMS"]
Backend["Express Backend"]
Queue["Job Queue (SQLite)"]
Engine["Stateful Cron Engine"]
Misaka["Misaka Network Service"]
Disk["Disk (Video Files)"]
FFmpeg["FFmpeg Service"]
User -->|"Creates/Updates directory or episode"| Strapi
Strapi -->|"Webhook POST payload"| Backend
Backend -->|"Adds job to queue"| Queue
Engine -->|"Polls queue on schedule"| Queue
Engine -->|"Dispatches job"| Misaka
Misaka -->|"Scans filesystem"| Disk
Misaka -->|"Invokes FFmpeg"| FFmpeg
Misaka -->|"Creates child directories & episodes"| Strapi
Strapi -->|"Fires new webhooks for each new entry"| Backend
The beauty of this design is that the loop closes itself. When the Misaka Network creates child directories in Strapi as part of processing a parent directory, Strapi fires new entry.create webhooks for each one. Those webhooks arrive at the backend, get queued, and get processed in turn.
No explicit loop or recursion is needed. The system is self-propagating (I never though I could create a system that I could describe as Self-Propagating in my entire life LOL).
Believe it or not, this entire system spawned by accident actually. It just started as "What if I could add a movie in strapi, and fire this event to process it automatically?", then "What if I do the same with directories?" voalá the system created itself.
The controller layer
The controllers are the first point of contact when Strapi sends a webhook. There is a dedicated controller for each event type that Strapi can emit:
-
strapiWebhook.entry.create.controller.tshandlesentry.createevents. -
strapiWebhook.entry.update.controller.tshandlesentry.updateevents. -
strapiWebhook.entry.delete.controller.tshandlesentry.deleteevents. -
strapiWebhook.entry.publish.controller.tshandlesentry.publishevents. -
strapiEngine.controller.tsis a special admin controller for accelerating and decelerate the cron job engine manually (from Strapi).
Each controller's job is intentionally narrow. It receives the payload, validates that the event type matches what it expects, inspects the uid field to determine which content type the event refers to (directory, episode, social media post, etc.), and then either rejects the request or adds a job to the queue. Then it responds with 200 OK and returns.
The types for the webhook payload are defined in a file called strapiWebhook.types.ts. A StrapiWebhookPayload carries the event name, the timestamp, the model name, the content type UID, and the full entry object. An enum called StrapiEventName lists all the possible event strings, and another enum called ModelNames lists the content type UIDs that the backend cares about.
For the directory and episode controllers specifically, there is a guard check: a directory will only be queued for processing if its is_processing field is set to true. This is the toggle that allows you to create entries in Strapi without triggering processing, which is useful for initial setup or manual data correction.
Here is the flow through the create controller:
flowchart TD
A["POST /webhooks/entry.create"] --> B{{"event === 'entry.create'?"}}
B -- "No" --> C["Return 400 Bad Request"]
B -- "Yes" --> D{{"uid === 'api::b-episode.b-episode'?"}}
D -- "Yes" --> E{{"version === 'V1'?"}}
E -- "Yes" --> F["Return 200 OK (no processing needed for mp4)"]
E -- "No" --> G["addJobToQueue(PROCESS_EPISODE)"]
G --> H["Return 200 OK"]
D -- "No" --> I{{"uid === 'api::b-directory.b-directory'?"}}
I -- "Yes" --> J{{"is_processing === true?"}}
J -- "No" --> K["Return 200 OK (ignored)"]
J -- "Yes" --> L["addJobToQueue(PROCESS_DIRECTORY)"]
L --> H
I -- "No" --> M["Return 404 Unknown UID"]
The job queue and cron engine
When a controller calls addJobToQueue, it writes a record to a local MySQL database. Each job has a type (PROCESS_DIRECTORY or PROCESS_EPISODE), a name for logging purposes, and a JSON payload containing the full Strapi entry.
The cron engine is managed by strapiEngineController. An admin can start the engine by sending a request to the engine endpoint with action: start_engine and specifying how often it should run (every) and for how long (during). The engine then picks jobs from the queue on that schedule and dispatches them to the appropriate processor. The default is that a cron job executes once every hour, but some jobs may come in loads and loads of small tasks that don't require 1 hour to process (like processing a video file would) so the engine is able to accelerate this process by setting a cron to process job queues every (1min, 2min, 5min, etc, a custom time leap basically). This engine is also built with an auto-stop ability, if the specified amount of time (set in a during property) is achieved, or no more jobs are pending to be processed, the engine stops and leaves the rest for the default cron job.
This design gives full control over when and how aggressively the backend processes media, and other jobs in general like posting this very article in LinkedIn and Dev.to from my personal blog without having to manually write 3 articles every time.
The Misaka Network: processDirectoryWebhook
When the cron engine picks up a PROCESS_DIRECTORY job, it calls processDirectoryWebhook. This function is the orchestrator for everything that needs to happen when a directory is processed.
Its steps, in order, are:
1. Resolve the path on disk.
The function takes the path field from the Strapi entry and tries to access it on the filesystem. If the path does not exist at that location, it falls back to constructing a path from the parent directory's path and the entry's display name. This handles cases where the path was set slightly differently during entry creation.
2. Scan the directory on disk (scanDirectoryOnDisk).
This function reads the directory and categorizes every item inside it. Video files are separated into episodes with their version determined by file extension (.mp4 is V1, .mkv and similar formats are V2). Child subdirectory names are collected. The presence of a cover.jpg file is noted. And if a metadata.json file is found, it is parsed to extract tags and a description for the directory.
3. Process episodes in the directory (processEpisodesInDirectory).
For each video file found on disk, the function queries Strapi to see if an episode entry already exists. If it does not, it creates one. V2 episodes are created with is_processing: true, which immediately triggers an entry.create webhook to Strapi, which the backend will queue as a PROCESS_EPISODE job. If an episode entry already exists but has incorrect metadata state, the function marks it as is_processing: true to trigger reprocessing.
4. Upload the directory cover (uploadDirectoryCover).
If a cover.jpg file was found, it is read from disk and uploaded to Strapi's media library via the REST upload endpoint. The returned media ID is stored for the finalization step.
5. Resolve directory tags (resolveDirectoryTags).
Tags from the metadata.json file are resolved one by one. For each tag name, the function checks if a matching tag entry already exists in Strapi. If it does, its ID is reused. If it does not, a new tag is created. The resulting list of tag document IDs is stored for finalization.
6. Process child directories (processChildDirectories).
For each subdirectory name found on disk, the function checks if a directory entry already exists in Strapi with the matching path. If it does not exist, it creates a new directory entry with is_processing: true and the current directory as its parent. If a matching entry already exists (perhaps from a previous partial import), it updates it to set is_processing: true and attach the correct parent.
The key insight here is that creating these child directory entries in Strapi with is_processing: true is what fires the next round of webhooks. The backend is not calling itself recursively. It is delegating the continuation of the process to Strapi's event system.
7. Finalize the directory (finalizeDirectory).
Once all the above steps are complete, the function updates the original directory entry in Strapi with the clean display name (stripping the age rating prefixes), the determined age rating, the cover image ID, the tag IDs, the description, and is_processing: false. This marks the directory as done and applies all the enriched metadata.
Here is the complete flow of a single directory processing job:
flowchart TD
A["Job: PROCESS_DIRECTORY picked from queue"] --> B["Resolve real path on disk"]
B --> C["scanDirectoryOnDisk"]
C --> D["Episodes found"]
C --> E["Child directories found"]
C --> F["Cover found"]
C --> G["metadata.json found"]
D --> H["processEpisodesInDirectory"]
H --> H1["For each episode: create/update in Strapi"]
H1 --> H2["V2 episodes created with is_processing=true"]
H2 -->|"Strapi fires entry.create"| Webhook1["New PROCESS_EPISODE job queued"]
E --> I["processChildDirectories"]
I --> I1["For each child: create/update in Strapi with is_processing=true"]
I1 -->|"Strapi fires entry.create"| Webhook2["New PROCESS_DIRECTORY job queued"]
F --> J["uploadDirectoryCover"]
J --> J1["Cover uploaded to Strapi media library"]
G --> K["resolveDirectoryTags"]
K --> K1["Tags created or reused in Strapi"]
H1 & I1 & J1 & K1 --> L["finalizeDirectory"]
L --> L1["Update directory in Strapi with all metadata"]
L1 --> L2["is_processing set to false"]
The Misaka Network: processEpisodeWebhook
Episode processing is simpler but equally important. When the engine picks up a PROCESS_EPISODE job, it calls processEpisodeWebhook.
The function first checks whether processing is actually needed. V1 episodes (.mp4 files) do not need FFmpeg processing, as they are already in a web-friendly format. Episodes where is_processing is false are also skipped, since they have already been processed.
For V2 episodes that do need processing, the function constructs the full file path from the parent directory's path and the episode's display name and file type. It verifies the file exists on disk. Then it calls processVideoFile from the FFmpeg service.
The FFmpeg service does the heavy lifting: it uses ffprobe to extract the stream metadata (audio tracks with their languages and codecs, subtitle tracks with their languages, codecs, and whether they are text-based or bitmap-based), then uses ffmpeg to extract each audio track to .m4a files and each subtitle track to .vtt files, storing them in a mirrored directory structure under a .v2 folder.
Once FFmpeg has finished, processEpisodeWebhook updates the episode entry in Strapi with the extracted languages_info metadata and sets is_processing to false. This is the episode's final state: it is now fully processed, ready to be streamed.
The self-propagating loop in action
To fully appreciate the elegance of this system, it helps to trace through a concrete example. Suppose I download a new anime series called "My Anime Series". The folder structure on disk looks like this:
/volumes/anime/My Anime Series/
Season 1/
Episode 01.mkv
Episode 02.mkv
cover.jpg
Season 2/
Episode 01.mkv
cover.jpg
cover.jpg
metadata.json
Here is what happens when I create the top-level Directory entry in Strapi for "My Anime Series" and mark is_processing: true:
sequenceDiagram
participant Me as Me (Smartphone)
participant Strapi as Strapi V5
participant Backend as Backend / Queue
participant Engine as Cron Engine
participant Disk as Disk
Me->>Strapi: Create "My Anime Series" directory (is_processing: true)
Strapi->>Backend: entry.create webhook
Backend->>Backend: Queue PROCESS_DIRECTORY job
Engine->>Backend: Pick up job
Backend->>Disk: scanDirectoryOnDisk("/volumes/anime/My Anime Series/")
Disk-->>Backend: Episodes: none, Children: [Season 1, Season 2], cover.jpg, metadata.json
Backend->>Strapi: Create "Season 1" directory (is_processing: true)
Strapi->>Backend: entry.create webhook for Season 1
Backend->>Backend: Queue PROCESS_DIRECTORY job for Season 1
Backend->>Strapi: Create "Season 2" directory (is_processing: true)
Strapi->>Backend: entry.create webhook for Season 2
Backend->>Backend: Queue PROCESS_DIRECTORY job for Season 2
Backend->>Strapi: Upload cover.jpg for "My Anime Series"
Backend->>Strapi: Resolve tags from metadata.json
Backend->>Strapi: Finalize "My Anime Series" (is_processing: false)
Engine->>Backend: Pick up "Season 1" job
Backend->>Disk: scanDirectoryOnDisk("/volumes/anime/My Anime Series/Season 1/")
Disk-->>Backend: Episodes: [Ep01.mkv, Ep02.mkv], cover.jpg
Backend->>Strapi: Create "Episode 01" (version: V2, is_processing: true)
Strapi->>Backend: entry.create webhook for Episode 01
Backend->>Backend: Queue PROCESS_EPISODE job for Episode 01
Backend->>Strapi: Create "Episode 02" (version: V2, is_processing: true)
Strapi->>Backend: entry.create webhook for Episode 02
Backend->>Backend: Queue PROCESS_EPISODE job for Episode 02
Backend->>Strapi: Upload cover.jpg for "Season 1"
Backend->>Strapi: Finalize "Season 1" (is_processing: false)
Engine->>Backend: Pick up Episode 01 job
Backend->>Disk: Run FFmpeg on Episode 01.mkv
Backend->>Strapi: Update Episode 01 with languages_info, is_processing: false
And so on for Season 2 and its episodes. The entire tree is processed without me touching anything after the initial entry creation in Strapi. I could be watching something on my phone while the backend quietly works through the queue in the background.
Comparing the two approaches
Let me be direct about what changed and why it matters.
Responsibility and scope
The original script was responsible for everything from start to finish: environment validation, disk scanning, Strapi querying, duplicate checking, FFmpeg execution, error tracking, and writing debug output. Each of these concerns was interleaved with the others in a single file. If you wanted to understand how the episode duplicate check worked, you had to read through directory processing logic to find it.
The new system distributes responsibility cleanly. The controllers handle only webhook routing. The job queue handles only persistence. The cron engine handles only scheduling. The Misaka Network functions handle only specific aspects of media processing. Each file has one job.
Maintainability
Adding a new content type to the old script would require opening a 3000-plus line file and carefully threading new logic through the existing flow. In the new system, you add a new uid check in the relevant controller, write a new service function, and register a new job type. The existing code does not need to change.
Fixing a bug is similarly easier. If there is an issue with how cover images are uploaded, the problem is isolated to uploadDirectoryCover.ts. You do not need to reason about the entire processing pipeline to fix it.
Manual intervention
The old workflow required SSH access, command-line execution, and often manual data entry in the Strapi admin panel. If I was away from my desk, there was nothing I could do.
With the new system, my smartphone is sufficient. I open the Strapi admin panel in my browser, create or update an entry, and the backend handles the rest. I can add a full new anime series while commuting.
Scalability and resilience
The old script was an all-or-nothing operation. If it failed halfway through because FFmpeg crashed or the network blipped, you had to figure out what had already been processed and re-run the script with the right settings to avoid duplicating work. The state was entirely in memory during the run.
The new system is naturally resilient. Each job in the queue is an independent unit. If the FFmpeg process for Episode 03 crashes, the job is marked as failed and can be retried. Episodes 01, 02, 04, and 05 are not affected. The job queue persists across restarts, so if the server goes down mid-processing, the remaining jobs are still there when it comes back up.
Code volume vs. complexity
It might seem counterintuitive that I described the new webhook system as "much more complex in terms of code" while also calling it easier to maintain. More files, more interfaces, more type definitions. But the complexity in the new system is the right kind of complexity. Each file is small, focused, and understandable in isolation. The overall system is more sophisticated, but no single piece of it is overwhelming.
The old script had its complexity compressed into one place. The new system has its complexity spread across many small, decoupled pieces. Reading the old script meant reading everything. Reading the new system means reading only the piece you care about.
What I learned
Rewriting the initialization system as a webhook-driven, event-based pipeline taught me several things that I think are worth sharing.
Events are a natural fit for hierarchical data. A tree is inherently recursive. You process a node, and processing it produces children that need to be processed. An event-driven system handles this naturally because each new child entry fires a new event, which queues a new job. You never need an explicit recursion stack or a pending-items array. The system's own feedback loop handles it.
Fast webhook handlers are not optional. A webhook handler that does too much work inline is a footgun. If the handler times out, Strapi (or any other system) might retry the webhook, leading to duplicate jobs. By keeping the handler lightweight (validate, queue, respond), you avoid this problem entirely.
A job queue is the right bridge between events and work. Without the queue, you would need to process everything inline in the webhook handler, which defeats the purpose. The queue absorbs the event at network speed and lets the processing happen at disk speed, which is necessarily slower when FFmpeg is involved.
Naming and file organization carry enormous cognitive weight. The processChildDirectories, finalizeDirectory, resolveDirectoryTags, and scanDirectoryOnDisk functions are not just code. Their names are documentation. When I open the processDirectoryWebhook.ts orchestrator and see those function calls laid out in order, I understand the full processing pipeline at a glance. That is something the original monolithic script could never offer.
Closing Thoughts
Unlimited Blades Work started as a personal project to watch anime and movies from my own server. The first version of everything was built to work, not to last. The initialization script was a perfect example: it solved the immediate problem, but it was not designed to grow.
The webhook system is better in every dimension that matters for a long-lived project. It is faster to debug, easier to extend, more resilient to failure, and more pleasant to use from a purely operational standpoint. Being able to add new content from my smartphone, without opening a terminal or running a script, feels like the difference between driving a car and pushing one.
If you are building a system that needs to react to changes in a CMS, a database, or any external service, I strongly encourage you to look at webhooks and event-driven design before reaching for the procedural script. The upfront cost of thinking in terms of events and handlers pays itself back quickly, and the resulting architecture is one you can actually be proud of six months later.
The Misaka Network is not perfect. There are edge cases I have not handled, and the cron engine could be smarter about prioritizing jobs. But it is a system I can reason about, a system I can extend, and a system I can operate from anywhere. For a personal project running on a self-hosted server, that is exactly what I need.
Find me on:
Chaldea Foundation News
My Portfolio
Top comments (0)