How We Built Bluvy Tube
When we started working on Bluvy Tube, one of the first questions we had to answer was surprisingly simple: what does a video platform actually need to own?
The obvious answer would be the videos themselves. That is how most people think about a video platform: users upload a file, the platform stores it, processes it, delivers it, and everything happens inside the same infrastructure.
Bluvy Tube takes a different approach.
Bluvy Tube is a video platform built on AT Protocol. It can be used as a complete video client: people can discover videos, watch them, follow creators, explore channels and trends, and, importantly, upload videos directly from Bluvy Tube.
The interesting part is what happens after that upload.
When someone publishes a video through Bluvy Tube, the original video file is not stored on Bluvy's own servers. The file is sent to the video infrastructure used by Bluesky and the AT Protocol ecosystem. Bluvy keeps the information it needs to index the publication and build the user experience around it, but we do not operate our own permanent video-storage infrastructure.
That distinction has shaped almost every technical decision we have made.
Instead of spending our time building another large video-storage stack, we can concentrate on the parts that actually make Bluvy Tube useful: indexing, search, discovery, channels, recommendations, trends, moderation, and the overall viewing experience.
A user can therefore publish a video from Bluvy Tube without having to use the Bluesky interface for that particular action. At the same time, a video published from another client compatible with AT Protocol can potentially appear in Bluvy Tube as well, provided it is public and satisfies the criteria used by our indexer.
That separation is what makes the project interesting to us.
The network transports and stores the media. Bluvy builds the video experience on top of that network.
We are also not trying to turn Bluvy Tube into another competitor to the major American video platforms. That is not the ambition of the project.
What we want to explore is much more specific: what can you build when a video platform relies on an open protocol instead of owning and controlling identity, storage, and content distribution from end to end?
This article is about the architecture we ended up building, but also about the problems that only became obvious once we connected a real application directly to an open, constantly changing network.
There will be some code along the way. You can skip those parts if you are not interested in the implementation details; the overall story should still make sense without them.
There is also a less comfortable part of the story, and it is probably one of the most important things we learned:
an open network does not filter anything for you.
Connecting to an Open Network
At a high level, the architecture can almost be summarized in a single line:
AT Protocol ā Jetstream v2 ā Node.js indexer ā internal API ā Symfony ā MariaDBThe first component is our Node.js indexer, located in indexer/. It maintains a connection to the Bluesky firehose through Jetstream v2 and receives network events as they happen: posts being created, deleted or updated, along with the other changes that occur across the network.
The indexer's job is intentionally focused. It does not try to become our entire backend.
It looks at incoming events, determines whether they represent genuine video posts that are relevant to Bluvy, extracts the information we need, applies the cheapest and fastest filters first, and then sends the resulting data to our Symfony application.
Symfony takes over from there. It handles the business rules that are easier to evolve outside the firehose consumer: eligibility, moderation decisions, indexing, SEO, and the public API.
This separation turned out to be important.
The indexer needs to be fast and resilient because it is sitting directly in front of a continuous stream of events. The backend has a different set of concerns and needs to be able to evolve without turning the firehose consumer into one enormous application containing every business rule we might ever need.
AT Protocol already gives us several of the primitives required to make this possible:
- portable creator identities, represented by DIDs such as
did:plc:ā¦; - a standard video schema,
app.bsky.embed.video; - Bluesky's video infrastructure, including HLS delivery through
.m3u8manifests and media segments; - a system of moderation labels that can be provided by different labelers;
- and shared, addressable lists, including lists represented through
app.bsky.graph.list.
In other words, we did not have to invent an identity system, a video publication format, or a way for independent moderation services to contribute labels.
But using an open network also introduces a very important trade-off.
The firehose does not know what Bluvy wants.
It does not know our editorial policy, our audience, or what we consider appropriate for the product.
It simply tells us what is happening.
Everything that is happening.
And when you build a consumer product on top of an open network, you inherit not only the interesting parts of that network, but also everything you would rather not have appearing in your product.
That includes content that users absolutely should not encounter accidentally.
Pornography, a Dark Pillar of Bluesky
We want to be careful with this section because the terminology can easily become more judgmental than intended.
Calling pornography a "dark pillar" is not a moral judgment about sexual content, and it is not a judgment about the people who create it. We are not making a statement that adult content should not exist.
The problem we had to solve was much more practical.
On Bluesky, adult content is an important, high-volume, and organized part of the network. For a general-purpose video service like Bluvy Tube, that means we cannot simply connect to the firehose, find every video, and assume that the result is ready to show to users.
It isn't.
We need a dedicated moderation and filtering pipeline, and that pipeline has to operate before content reaches discovery.
Several things make this particularly clear:
- The protocol itself provides labels that can identify categories such as
porn,sexual,nudity,graphic-media, andsexual-figurative. - An ecosystem of labelers exists to identify and categorize this kind of content at scale.
- Community moderation lists can contain hundreds of thousands of accounts and are sometimes specifically designed to identify adult accounts.
Once we started measuring the stream, the scale became much easier to understand.
Here is what our indexer measured during a rolling 24-hour window:
Over 24 hours on Bluesky
Posts seen on the firehose 3,395,004 Deletions 314,426 Posts containing a video 53,760 Posts self-declared as adult by their author 24,654 (0.73%) Adult labels applied by labelers 51,690 Non-adult posts 99.27%
At first, 0.73% does not look particularly alarming.
It is less than one percent.
But percentages can be misleading when the underlying stream contains several million posts. In this case, less than one percent still meant nearly 25,000 self-declared adult posts in a single day.
For a platform that is specifically built around discovering and watching videos, that is already large enough that moderation cannot be treated as a secondary feature.
"0.73%" Means Nearly 25,000 Posts a Day
During that 24-hour window, the 24,654 posts that were self-declared as adult were distributed across several categories:
porn: 17,710;sexual: 3,866;graphic-media: 2,432;nudity: 2,304.
The exact distribution will obviously change over time, but the important point is the scale.
At these volumes, the problem stops being theoretical very quickly. If a comparable rate were maintained over a week, we would be looking at more than 170,000 self-declared adult posts.
And from the perspective of a video product, the issue is not only the total number.
A single publication appearing unexpectedly in a discovery feed, search result, recommendation or channel can be enough to damage the experience for the person using the platform.
That is why we decided very early that moderation had to be part of the ingestion architecture rather than something bolted onto the product later.
The Problem: Not All Adult Content Is Self-Declared
This is where things became more complicated.
We compared the declarations made by authors with labels subsequently applied by labelers, including mod.bsky.app.
For the two main categories, our 24-hour measurements looked like this:
Label | Self-declared by author | Applied by labelers |
|---|---|---|
porn | 17,710 | 39,099 |
sexual | 3,866 | 11,492 |
These numbers should not be interpreted as a direct count of unique posts. Labelers produce labels rather than a clean unique-post dataset, and the stream can also contain labels applied to accounts.
Even with that limitation, however, the difference is large enough to make one thing very clear:
self-declaration alone is not enough.
A system based only on:
adult self-label ā excludewould inevitably let some content through simply because the author had not declared it.
That realization is one of the reasons our moderation system became layered.
We did not want one magic rule.
We wanted several imperfect signals that could be combined, compared and adjusted over time.
Adult Content Fights for Visibility
There is another part of the problem that is easy to miss if you only look at moderation labels: discoverability.
On a network where people use hashtags and other mechanisms to make their posts easier to find, those mechanisms themselves become useful signals for a discovery system.
During one 24-hour measurement window, the most frequently observed hashtags included:
#dlsiteā 11,938#nowplayingā 7,176#nsfwā 6,218#artā 5,937#newsā 4,823
Two of the five most-used hashtags in that particular measurement window were therefore directly associated with adult content.
Again, the point is not that these numbers are a permanent ranking of the Bluesky network. They are a snapshot from our own indexing measurements.
What matters is what that snapshot tells us about the architecture.
Adult-oriented content does not necessarily sit quietly in an isolated corner of the network. It has its own vocabulary, its own communities, its own labels, and explicit mechanisms for being discovered and distributed.
For a general-purpose video aggregator, that changes the problem considerably.
Adult content can naturally make its way into discovery.
That is what we mean by calling it a "dark pillar": it is significant and structured enough that a serious video platform needs to account for it from the beginning, rather than discovering the problem after the product is already live.
How We Did It: A Layered Moderation Pipeline
The more we worked on the system, the more obvious it became that no single moderation signal was going to be reliable enough.
Self-labels are incomplete.
Keywords are fast, but they create false positives.
An image classifier can make mistakes.
Community lists can be extremely useful, but they can also be noisy or reflect someone else's moderation policy.
So instead of asking one system to make every decision, we built a pipeline where several layers contribute evidence.
The principle is straightforward:
each layer compensates for some of the weaknesses of the others.
We begin with checks that are cheap and fast enough to run directly in the indexer. As the signal becomes more ambiguous, we move toward more expensive analysis.
This is important not only for accuracy, but also for infrastructure costs. There is little point in running visual analysis on a post that we already know should not be indexed because it is not actually a video.
Layer 0 ā What Actually Counts as a Video?
The first question is almost embarrassingly basic:
is this really a video that we want to index?
The answer is not always as obvious as it sounds.
For every relevant firehose event, the indexer checks the collection, the embed type, the blob reference and the way the media is presented. We also explicitly exclude GIF presentations that can otherwise appear close enough to video content to confuse a simplistic ingestion pipeline.
// indexer/src/filter.js
const POST_COLLECTION = 'app.bsky.feed.post';
const VIDEO_EMBED_TYPE = 'app.bsky.embed.video';
export function interpretCommit(did, commit) {
if (commit.collection !== POST_COLLECTION) return null;
if (commit.operation === 'delete') {
return { type: 'delete', did, rkey: commit.rkey };
}
const record = commit.record;
const isGifPresentation = record?.embed?.presentation === 'gif';
if (
!record ||
record.embed?.$type !== VIDEO_EMBED_TYPE ||
isGifPresentation
) {
return commit.operation === 'update'
? { type: 'delete', did, rkey: commit.rkey }
: null;
}
// ⦠builds the payload:
// text, hashtags, language, self-labels, thread root
}This is not the most exciting part of the system, but it prevents a surprisingly fundamental class of errors.
Before asking whether a piece of media is appropriate, we first need to be certain that we are looking at the kind of media we actually intended to process.
Layer 1 ā The Keyword Pre-Filter
The second layer is intentionally simple.
The indexer runs the post through a keyword filter divided into two categories: hard and soft.
The distinction exists because not every word has the same meaning in every context.
hard
A single matching word can be enough to exclude a post.
Examples include:
porn
hentai
onlyfanssoft
These are words that can be associated with adult content but can also appear in completely legitimate contexts.
Examples include:
sex
nude
bdsm
escortA single soft signal sends the post to review, while two distinct soft signals can be enough to trigger exclusion.
// indexer/src/moderation.js
export function decideKeywords({
hardMatches,
softMatches,
candidateHashtagMatches
}) {
if (hardMatches.length >= 1 || softMatches.length >= 2) {
return 'exclude';
}
if (
softMatches.length === 1 ||
candidateHashtagMatches.length >= 1
) {
return 'review';
}
return 'clean';
}The nice thing about this layer is that it is cheap, transparent and easy to change.
The less nice thing is that it is also very easy to overestimate what it can accomplish.
Across nearly 38,000 videos processed by this filter during our 24-hour measurement window, only 1.67% were flagged:
- 377 for exclusion;
- 257 for review;
- 37,367 considered clean by keywords alone.
That tells us something useful.
Keywords are valuable as a first net, but they are not a moderation system by themselves.
A filter that sees only a small part of the problem is useful precisely because it is only the first layer.
Layer 2 ā Visual Analysis
Eventually, we had to stop looking only at what people wrote and start looking at what the video actually contained.
A video can contain no problematic word at all and still contain explicit imagery. The reverse can also happen: a word that looks suspicious in isolation can appear in an entirely legitimate context.
That is why we added visual analysis.
The indexer retrieves the elements necessary for analysis from the video infrastructure and sends them through an NSFW classifier based on NSFWJS / TensorFlow.
The score is intentionally weighted rather than treating every category in exactly the same way:
// indexer/src/nsfwClassifier.js
const cat = Object.fromEntries(
predictions.map((p) => [p.className, p.probability])
);
const score =
(cat.Porn ?? 0) +
(cat.Hentai ?? 0) +
0.5 * (cat.Sexy ?? 0);
return Math.min(1, Number(score.toFixed(4)));The Sexy category is deliberately weighted at 50%.
That distinction matters because an image of someone wearing a swimsuit, or an image that is simply suggestive, should not automatically be treated as equivalent to explicitly sexual imagery.
But visual analysis introduces its own problem.
A thumbnail is only one image.
It might be a title card, a black screen, a logo, an unrelated image, or simply a frame taken during a harmless moment of a much longer video.
So a single thumbnail can produce both false positives and false negatives.
Multi-Frame Analysis
The obvious next step was to stop trusting a single frame.
Using ffmpeg, we sample several images distributed throughout the video. In our current implementation, four frames are analyzed and we keep the second-highest score.
Why not simply use the maximum?
Because one strange or anomalous frame can create an artificial spike and make an otherwise acceptable video look much worse than it really is.
Why not use the average?
Because the opposite problem appears: a single relevant frame can disappear into several neutral ones and become statistically insignificant.
The second-highest score gives us a useful compromise between those two extremes. It is not a perfect solution, but in practice it gives us a much more representative signal than trusting a single thumbnail.
We Do Not Store the Videos
This part is worth explaining because it is easy to misunderstand what "we do not store the videos" means technically.
When we need to determine the duration of a video, we do not download the whole video and keep a copy of it. We read the HLS .m3u8 manifest and use its #EXTINF entries to calculate the duration.
For visual moderation, however, the situation is slightly different.
To analyze several points in the video, the indexer temporarily retrieves the HLS fragments corresponding to the frames we need, extracts those frames, runs the classifier, and then discards the fragments.
Those temporary fragments are an implementation detail of the analysis process.
They are not stored as videos, and they are not added to our media catalog as a permanent copy of the original content.
This distinction is important to the architecture because it means our moderation system can inspect media without turning Bluvy into another permanent video-storage service.
It also keeps the responsibilities clearer: the underlying network handles media storage and delivery, while our infrastructure handles indexing and the processing required to build the Bluvy experience.
Layer 3 ā A Weighted Risk Score
Once all of these signals exist, the next question is how to combine them.
We could have created a long chain of independent gates:
label?
ā
keyword?
ā
AI?
ā
list?That approach works initially, but it becomes increasingly difficult to maintain as the number of signals grows.
Instead, we convert the different signals into a single risk score from 0 to 100.
Each signal has a weight:
// symfony/src/Moderation/ModerationRiskEvaluator.php
private const W_SELF_LABEL = 100;
private const W_ACCOUNT_LABEL = 100;
private const W_BIO = 85;
private const W_THUMB_HIGH = 78;
private const W_THUMB_MID = 55;
private const W_THUMB_LOW = 40;
private const W_HARD_KEYWORD = 78;
private const W_SOFT_KEYWORD = 35;
private const W_THREAD_ROOT = 55;
// score >= 70 ā Exclude
// score >= 40 ā Human review
// otherwise ā OKThe important idea is not that these numbers are perfect.
They are deliberately tunable.
Accumulation
Suppose a thumbnail produces a score of 0.88.
That might contribute 40 points.
If the same video also contains a soft keyword worth 35 points, the combined score becomes:
40 + 35 = 75The result now crosses the exclusion threshold.
Neither signal had to be perfect on its own. The combination is what makes the decision meaningful.
Corroboration
At the same time, we do not want one automated system to become an unquestionable authority.
An AI classifier can be wrong. A keyword can be ambiguous. A moderation list can reflect a community opinion rather than an objective fact.
For that reason, we prefer independent signals that corroborate one another wherever possible.
The system also distinguishes between confidence levels:
- strong signal ā exclusion;
- intermediate signal ā human review;
- insufficient signal ā publication retained.
The objective is not to remove as much content as possible.
The objective is to remove the right content, while keeping the rest of the catalog useful.
Layer 4 ā The Wisdom of Crowds
AT Protocol also gives us another useful mechanism: community moderation lists.
We can subscribe to relevant lists through app.bsky.graph.list, periodically retrieve their contents, and turn membership into another signal in our moderation pipeline.
We mainly use two modes.
signal
Membership contributes evidence, but does not automatically determine the result.
For example:
list A
+
list B
+
Bluesky label
=
sufficiently corroborated signalThis makes an isolated community list less powerful while still allowing several independent sources to reinforce one another.
exclude
For lists that we consider particularly reliable, membership can be enough to trigger exclusion.
We use this mode much more carefully because it gives a single external source considerably more influence over the final decision.
Lists Have Their Own Problems
A moderation list containing 150,000 members cannot realistically be treated as a single API response.
It has to be retrieved page by page, which means the synchronization process itself can fail partway through.
Our client is therefore designed to tolerate partial results.
If one page cannot be read, we do not throw away everything that came before it. If a synchronization fails after several successful pages, we keep the data already retrieved and allow a later pass to complete the job.
// symfony/src/Atproto/AppViewListClient.php
// Page 1 unreadable ā null, no action
// Failure partway through ā AppViewListResult {
// dids: [...already retrieved],
// complete: false
// }This is a small implementation detail, but it reflects a broader principle that appears throughout the project:
a partial failure should not automatically become a global decision.
If one external API call fails, that should not suddenly mean that 150,000 accounts have changed status.
Layer 5 ā The Feedback Loop
The last layer is the one that makes the previous layers sustainable.
Moderation rules are not something you write once and then leave untouched forever.
Labels can arrive after the original indexing event. A keyword that looked useful can turn out to create too many false positives. A community list can change over time. An account can change its behavior.
The network changes, and our moderation system has to be able to change with it.
We therefore run periodic scans over the active catalog. Every night, part of the indexed content is re-evaluated using the latest information available to us, including official labels and engagement signals.
Cases that look like possible false positives are proposed to the administrator rather than being automatically restored.
Every decision is also recorded in a log.
Once a week, we can then look at false positives by rule and see which parts of the system are causing the most noise.
If a keyword repeatedly produces too many false positives, it can automatically be downgraded from hard to soft.
That may sound like a small optimization, but it changes how the moderation system is maintained.
It means the system is not only making decisions.
It is also measuring the quality of those decisions.
False Positives: Moderation That Does Not Break the Platform
One of the easiest mistakes to make when building moderation is to define success as "we blocked everything we were worried about."
That is not success.
A platform that aggressively blocks legitimate content will eventually become just as frustrating as a platform that lets everything through.
For us, "professional" moderation means something closer to predictable, measurable, explainable and sufficiently precise.
That is why we progressively added several safeguards.
Contextual exceptions
Words do not have one universal meaning.
A keyword that looks suspicious in isolation can be completely harmless when it appears as part of a legitimate name, expression or sentence.
For example:
BBC News
potty mouth
word of mouth
sailor mouthshould obviously not be treated as four instances of the same adult signal.
We therefore maintain an editable context table that can neutralize certain matches when the surrounding context makes them legitimate.
Downgrading overly broad keywords
Some words produced exactly the kind of false positives we expected from a lexical filter that was too aggressive.
Terms such as:
mouth
dominate
taboo
dogloverscould trigger an exclusion even when they appeared in Twitch clips, performances, discussions or animal-related content.
We therefore downgraded them to soft.
They still contribute to the overall score, but they can no longer determine the fate of a video on their own.
Creator confidence score
Not every account has the same history.
An account with a healthy history, no previous exclusions and enough positive trust signals should not necessarily be treated exactly like a brand-new account with no history at all.
Our creator confidence score allows us to take that context into account when interpreting ambiguous signals.
The principle is simple:
the same weak signal can mean different things depending on the context in which it appears.
Bluvy-verified creators
Creators verified by Bluvy are also handled differently.
We do not want an automated rule to silently make an established creator's entire catalog disappear because one questionable item triggered a classifier.
A piece of content can be placed on hold while it is reviewed.
But the final decision remains human.
Editorial Quality, Not Just Safety
Moderation is only part of the problem.
Once you build a discovery product, you also have to decide what deserves to be shown and how it should be ranked.
Some of our rules therefore have nothing to do with adult content.
Very short clips
Videos shorter than five seconds are removed during indexing.
The duration is obtained from the HLS manifest rather than by maintaining our own permanent copy of the video.
This is not intended as a punishment for short-form content. It is an editorial decision designed to keep the catalog from being overwhelmed by content that is too short to provide the kind of video experience we are trying to build.
Deleted videos
If a creator deletes a video on Bluesky, Bluvy needs to eventually reflect that deletion.
Periodic scans therefore check for content that is no longer available and remove it from our index.
Trends
Very short videos can also produce unusual behavior in ranking systems because a loop lasting a few seconds can naturally generate a large number of repetitions.
For that reason, we apply a penalty to extremely short videos in certain ranking mechanisms.
We are not banning them.
We simply do not want a general-purpose discovery system to become dominated by a handful of tiny loops.
A European Choice
Bluvy Tube is built by a French team, so the European dimension is obviously part of the project.
But the goal is not to build "the European version" of an existing American video platform.
We are not trying to reproduce the same closed infrastructure with a different logo.
The interesting question for us is whether it is possible to build a serious video experience while keeping the application layer separate from the underlying network infrastructure.
That leads to a few principles.
Do not host what we do not need to host
We build an index from public content available on the network rather than maintaining our own permanent video library.
When our moderation system temporarily retrieves HLS fragments for analysis, those fragments are processed and discarded. They are not turned into a permanent copy of the source media.
Minimizing data by design is much simpler than trying to minimize it after the fact.
Build on top of an open protocol
Creator identity relies on AT Protocol primitives, including DIDs.
That creates a fundamentally different relationship between the application and its users than a completely closed platform.
Bluvy Tube can provide the interface, indexing and discovery experience without becoming the owner of the underlying network.
We are building a layer on top of the network.
We do not want to own the network itself.
Transparency
This is also why we are comfortable publishing technical articles like this one.
The measurements in this article come from our own production infrastructure and describe problems we actually encountered while building the service.
The solutions are not presented as final or perfect.
They have to evolve because the network evolves, the content changes, and our understanding of what makes a good video experience changes with it.
What We Are Actually Building
At first, the idea of a video platform that does not permanently store the videos itself can sound almost contradictory.
After building the system, however, the distinction becomes much clearer.
A video platform does not necessarily have to own the video file.
It has to be able to find relevant videos, understand their context, filter what should not be shown, present the content correctly, rank what deserves to be discovered, and react when the underlying information changes.
AT Protocol provides some of the foundations that make this possible.
The rest is engineering.
And a surprisingly large part of that engineering is about dealing with the realities of an open network: events arrive continuously, labels can change, content can disappear, external services can fail, and the network itself does not know anything about our editorial policy.
A firehose does not know our audience.
It does not know which videos we want to show.
It does not know what Bluvy Tube should look like.
It simply gives us the events.
We build the system that turns those events into a product.
That is ultimately what Bluvy Tube is.
It is not another closed garden, and it is not another company trying to own every layer of video infrastructure.
It is a video experience built on top of an open network, with Bluvy responsible for the parts that make that network useful to a video audience: discovery, indexing, presentation, ranking and moderation.
And if there is one lesson that stands out after building all of this, it is probably this:
When the network is open, moderation is not a feature you add at the end. It is part of the architecture.
That is the problem Bluvy Tube is designed to solve.
And we are still building it.